Skip to content

Estrategia de Testes

rodrigosdk edited this page Nov 12, 2026 · 3 revisions

Estratégia de testes: confiança para mudar o código

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.


Para que servem testes automatizados?

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:

  1. 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.
  2. Documentação que não mente. Um teste chamado Deve_LancarValidacao_Quando_SinistroNaoEstiverEmAnalise descreve uma regra, e, ao contrário de um comentário, ele falha se a regra mudar.
  3. 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.


A pirâmide de testes

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
Loading
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.


As ferramentas

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

[Fact] e [Theory]

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.


As convenções

Nomes que contam a história

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

Traits: etiquetas para filtrar

[Trait("Application", "Claims - Approve")]
[Trait("Integration", "Claims - Concorrência")]

Permitem rodar um grupo: dotnet test --filter "Integration=Claims - Concorrência".

Arrange, Act, Assert

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.

As pastas espelham o código

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.


Camada 1: testes de domínio

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, Policy e Policyholder.
  • 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) e MoneyLimits (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.


Camada 2: testes de aplicação

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.

Como os testes enxergam classes internal

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.


Camada 3: testes de infraestrutura, com um banco de verdade

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.
  • IAsyncLifetime dá 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 ... CASCADE apaga os dados; o CASCADE alcança a tabela claims pela 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.


Camada 4: testes de integração da API

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 Testing não é Production, então o Program.cs aplica 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.

Classes base em camadas

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

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.

Testando concorrência

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.

Substituindo uma peça

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.


Controlando o tempo

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.


Utilitários compartilhados

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.


Rodando os testes

# 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 de código

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-report

No 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%

Como interpretar a cobertura

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.


Checklist para um novo teste

  • Está no projeto da camada que contém a lógica testada? (Regra de negócio → Domain.Tests.)
  • O nome segue Deve_..._Quando_...?
  • Tem DisplayName e Trait?
  • Usa os builders, sobrescrevendo só o que importa?
  • Nos casos de uso, confere que o CommitAsync não é chamado nos caminhos de erro?
  • Testa o caminho feliz e cada caminho de erro relevante?
  • Usa datas fixas e o FakeTimeProvider, em vez de DateTime.UtcNow?
  • Se depende de comportamento do banco, está em Infrastructure.Tests ou Api.IntegrationTests?

Resumo

  • 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.

Para aprofundar

  • 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.

Clone this wiki locally