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 provar | Como |
|---|---|
| A lógica do handler | Instancie a classe e passe fakes. Cada operação é uma interface de um método. |
| Que o comando grava, é atômico e despacha eventos | NexoTestHost, 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ção | O 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.
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
dotnet add package Nexo.TestingNexoTestHost 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.
[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ça | O que é |
|---|---|
NexoTestHost.StartAsync<TContext>(nexo, services, session) | O host. Os três callbacks são os mesmos da aplicação. |
host.SendAsync / FetchAsync / BroadcastAsync | Uma mensagem, num escopo próprio. |
host.QueryAsync<TContext, T> | Lê o banco por um contexto novo, sem passar pelo Nexo. |
host.Dispatched | DispatchLog: Of<T>(), ShouldHaveDispatched<T>(predicate), ShouldNotHaveDispatched<T>(), Clear(). |
host.User | O 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.
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:
[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:
[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.
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:
// 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:
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
# 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.IntegrationTestsProjetos 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.