Nexo

Documentation

Testing

A handler on its own with a one-line fake, the whole pipeline with Nexo.Testing over SQLite, and the real databases in containers.

Two layers of test

What you want to proveHow
The handler's logicConstruct the class and pass fakes. Each operation is a one-method interface.
That the command writes, is atomic and dispatches eventsNexoTestHost, from the Nexo.Testing package: a real container, real pipelines, SQLite in memory. It is the cheap way to prove rollback.
That your mapping works on the production databaseThe same host over the real provider, in a container (Testcontainers). Slower; save it for what depends on the provider.

A handler on its own

The granularity of the operations pays off here: a fake for ICreate<Product> has one method. No DbContext mock, no fake DbSet, no mocking library unless you want one.

C#
private sealed class FakeCreate<T> : ICreate<T> where T : class, IEntity
{
    public List<T> Created { get; } = [];

    public Task<T> CreateAsync(T entity, CancellationToken ct = default)
    {
        Created.Add(entity);
        return Task.FromResult(entity);
    }
}

[Fact]
public async Task Creates_the_product_with_the_category_key()
{
    var create = new FakeCreate<Product>();
    var categories = new FakeReadByExternalId<Category>(new Category { Id = 7 });
    var handler = new CreateProductHandler(create, categories);

    var id = await handler.HandleAsync(
        new CreateProduct { Sku = "A1", Price = 10m },
        TestContext.Current.CancellationToken);

    id.ShouldNotBe(Guid.Empty);
    create.Created.Single().CategoryId.ShouldBe(7);
}

The whole pipeline

Shell
dotnet add package Nexo.Testing

NexoTestHost assembles the container the way the application does — mediator, pipelines, unit of work — over in-memory SQLite, creates the schema and gives each message its own scope, like a request. Scope validation stays on: a singleton capturing something scoped fails in the test before it fails in production.

C#
[Fact]
public async Task Creating_a_product_records_its_first_price()
{
    await using var host = await NexoTestHost.StartAsync<CatalogDbContext>(nexo => nexo
        .AddHandlersFromAssemblyOf<CreateProductHandler>()
        .AddValidatorsFromAssemblyOf<CreateProductHandler>());

    host.User.UserId = "alice";
    var id = await host.SendAsync(new CreateProduct { Sku = "A1", Price = 10m });

    // What reached the handlers, in order — commands, queries and events.
    host.Dispatched.ShouldHaveDispatched<ProductCreated>(e => e.ProductId == id);

    // Read from a fresh context: nothing can come from a change tracker.
    var stored = await host.QueryAsync<CatalogDbContext, Product>(db =>
        db.Products.AsNoTracking().SingleAsync(p => p.ExternalId == id));

    stored.CreatedBy.ShouldBe("alice");
}
PieceWhat it is
NexoTestHost.StartAsync<TContext>(nexo, services, session)The host. The three callbacks are the application's.
host.SendAsync / FetchAsync / BroadcastAsyncOne message, in its own scope.
host.QueryAsync<TContext, T>Reads the database through a fresh context, bypassing Nexo.
host.DispatchedDispatchLog: Of<T>(), ShouldHaveDispatched<T>(predicate), ShouldNotHaveDispatched<T>(), Clear().
host.UserThe ambient user; change UserId to act as somebody else.
services.AddNexoSqliteInMemory<TContext>()Registers a context over its own in-memory SQLite, with the interceptor installed.
nexo.AddDispatchRecording() / nexo.UseFixedCurrentUser()The same pieces, for anyone assembling their own host.

Testing with several databases

The same host, with one more UseDbContext<T>() and one more AddNexoSqliteInMemory<T>(). Each context gets its own independent SQLite database — not the same file, or SQLite would wrap both writes in the same transaction and every cross-database test would pass for the wrong reason.

C#
await using var host = await NexoTestHost.StartAsync<CatalogDbContext>(
    nexo => nexo
        .UseDbContext<BillingDbContext>()
        .AddHandlersFromAssemblyOf<SellProductHandler>(),
    services => services.AddNexoSqliteInMemory<BillingDbContext>());

With that in place, the tests worth writing are the ones that prove routing and the boundary of the unit of work:

C#
[Fact]
public async Task Each_entity_goes_to_the_context_that_maps_it()
{
    await using var host = await MultiStoreHost.StartAsync();

    await host.SendAsync(new SellProduct { Code = "SKU-1", Price = 42m });

    (await host.QueryCatalogAsync(db => db.Products.CountAsync())).ShouldBe(1);
    (await host.QueryBillingAsync(db => db.Invoices.CountAsync())).ShouldBe(1);
}

[Fact]
public async Task A_failing_command_writes_to_neither()
{
    await using var host = await MultiStoreHost.StartAsync();

    await Should.ThrowAsync<InvalidOperationException>(
        host.SendAsync(new SellProduct { Code = "SKU-2", ThrowBeforeSave = true }));

    (await host.QueryCatalogAsync(db => db.Products.CountAsync())).ShouldBe(0);
    (await host.QueryBillingAsync(db => db.Invoices.CountAsync())).ShouldBe(0);
}

