--- name: restworld-testing description: 'Write unit and integration-style tests for a RESTworld application with the RESTworld.Testing package. Use when asked to test a RESTworld service, mapper, authorization handler, validator or custom service; to set up TestBuilder, ITestEnvironment, ITestConfiguration; to use WithInMemoryDatabase, WithSeed, WithUser, WithoutUser, WithCrudService, WithReadService, WithCrudMapper, WithReadMapper, WithCrudAutoMapper, WithAutomapper, WithValidation, WithService, WithSubstitute, WithOptions, WithAction or BuildWithServiceAsSut; or to create a test project for a RESTworld API. Also handles: seeding an in-memory database, faking the current user, substituting dependencies with NSubstitute, and testing authorization/validation outcomes through the real service.' argument-hint: 'Name the class under test (service, mapper, authorization handler, validator) and the test framework in use (default MSTest). Optionally paste the class.' --- # RESTworld: Testing with RESTworld.Testing `RESTworld.Testing` (NuGet package, namespace `RESTworld.Testing`) builds a small DI container per test, wires the usual RESTworld dependencies for you and returns the class under test (the *SUT*). It references EF Core InMemory, NSubstitute, AutoFixture and console logging. It is test-framework agnostic; the examples use MSTest. ## When to Use - Testing a service derived from `CrudServiceBase` / `ReadServiceBase` / `DbServiceBase`, a mapper, an authorization handler or a validator - Setting up a test project for a RESTworld solution - Checking that an authorization handler or validator really changes the service result ## Key Rules 1. **Compose with `new TestBuilder()`**, chain `With…` extension methods, finish with `Build()`, `Build()` or `BuildWithServiceAsSut()`. Dispose the environment (`using var`), `ITestEnvironment` is `IDisposable`. 2. **`Build()`** registers `TSut` as singleton if nothing registered it, then `GetSut()` resolves it. All constructor dependencies of the SUT must have been added by `With…` calls. `ILogger` is always available (logging is added automatically). 3. **`BuildWithServiceAsSut()`** exists only directly after `WithCrudService(…)`, `WithReadService(…)` or `WithService()` (it returns `ITestEnvironment`). Do not insert other `With…` calls between. 4. **Order of registration does not matter for resolution**, but `WithSeed` must follow `WithInMemoryDatabase()` (it extends that builder). Seeding runs after the container is built. 5. **Each `WithInMemoryDatabase` gets a unique database name** (GUID) unless you pass one, so tests are isolated. 6. **Everything is registered as singleton.** 7. **Use `Parallel()`** (from `EntityFrameworkCore.Parallel`, `using Microsoft.EntityFrameworkCore;`) on `IDbContextFactory` in assertions: `await factory.Parallel().Set().CountAsync()`. 8. The InMemory provider is not SQL Server: it does not generate `Timestamp` row versions or enforce relational constraints. Test those against a real database separately. ## Builder cheat sheet | Extension | Registers | |---|---| | `WithInMemoryDatabase(string? databaseName = null)` (+ overloads taking seed data) | `IDbContextFactory` on EF InMemory | | `.WithSeed(params object?[])` / `.WithSeed(IEnumerable)` | Entities added after build | | `WithUser(string? name = null, string? authenticationType = null)`, `WithUser(IEnumerable?, string?)`, `WithUser(ClaimsIdentity)`, `WithUser(ClaimsPrincipal)` | `IUserAccessor` with that principal (default name `"TestName"`) | | `WithoutUser()` | `IUserAccessor` with an unauthenticated principal | | `WithCrudMapper(TMapper? instance = null)` | `ICrudMapper`, `IReadMapper` and `TMapper` | | `WithReadMapper(…)` | `IReadMapper` and `TMapper` | | `WithCrudAutoMapper<…6 types…>()` / `WithReadAutoMapper<…4 types…>()` | legacy AutoMapper-based mappers (need `WithAutomapper`; for code not yet migrated to Mapperly) | | `WithAutomapper(Action)` | `IMapper` (legacy AutoMapper only) | | `WithValidation()` | `IValidationService<,,>` (open generic) | | `WithValidation(IValidator<…> validator)` (also create-only / update-only overloads) | validation service + your validator instance | | `WithCrudService()` (+ shorter overloads) | `ICrudServiceBase<…>` → `CrudServiceBase<…>` | | `WithReadService()` (+ shorter overloads) | `IReadServiceBase<…>` → `ReadServiceBase<…>` | | `WithService()`, `WithService()`, `WithService(instance)`, `WithService(Func)` | any service | | `WithSubstitute(Action? configure = null)` then `.Configure(Action)` | an NSubstitute substitute | | `WithOptions(T options)` / `WithOptions(Action)` | `IOptions` | | `WithAction(Action)`, `WithAction(Action)`, `WithAction(configure, after)` | anything else (see below on overload resolution) | When using lambdas with `WithAction`, give the parameter a type (`(IServiceCollection s) => …`) to disambiguate the overloads. ## Examples (consumer test project, MSTest) ### Service test (CRUD service on an in-memory database) ```csharp [TestClass] public class BlogServiceTests { [TestMethod] public async Task Create_then_get_returns_the_blog() { using var environment = new TestBuilder() .WithInMemoryDatabase() .WithSeed(new Blog { Id = 1, Name = "First" }) .WithoutUser() .WithValidation() .WithCrudMapper() .WithCrudService() .BuildWithServiceAsSut(); var service = environment.GetSut(); var created = await service.CreateAsync(new BlogCreateDto { Name = "Second" }, CancellationToken.None); var missing = await service.GetSingleAsync(999, CancellationToken.None); Assert.IsTrue(created.Succeeded, created.ProblemDetails); Assert.AreEqual("Second", created.ResponseObject!.Name); Assert.AreEqual(HttpStatusCode.NotFound, missing.Status); } } ``` Without an authorization handler registered the service logs a warning once and allows everything. To test with a handler, add `.WithService, BlogpostAuthorizationHandler>()` (before `WithCrudService`) — then `service.GetSingleAsync(402, …)` returns `HttpStatusCode.PaymentRequired` through `BlogpostAuthorizationHandler`. ### Mapper test (Mapperly mappers need no container) ```csharp [TestMethod] public void MapCreateToEntity_copies_the_name() { var mapper = new BlogMapper(); var entity = mapper.MapCreateToEntity(new BlogCreateDto { Name = "x" }); var dto = mapper.MapEntityToFullExpression().Compile()(new Blog { Id = 3, Name = "n" }); Assert.AreEqual("x", entity.Name); Assert.AreEqual("n", dto.Name); } ``` For legacy AutoMapper-based mappers only, use `.WithAutomapper(cfg => cfg.CreateMap())` and resolve `IMapper` via `environment.GetRequiredService()`. ### Authorization handler test (call the handler directly) ```csharp [TestMethod] public async Task GetSingle_with_an_http_status_code_id_short_circuits() { using var environment = new TestBuilder().Build(); // needs only ILogger<> var handler = environment.GetSut(); var result = await handler.HandleGetSingleRequestAsync( AuthorizationResult.Ok(404), CancellationToken.None); Assert.AreEqual(HttpStatusCode.NotFound, result.Status); } [TestMethod] public async Task Response_for_post_42_is_a_problem() { using var environment = new TestBuilder().Build(); var response = await environment.GetSut().HandleGetSingleResponseAsync( ServiceResponse.FromResult(new PostGetFullDto { Id = 42 }), CancellationToken.None); Assert.AreEqual((HttpStatusCode)42, response.Status); } ``` Filters set with `WithFilter(...)` are applied via `result.Filter(queryable)`. For handlers that read the user (e.g. `UserIsAuthorizedCrudAuthorizationHandler<…>` or your own with `IUserAccessor`) add `.WithUser("alice")` or `.WithoutUser()` — the latter makes `HandleCreateRequestAsync(...)` return `HttpStatusCode.Unauthorized`. ### Validator test ```csharp [TestMethod] public async Task Headline_and_text_must_differ() { using var environment = new TestBuilder() .WithInMemoryDatabase() .Build(); var results = await environment.GetSut().ValidateBeforeCreateAsync( new PostCreateDto { AuthorId = 1, Headline = "a", Text = "A" }, CancellationToken.None); Assert.IsFalse(results.ValidationSucceeded); } ``` ### Substituting a dependency ```csharp using var environment = new TestBuilder() .WithSubstitute(s => s.GetPhotoAsync("a@b.c", Arg.Any()) .Returns(ServiceResponse.FromResult(new MemoryStream()))) .Build(); ``` ## Steps for a new test project 1. Create a test project (`net10.0`), add package references `RESTworld.Testing` and your test framework; reference your `.Business`, `.Data` and `.Common` projects. 2. Add one test class per service/handler/validator/mapper; build the environment inside each test (it is cheap). 3. Run `dotnet test`. ## Verification Checklist - [ ] Environment is created per test and disposed - [ ] `BuildWithServiceAsSut()` directly follows the `WithCrudService` / `WithReadService` / `WithService` call - [ ] All SUT constructor dependencies are registered (mapper, `IUserAccessor` via `WithUser`/`WithoutUser`, validation, authorization handler where relevant) - [ ] Database state is asserted with `factory.Parallel()` - [ ] No assertions rely on SQL Server-only behavior (rowversion, FK enforcement) ## Related - [Testing guide](https://github.com/wertzui/RESTworld/blob/main/doc/testing.md) - Skills `restworld-custom-service`, `restworld-authorization-handler`, `restworld-validation`, `restworld-create-mapper`