Repository navigation
Estrategia de Testes
Para quem é este artigo: para quem vai escrever ou manter testes no PolicyDesk, e para quem quer aprender testes automatizados em .NET, de testes unitários a testes com banco de dados real.
Pré-requisitos: C# básico. O Modelo de Domínio e a Arquitetura ajudam a entender o que está sendo testado.
O que você vai aprender: para que servem testes automatizados, a pirâmide de testes, as ferramentas usadas, como cada camada é testada, como testar com um PostgreSQL de verdade, como controlar o relógio e como medir a cobertura.
Um teste automatizado é um código que executa outro código e confere se o resultado é o esperado. Parece trabalho dobrado, mas ele resolve três problemas reais:
- Medo de mudar. Sem testes, cada alteração pode quebrar algo em outro canto, e você só descobre em produção. Com uma boa suíte, você muda o código e, em segundos, sabe se quebrou algo.
-
Documentação que não mente. Um teste chamado
Deve_LancarValidacao_Quando_SinistroNaoEstiverEmAnalisedescreve uma regra, e, ao contrário de um comentário, ele falha se a regra mudar. - Design melhor. Código difícil de testar costuma ser código mal dividido. Escrever testes força dependências claras e responsabilidades separadas.
O PolicyDesk tem mais de 460 testes, que rodam a cada pull request no CI.
Nem todo teste custa igual. Um teste que verifica uma regra em uma classe isolada roda em microssegundos. Um teste que sobe a API, um banco de dados e faz uma requisição HTTP leva segundos. A pirâmide de testes, ideia popularizada por Mike Cohn, sugere: muitos testes baratos na base, poucos testes caros no topo.
flowchart TB
subgraph P[" "]
direction TB
I["<b>Integração (API)</b><br/>92 testes · HTTP real + PostgreSQL real"]
R["<b>Infraestrutura</b><br/>51 testes · repositórios + PostgreSQL real"]
A["<b>Aplicação</b><br/>108 testes · casos de uso + dublês"]
D["<b>Domínio</b><br/>212 testes · entidades e validadores puros"]
end
I --- R --- A --- D
| Projeto | O que testa | Depende de | Velocidade |
|---|---|---|---|
PolicyDesk.Domain.Tests |
Entidades, transições de estado, validadores, exceções | Nada | Milissegundos |
PolicyDesk.Application.Tests |
A orquestração dos casos de uso | Nada (usa dublês) | Milissegundos |
PolicyDesk.Infrastructure.Tests |
SQL, mapeamentos, filtros, upsert, paginação, check constraints, Unit of Work, configuração | Docker | Segundos |
PolicyDesk.Api.IntegrationTests |
Rotas, status HTTP, contrato de erro, idioma, concorrência, health check | Docker | Segundos |
A distribuição faz sentido: as regras de negócio, a parte mais importante e mais variada, ficam no domínio, onde os testes são mais baratos. Os testes caros cobrem só o que não pode ser testado de outra forma: SQL de verdade, índices, concorrência, o pipeline HTTP.
| Biblioteca | O que faz | Exemplo |
|---|---|---|
| xUnit | O framework: descobre e executa os testes |
[Fact], [Theory]
|
| Shouldly | Asserções que se leem como frases | claim.Status.ShouldBe(ClaimStatus.Approved) |
| Moq | Cria dublês: objetos falsos que imitam uma interface | Um repositório que "devolve" um sinistro sem banco |
| Bogus | Gera dados falsos realistas | Nomes, e-mails, valores |
| Testcontainers | Sobe um PostgreSQL descartável em Docker durante os testes | Um banco limpo por suíte |
| Microsoft.AspNetCore.Mvc.Testing | Roda a API inteira em memória | WebApplicationFactory<Program> |
| Microsoft.Extensions.Diagnostics.Testing | Captura logs para conferir | FakeLogger |
| Microsoft.Extensions.TimeProvider.Testing | Um relógio controlado pelo teste | FakeTimeProvider |
| coverlet.collector | Mede quais linhas os testes executam | Relatório de cobertura |
No xUnit, um [Fact] é um teste sem parâmetros: uma verdade que sempre vale. Um [Theory] é um teste com parâmetros, executado uma vez para cada conjunto de dados:
[Theory(DisplayName = nameof(Deve_SerValido_Quando_CpfForCorreto))]
[Trait("Domain", "CpfValidator - IsValid")]
[InlineData("12345678909")]
[InlineData("123.456.789-09")]
[InlineData("52998224725")]
public void Deve_SerValido_Quando_CpfForCorreto(string cpf)
{
CpfValidator.IsValid(cpf).ShouldBeTrue();
}Esse único método vira três testes. Para testar a mesma regra com várias entradas, [Theory] evita copiar e colar.
Deve_<ResultadoEsperado>_Quando_<Condição>
Deve_AprovarEConfirmar_Quando_SinistroEstiverEmAnalise
Deve_LancarNaoEncontradoSemConfirmar_Quando_SinistroNaoExistir
Deve_AceitarApenasUmaDecisao_Quando_AprovarENegarAoMesmoTempo
Deve_RetornarHealthy_Quando_BancoEstiverDisponivel
Quando um teste falha no CI, o nome sozinho já diz qual regra quebrou. O DisplayName = nameof(...) faz as ferramentas mostrarem exatamente esse nome.
Os sublinhados nos nomes de métodos violam uma regra de estilo do .NET (CA1707), que por isso é desligada só para a pasta de testes, no .editorconfig:
[tests/**.cs]
dotnet_diagnostic.CA1707.severity = none[Trait("Application", "Claims - Approve")]
[Trait("Integration", "Claims - Concorrência")]Permitem rodar um grupo: dotnet test --filter "Integration=Claims - Concorrência".
Todo teste tem três blocos, separados por uma linha em branco:
public async Task Deve_AprovarEConfirmar_Quando_SinistroEstiverEmAnalise()
{
var claim = ClaimBuilder.Build(); // Arrange: prepare o cenário
claim.StartAnalysis();
var (useCase, unitOfWork) = CreateUseCase(claim);
await useCase.ExecuteAsync(claim.Id); // Act: execute a ação
claim.Status.ShouldBe(ClaimStatus.Approved); // Assert: confira o resultado
claim.ApprovedAt.ShouldNotBeNull();
unitOfWork.Verify(work => work.CommitAsync(), Times.Once);
}Com esse formato, qualquer pessoa encontra rapidamente o que está sendo testado (o Act, normalmente uma linha) e o que se espera.
src/Backend/PolicyDesk.Application/Claims/Approve/ApproveClaimUseCase.cs
tests/src/PolicyDesk.Application.Tests/Claims/Approve/ApproveClaimUseCaseTests.cs
Para achar os testes de uma classe, basta seguir o mesmo caminho.
São os testes mais simples e mais numerosos: sem dublês, sem banco, sem injeção de dependência. Você cria o objeto e chama o método.
Um exemplo simplificado, no estilo dos testes de ClaimsTests (o teste real equivalente é Deve_LancarExcecaoSemAlterar_Quando_AprovarSinistroForaDeAnalise, que cobre todos os status de uma vez com [Theory]):
[Fact]
public void Deve_LancarRegraDeNegocio_Quando_AprovarSinistroPendente()
{
var claim = ClaimBuilder.Build();
var exception = Should.Throw<DomainRuleException>(() => claim.Approve());
exception.Message.ShouldBe(ResourcesMessageException.CLAIM_MUST_BE_IN_ANALYSIS_FOR_APPROVAL);
}O que eles cobrem:
- Cada verificação dos construtores de
Claim,PolicyePolicyholder. - Cada transição válida e cada transição inválida da máquina de estados do sinistro.
- A validação e a normalização de CPF, e-mail e telefone, inclusive os casos difíceis: DDD inexistente, ramal, dígitos de outros alfabetos.
-
FieldLimits(exatamente no limite e um caractere acima) eMoneyLimits(casas decimais e dígitos inteiros). - O saldo da cobertura em
Policy.RegisterClaim. - O
BusinessClock: à meia-noite, à 1h e às 2h59 em UTC do dia 11/03, ainda é dia 10/03 em Brasília; às 3h passa a ser dia 11. - O formato da mensagem das exceções.
Essa é a camada que dá mais segurança por segundo de execução. Se uma regra de negócio pode ser testada aqui, ela deve ser testada aqui.
Os casos de uso dependem de repositórios, que dependem de banco. Para testá-los sem banco, os repositórios são substituídos por dublês (test doubles), objetos falsos que imitam a interface.
O foco é a orquestração: o caso de uso buscou a entidade certa? Chamou o método certo? Confirmou a gravação? E, tão importante quanto: não confirmou quando deu erro?
[Fact(DisplayName = nameof(Deve_LancarNaoEncontradoSemConfirmar_Quando_SinistroNaoExistir))]
[Trait("Application", "Claims - Approve")]
public async Task Deve_LancarNaoEncontradoSemConfirmar_Quando_SinistroNaoExistir()
{
var (useCase, unitOfWork) = CreateUseCase();
await Should.ThrowAsync<NotFoundException>(() => useCase.ExecuteAsync(Guid.NewGuid()));
unitOfWork.Verify(work => work.CommitAsync(), Times.Never);
}A última linha usa o Moq para conferir que CommitAsync nunca foi chamado. Esse tipo de verificação pega um defeito sutil: um caso de uso que grava dados mesmo quando deveria ter abortado.
Os casos de uso são internal: invisíveis fora do projeto Application. Para os testes conseguirem criá-los, o projeto declara:
[assembly: InternalsVisibleTo("PolicyDesk.Application.Tests")]A API e os outros projetos continuam sem enxergar as implementações; só o projeto de testes ganha acesso.
Algumas coisas não podem ser testadas com dublês: um ON CONFLICT do PostgreSQL, um índice parcial, a coluna xmin, uma check constraint. Um banco em memória não tem esses recursos, e um teste que passasse nele não provaria nada.
O Testcontainers resolve isso: ele sobe um PostgreSQL de verdade em um contêiner Docker, só para os testes, e o descarta no final.
public sealed class PostgreSqlInfrastructureFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _database = new PostgreSqlBuilder("postgres:16-alpine")
.WithDatabase("policydesk_infrastructure_tests")
.WithUsername("policydesk")
.WithPassword("test-password")
.Build();
public async Task InitializeAsync()
{
await _database.StartAsync();
await using var dbContext = CreateDbContext();
await dbContext.Database.MigrateAsync();
}
internal async Task<PolicyDeskDbContext> CreateCleanDbContextAsync()
{
var dbContext = CreateDbContext();
await dbContext.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE policies, policyholders CASCADE;");
return dbContext;
}
}Como funciona:
- Uma fixture é um recurso compartilhado entre vários testes. Subir um contêiner leva alguns segundos, então ele sobe uma vez por suíte, e não uma vez por teste.
-
IAsyncLifetimedá ao xUnit os ganchos de início (InitializeAsync) e fim (DisposeAsync). - As migrations reais são aplicadas. Os testes validam o esquema que vai para produção, e não um esquema gerado de outra forma.
-
Cada teste começa limpo. O
TRUNCATE ... CASCADEapaga os dados; oCASCADEalcança a tabelaclaimspela chave estrangeira.
O que só é testado aqui: o upsert com ON CONFLICT, o índice parcial de e-mail, o filtro de soft delete, a concorrência com xmin, as check constraints, a paginação, a tradução de exceções do UnitOfWork e a validação da configuração na subida.
O topo da pirâmide: a aplicação inteira, de verdade, recebendo requisições HTTP. O WebApplicationFactory<Program> sobe a API em memória, sem abrir porta de rede, apontada para um PostgreSQL do Testcontainers:
public class PolicyDeskApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DbConnection"] = _database.GetConnectionString()
}));
builder.ConfigureTestServices(services =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(Clock);
});
}
}- O ambiente
Testingnão éProduction, então oProgram.csaplica as migrations ao subir. - A connection string é trocada pela do contêiner.
- O relógio do sistema é trocado por um relógio controlado (veja abaixo).
A fábrica também fixa a cultura pt-BR nas threads de teste, para que as mensagens comparadas sejam sempre as mesmas, em qualquer máquina.
Para testar um sinistro, é preciso antes ter um segurado e uma apólice. Para não repetir essa preparação em todo teste, as classes base acumulam ajudantes:
classDiagram
BaseIntegrationTest <|-- PolicyholdersTestBase
PolicyholdersTestBase <|-- PoliciesTestBase
PoliciesTestBase <|-- ClaimsTestBase
ClaimsTestBase <|-- ClaimWorkflowTests
ClaimsTestBase <|-- ClaimConcurrencyTests
class BaseIntegrationTest {
+Post() Get() Put() Delete()
+DbContext
}
class PoliciesTestBase {
+RegisterPolicyForNewHolder()
}
class ClaimsTestBase {
+RegisterClaimForNewPolicy()
+FindClaim(id)
}
Um teste de sinistro chama RegisterClaimForNewPolicy(), que cadastra um segurado, emite uma apólice e abre um sinistro, tudo pela própria API, como um cliente real faria.
Como testar que duas decisões simultâneas não são ambas aceitas? Disparando as duas ao mesmo tempo:
var responses = await Task.WhenAll(
Post($"/api/sinistros/{claim.Id}/aprovacao", new { }),
Post($"/api/sinistros/{claim.Id}/negacao", new RequestDenyClaimJson { Motivo = "Not covered" }));
responses.Count(r => r.StatusCode == HttpStatusCode.NoContent).ShouldBe(1);Repare que o teste não exige um status específico para a requisição que perde. Dependendo do momento exato, ela recebe 409 (as duas leram o mesmo estado e gravaram juntas) ou 400 (a outra já tinha gravado quando esta leu). O que o teste garante é a regra: nunca as duas são aceitas. Testes de concorrência que exigem uma ordem exata ficam instáveis ("flaky"), passando e falhando sem motivo aparente.
Alguns cenários são difíceis de provocar de verdade, como um conflito garantido ou um erro inesperado. Para esses, o teste troca um serviço por uma versão "sabotada":
var client = Factory
.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
services.AddScoped<IApproveClaimUseCase, ConflictingApproveClaimUseCase>()))
.CreateClient();O ClaimConcurrencyTests usa essa técnica para forçar um conflito, e o ErrorResponseTests a usa (com um caso de uso que lança uma exceção) para provar que um erro inesperado vira um 500 com a mensagem genérica e o traceId.
Testes que dependem de DateTime.Now são um pesadelo: passam de dia e falham à noite, ou passam hoje e falham no ano que vem. O PolicyDesk evita isso com duas técnicas.
1. Datas fixas. Os testes nunca usam DateTime.UtcNow.Date. Eles usam datas de referência, definidas em um só lugar:
public static class BusinessClockBuilder
{
public static readonly DateTimeOffset DefaultUtcNow = new(2026, 3, 10, 15, 0, 0, TimeSpan.Zero);
public static readonly DateOnly DefaultToday = new(2026, 3, 10);
public static readonly DateTimeOffset LateEveningUtcNow = new(2026, 3, 11, 1, 0, 0, TimeSpan.Zero); // 22h de 10/03 em Brasília
}2. Um relógio controlado. Como o sistema lê o tempo por um TimeProvider injetado (veja Modelo de Domínio › BusinessClock), os testes de integração trocam o relógio real por um FakeTimeProvider, que começa em uma data fixa. Para testar o que acontece à noite em Brasília:
Factory.Clock.AdjustTime(BusinessClockBuilder.LateEveningUtcNow);É assim que cenários como "às 22h em Brasília, um sinistro com a data de amanhã é recusado" viram testes reproduzíveis, que dão o mesmo resultado a qualquer hora em que rodem.
O projeto CommonTestUtilities reúne o que todos os projetos de teste usam:
| Pasta | Conteúdo | Para quê |
|---|---|---|
Entities/ |
ClaimBuilder, PolicyBuilder, PolicyholderBuilder
|
Criar entidades válidas com uma linha |
Requests/ |
Request*JsonBuilder, CpfGenerator, BirthDateGenerator, PhoneGenerator
|
Criar requisições válidas, com CPFs e telefones que passam na validação |
Repositories/ |
*RepositoryBuilder, UnitOfWorkBuilder
|
Configurar dublês de repositório de forma legível |
Time/ |
BusinessClockBuilder |
As datas de referência |
A ideia central é o Test Data Builder: cada builder cria um objeto válido com valores padrão, e o teste sobrescreve só o que importa para ele. Um teste sobre o valor do sinistro escreve RequestClaimJsonBuilder.Build(amount: 100), e não precisa se preocupar com descrição e data. Assim, cada teste mostra só o que é relevante. Detalhes em Design Patterns › Test Data Builder.
# Todos (o Docker precisa estar rodando)
dotnet test
# Só os rápidos, sem Docker
dotnet test tests/src/PolicyDesk.Domain.Tests
dotnet test tests/src/PolicyDesk.Application.Tests
# Pelo nome
dotnet test --filter "FullyQualifiedName~Concurrency"
# Por etiqueta
dotnet test --filter "Application=Claims - Approve"Cobertura é a porcentagem do código que os testes executam. Ela é medida pelo coverlet, presente nos quatro projetos de teste.
dotnet test --collect:"XPlat Code Coverage"Cada projeto gera um arquivo coverage.cobertura.xml em TestResults/. Para ver um relatório em HTML:
dotnet tool install --global dotnet-reportgenerator-globaltool
reportgenerator -reports:"tests/**/coverage.cobertura.xml" -targetdir:coverage-reportNo CI, o mesmo relatório é gerado automaticamente: a tabela aparece no resumo de cada execução do GitHub Actions, e o HTML completo fica no artefato test-results. Veja CI/CD e Deploy.
Os números de referência, por camada:
| Assembly | Linhas cobertas |
|---|---|
| Domain | 100% |
| Communication | 100% |
| Application | 98% |
| Infrastructure | 97,9% |
| Exception | 92,1% |
| Api | 54,4% |
| Total | 83,6% |
Cobertura é um bom alarme, mas um péssimo objetivo:
- Baixa cobertura mostra código que nenhum teste executa. Isso vale investigar.
- Alta cobertura não prova que os testes são bons. Um teste que chama um método sem conferir nada aumenta a cobertura e não protege nada.
- A Api tem cobertura menor porque inclui código gerado pelo compilador (logs, expressões regulares, OpenAPI) e caminhos que os testes não ligam, como o Swagger.
Use a cobertura para encontrar regras sem teste, e não como meta a ser batida.
- Está no projeto da camada que contém a lógica testada? (Regra de negócio → Domain.Tests.)
- O nome segue
Deve_..._Quando_...? - Tem
DisplayNameeTrait? - Usa os builders, sobrescrevendo só o que importa?
- Nos casos de uso, confere que o
CommitAsyncnão é chamado nos caminhos de erro? - Testa o caminho feliz e cada caminho de erro relevante?
- Usa datas fixas e o
FakeTimeProvider, em vez deDateTime.UtcNow? - Se depende de comportamento do banco, está em
Infrastructure.TestsouApi.IntegrationTests?
- Testes dão confiança para mudar, documentam regras e melhoram o design.
- A pirâmide: muitos testes de domínio (baratos), poucos testes de integração (caros).
- Casos de uso são testados com dublês, com foco na orquestração e no "não gravou quando deu erro".
- O Testcontainers dá um PostgreSQL de verdade para o que só um banco real prova.
- O relógio é controlado pelo teste; nenhuma data depende do dia em que o teste roda.
- Builders deixam cada teste mostrar só o que importa.
- Unit Testing: Principles, Practices, and Patterns, Vladimir Khorikov.
- Growing Object-Oriented Software, Guided by Tests, Steve Freeman e Nat Pryce.
- "The Practical Test Pyramid", artigo de Ham Vocke no site de Martin Fowler.
- Documentação do Testcontainers para .NET.
- Próximo artigo sugerido: Tutorial: Nova Funcionalidade.
PolicyDesk · Distribuído sob a licença MIT · Repositório
Comece aqui
Como o sistema é feito
A API
Engenharia
Prática
Consulta