[Fact]
public async Task Session_options_are_per_context()
{
    await using var host = await MultiStoreHost.StartAsync(
        configureCatalogSession: session => session.SoftDelete = false);

    await host.InScopeAsync(sp =>
    {
        var sessions = sp.GetRequiredService<DbSessionProvider>();

        sessions.ForEntity<Product>().Options.SoftDelete.ShouldBeFalse();
        sessions.ForEntity<Invoice>().Options.SoftDelete.ShouldBeTrue();

        return Task.CompletedTask;
    });
}

The tests worth writing

Taken from Nexo's own suite, whose TestHost does what NexoTestHost does — QueryDatabaseAsync is its QueryAsync:

C#
[Fact]
public async Task A_handler_persists_without_ever_calling_save_changes()
{
    await using var host = await TestHost.StartAsync();

    var id = await host.SendAsync(new CreateProduct { Code = "A1", Price = 9.99m });

    var stored = await host.QueryDatabaseAsync(db =>
        db.Products.AsNoTracking().SingleOrDefaultAsync(p => p.ExternalId == id));

    stored.ShouldNotBeNull();
    stored.Id.ShouldBeGreaterThan(0);   // the key was assigned on save
}

[Fact]
public async Task A_failing_handler_leaves_nothing_behind()
{
    await using var host = await TestHost.StartAsync();

    await Should.ThrowAsync<InvalidOperationException>(() =>
        host.SendAsync(new CreateProductPair
        {
            FirstCode = "A1",
            SecondCode = "A2",
            ThrowAfterFirst = true,   // the first write already happened
        }));

    var count = await host.QueryDatabaseAsync(db => db.Products.CountAsync());
    count.ShouldBe(0);
}

[Fact]
public async Task A_query_never_saves_what_its_handler_wrote()
{
    await using var host = await TestHost.StartAsync();

    await host.FetchAsync(new MisbehavingQuery { Code = "GHOST" });

    var count = await host.QueryDatabaseAsync(db => db.Products.CountAsync());
    count.ShouldBe(0);
}

[Fact]
public async Task Domain_events_are_dispatched_and_their_writes_are_committed()
{
    await using var host = await TestHost.StartAsync();
    var id = await host.SendAsync(new CreateProduct { Code = "A1", Price = 10m });

    await host.SendAsync(new RepriceProduct { ProductId = id, Price = 25m });

    // The event handler created the child row, in the command's transaction.
    var categories = await host.QueryDatabaseAsync(db =>
        db.Categories.AsNoTracking().Select(c => c.Name).ToListAsync());

    categories.ShouldBe(["repriced-25"]);
}

Freezing time

The audit interceptor uses the registered TimeProvider. Replace it and the columns become deterministic.

C#
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero));
services.AddSingleton<TimeProvider>(clock);

// ...

clock.Advance(TimeSpan.FromMinutes(5));
await host.SendAsync(new RenameProduct { ProductId = id, Name = "After" });

var row = await host.QueryDatabaseAsync(/* ... */);
row.UpdatedOn.ShouldBe(row.CreatedOn.AddMinutes(5));

The ambient user is just as replaceable:

C#
// With NexoTestHost:
host.User.UserId = "alice";

// Assembling your own host:
services.AddNexo<CatalogDbContext>(nexo => nexo.UseFixedCurrentUser("alice"));

The real databases

SQLite proves the logic; it does not prove the provider. Column types, LINQ translation, the duplicate key error code and transaction behaviour belong to the database. For whatever depends on that, bring the database up in a container with Testcontainers and register the context with the real provider — the rest of the host does not change:

C#
private readonly MsSqlContainer _sqlServer =
    new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build();

// One database per test, on the same server, so the tests can run in parallel.
var connectionString = new DbConnectionStringBuilder
{
    ConnectionString = _sqlServer.GetConnectionString(),
    ["Database"] = $"test_{Guid.NewGuid():N}",
}.ConnectionString;

services.AddDbContext<CatalogDbContext>((sp, options) => options
    .UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure())
    .UseNexo(sp));

That is how the repository's Nexo.IntegrationTests suite runs against SQL Server, MySQL, PostgreSQL and MongoDB — standalone and replica set — with one container each, shared across the whole run.

Running the suite

Shell
# The unit tests: fast, no Docker
dotnet test

# The integration ones: they need Docker, and live outside the solution
dotnet test --project tests/Nexo.IntegrationTests

xunit v3 projects are executables on Microsoft.Testing.Platform, and the repository's global.json selects that runner for dotnet test. Running the executable directly (dotnet run --project tests/Nexo.Tests) works too, and accepts xunit's filters.