Nexo

Documentação

Testes

Handler isolado com um fake de uma linha, o pipeline inteiro com Nexo.Testing sobre SQLite, e os bancos de verdade em containers.

Duas camadas de teste

O que você quer provarComo
A lógica do handlerInstancie a classe e passe fakes. Cada operação é uma interface de um método.
Que o comando grava, é atômico e despacha eventosNexoTestHost, do pacote Nexo.Testing: container real, pipelines reais, SQLite em memória. É a forma barata de provar rollback.
Que o seu mapeamento funciona no banco de produçãoO mesmo host sobre o provider real, num container (Testcontainers). Mais lento; reserve para o que depende do provider.

Handler isolado

A granularidade das operações paga aqui: um fake de ICreate<Product> tem um método. Sem mock de DbContext, sem DbSet falso, sem biblioteca de mocking se você não quiser.

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);
}

O pipeline inteiro

Shell
dotnet add package Nexo.Testing

NexoTestHost monta o container como a aplicação monta — mediator, pipelines, unit of work — sobre SQLite em memória, cria o schema e dá a cada mensagem um escopo próprio, como um request. A validação de escopos fica ligada: um singleton que captura algo scoped falha no teste antes de falhar em produção.

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 });

    // O que chegou aos handlers, em ordem — comandos, queries e eventos.
    host.Dispatched.ShouldHaveDispatched<ProductCreated>(e => e.ProductId == id);

    // Lido de um contexto novo: nada pode vir de um change tracker.
    var stored = await host.QueryAsync<CatalogDbContext, Product>(db =>
        db.Products.AsNoTracking().SingleAsync(p => p.ExternalId == id));

    stored.CreatedBy.ShouldBe("alice");
}
PeçaO que é
NexoTestHost.StartAsync<TContext>(nexo, services, session)O host. Os três callbacks são os mesmos da aplicação.
host.SendAsync / FetchAsync / BroadcastAsyncUma mensagem, num escopo próprio.
host.QueryAsync<TContext, T>Lê o banco por um contexto novo, sem passar pelo Nexo.
host.DispatchedDispatchLog: Of<T>(), ShouldHaveDispatched<T>(predicate), ShouldNotHaveDispatched<T>(), Clear().
host.UserO usuário ambiente; troque UserId para agir como outra pessoa.
services.AddNexoSqliteInMemory<TContext>()Registra um contexto sobre um SQLite em memória próprio, com o interceptor instalado.
nexo.AddDispatchRecording() / nexo.UseFixedCurrentUser()As mesmas peças, para quem monta o próprio host.

Testando com vários bancos

Mesmo host, com um UseDbContext<T>() e um AddNexoSqliteInMemory<T>() a mais. Cada contexto ganha o seu banco SQLite independente — não o mesmo arquivo, senão o SQLite envolveria as duas escritas na mesma transação e todo teste de cruzamento passaria pelo motivo errado.

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

Com isso, os testes que valem a pena são os que provam o roteamento e a fronteira do unit of work:

C#
[Fact]
public async Task Cada_entidade_vai_para_o_contexto_que_a_mapeia()
{
    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 Um_comando_que_falha_nao_grava_em_nenhum_dos_dois()
{
    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 As_opcoes_de_sessao_sao_por_contexto()
{
    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;
    });
}

Os testes que valem a pena

Tirados da suíte do próprio Nexo, cujo TestHost faz o mesmo que o NexoTestHost — QueryDatabaseAsync é o QueryAsync dele:

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);   // a chave foi atribuída no 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,   // a primeira escrita já aconteceu
        }));

    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 });

    // O handler do evento criou a linha filha, na transação do comando.
    var categories = await host.QueryDatabaseAsync(db =>
        db.Categories.AsNoTracking().Select(c => c.Name).ToListAsync());

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

Congelando o tempo

O interceptor de auditoria usa o TimeProvider registrado. Substitua e as colunas ficam determinísticas.

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 = "Depois" });

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

O usuário ambiente é igualmente substituível:

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

// Montando o seu host:
services.AddNexo<CatalogDbContext>(nexo => nexo.UseFixedCurrentUser("alice"));

Os bancos de verdade

O SQLite prova a lógica; não prova o provider. Tipo de coluna, tradução de LINQ, código de erro de chave duplicada e o comportamento da transação são do banco. Para o que depende disso, suba o banco num container com Testcontainers e registre o contexto com o provider real — o resto do host não muda:

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

// Um banco por teste, no mesmo servidor, para os testes rodarem em paralelo.
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));

É assim que a suíte Nexo.IntegrationTests do repositório roda contra SQL Server, MySQL, PostgreSQL e MongoDB — standalone e replica set — com um container de cada, compartilhado pela execução inteira.

Rodando a suíte

Shell
# Os testes unitários: rápidos, sem Docker
dotnet test

# Os de integração: precisam de Docker, ficam fora da solution
dotnet test --project tests/Nexo.IntegrationTests

Projetos xunit v3 são executáveis sobre Microsoft.Testing.Platform, e o global.json do repositório seleciona esse runner para o dotnet test. Rodar o executável direto (dotnet run --project tests/Nexo.Tests) também funciona, e aceita os filtros do xunit.