Testing
Level: Intermediate 📘 | Time: 20-30 min | Prerequisites: Basics, Mediator Pipeline
Good Trellis tests read like the production code they protect: success and failure are values, authorization is explicit, and integration tests exercise the real pipeline.
Trellis.Testing gives you purpose-built assertions, fakes, and test helpers for that style.
Installation
dotnet add package Trellis.Testing
What problem does this solve?
The package helps you avoid common test friction:
- noisy assertions against
Result<T>andMaybe<T> - brittle repository mocks
- hand-built actor headers for integration tests
- repeated boilerplate for test errors and validation failures
Start here: result assertions
When a handler returns Result<T>, assert on the result directly instead of unpacking booleans yourself.
using FluentAssertions;
using Trellis;
using Trellis.Testing;
var success = Result.Success(42);
success.Should().BeSuccess().Which.Should().Be(42);
var failure = Result.Failure<int>(Error.NotFound("Order 123 not found", "123"));
failure.Should().BeFailureOfType<NotFoundError>();
failure.Should().HaveErrorCode("not.found.error");
failure.Should().HaveErrorDetail("Order 123 not found");
Async assertions
This detail matters: async assertions are extensions on Task<Result<T>> and ValueTask<Result<T>> directly.
using System.Threading.Tasks;
using FluentAssertions;
using Trellis;
using Trellis.Testing;
Task<Result<int>> taskResult = Task.FromResult(Result.Success(42));
ValueTask<Result<int>> valueTaskResult = ValueTask.FromResult(Result.Success(7));
(await taskResult.BeSuccessAsync()).Which.Should().Be(42);
(await valueTaskResult.BeSuccessAsync()).Which.Should().Be(7);
Warning
Do not write await result.Should().BeSuccessAsync(). The async helpers are not extensions on ResultAssertions<T>.
Maybe<T> and Error assertions
Use the Trellis-specific assertions so failures explain the intent of the test.
using FluentAssertions;
using Trellis;
using Trellis.Testing;
var maybeName = Maybe.From("Ada");
maybeName.Should().HaveValue().Which.Should().Be("Ada");
var none = Maybe<string>.None;
none.Should().BeNone();
var error = Error.Validation("Email is required.", "email");
error.Should().HaveCode("validation.error");
error.Should().HaveDetailContaining("required");
FakeRepository: fast handler tests without mocks
If you are testing application handlers, FakeRepository<TAggregate, TId> is usually better than mocking repository calls by hand.
using FluentAssertions;
using Trellis;
using Trellis.Testing;
using Trellis.Testing.Fakes;
public sealed record OrderId(Guid Value);
public sealed class Order : Aggregate<OrderId>
{
public Order(OrderId id, string email) : base(id) => Email = email;
public string Email { get; }
}
var repo = new FakeRepository<Order, OrderId>()
.WithUniqueConstraint(order => order.Email);
var order = new Order(new OrderId(Guid.NewGuid()), "ada@example.com");
await repo.SaveAsync(order).BeSuccessAsync();
(await repo.GetByIdAsync(order.Id)).Should().BeSuccess().Which.Should().BeSameAs(order);
repo.Exists(order.Id).Should().BeTrue();
repo.Count.Should().Be(1);
Important details from the current API:
SaveAsyncreturnsTask<Result<Unit>>DeleteAsyncreturnsTask<Result<Unit>>- unique constraint conflicts return
ConflictError - missing aggregates use details like
"{AggregateTypeName} with ID {id} not found"
TestActorProvider: authorization tests without plumbing
When you want to test permission checks or resource ownership, use TestActorProvider.
using FluentAssertions;
using Trellis.Authorization;
using Trellis.Testing.Fakes;
var actorProvider = new TestActorProvider("admin", "Orders.Read", "Orders.Write");
await using var scope = actorProvider.WithActor("user-1", "Orders.Read");
var actor = await actorProvider.GetCurrentActorAsync();
actor.Id.Should().Be("user-1");
actor.HasPermission("Orders.Read").Should().BeTrue();
actor.HasPermission("Orders.Write").Should().BeFalse();
There is also an overload that accepts a full Actor, which is useful when you need:
ForbiddenPermissionsAttributes
Building failures for tests
ResultBuilder keeps intent obvious and removes repetitive error construction.
using Trellis.Testing.Builders;
var success = ResultBuilder.Success(42);
var notFound = ResultBuilder.NotFound<int>("Order", "123");
var forbidden = ResultBuilder.Forbidden<int>("Not allowed.");
ResultBuilder.NotFound<T>("Order", "123") produces:
- detail:
Order 123 not found - instance:
123
No extra quotes are added.
HTTP integration tests with actor headers
When your app uses DevelopmentActorProvider, CreateClientWithActor is the easiest way to exercise authorization through the HTTP pipeline.
Convenience overload
using Microsoft.AspNetCore.Mvc.Testing;
using Trellis.Testing;
public sealed class Program
{
}
WebApplicationFactory<Program> factory = default!;
var client = factory.CreateClientWithActor("user-1", "Orders.Create", "Orders.Read");
Full Actor overload
Use this when the test needs more than id + granted permissions.
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc.Testing;
using Trellis.Authorization;
using Trellis.Testing;
public sealed class Program
{
}
WebApplicationFactory<Program> factory = default!;
var actor = new Actor(
id: "user-1",
permissions: new HashSet<string> { "Orders.Read" },
forbiddenPermissions: new HashSet<string> { "Orders.Delete" },
attributes: new Dictionary<string, string> { ["tenant"] = "acme" });
var client = factory.CreateClientWithActor(actor);
Note
The X-Test-Actor header includes Id, Permissions, ForbiddenPermissions, and Attributes. That matches what DevelopmentActorProvider expects.
Replacing infrastructure in integration tests
Two helpers are especially useful in WebApplicationFactory-based tests.
Swap the EF Core provider
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Trellis.Testing;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
}
public sealed class Program
{
}
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _connection = new("DataSource=:memory:");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
_connection.Open();
builder.ConfigureServices(services =>
services.ReplaceDbProvider<AppDbContext>(options => options.UseSqlite(_connection)));
}
protected override void Dispose(bool disposing)
{
_connection.Dispose();
base.Dispose(disposing);
}
}
Warning
ReplaceDbProvider<TContext> re-registers the context via AddDbContext<TContext>. If your app uses AddDbContextFactory or AddPooledDbContextFactory, replace those registrations directly instead.
Replace a resource loader
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Authorization;
using Trellis.Testing;
public sealed record GetOrderQuery(string OrderId);
public sealed record OrderResource(string Id);
public sealed class FakeOrderLoader : IResourceLoader<GetOrderQuery, OrderResource>
{
public Task<Result<OrderResource>> LoadAsync(
GetOrderQuery message,
CancellationToken cancellationToken) =>
Task.FromResult(Result.Success(new OrderResource(message.OrderId)));
}
var services = new ServiceCollection();
services.ReplaceResourceLoader<GetOrderQuery, OrderResource>(_ => new FakeOrderLoader());
Practical guidance
Prefer fakes over mocks for repositories
FakeRepository behaves more like the real thing:
- not-found behavior
- unique constraints
- domain event capture
Assert on error codes when it matters
Trellis default error codes end in .error, which makes them good assertion targets.
Use the actor overload when authorization rules are richer
If your policy uses forbidden permissions or attributes, pass a real Actor to CreateClientWithActor.
Keep Entra token tests separate
Use CreateClientWithActor for fast local and CI tests. Use real tokens only when you need to validate the full authentication pipeline.