Start Debugging

WebApplicationFactory vs Testcontainers para testes de integração no ASP.NET Core

Não são alternativas. WebApplicationFactory sobe a sua aplicação, o Testcontainers sobe as dependências dela. Medido no .NET SDK 10.0.201: um fixture com contêiner custa 1,7 s por classe contra 10 ms com SQLite, e uma violação de HasMaxLength(16) que o Postgres rejeita com 22001 o SQLite aceita silenciosamente.

Use os dois. O WebApplicationFactory<T> sobe a sua aplicação; o Testcontainers sobe aquilo com que a sua aplicação conversa. A única decisão que você realmente precisa tomar é o que fica por trás da sua camada de dados, e a resposta é: se o teste verifica qualquer coisa que o banco de dados impõe, você precisa de um banco de dados real em um contêiner. Se ele verifica roteamento, model binding, autorização ou o formato do JSON, pule o Docker e pague 10 ms em vez de 1,7 segundo.

Tudo abaixo foi medido no .NET SDK 10.0.201 com Microsoft.AspNetCore.Mvc.Testing 10.0.1, Testcontainers.PostgreSql 4.13.0, EF Core 10.0.1 e postgres:17.6-alpine, rodando no Docker Desktop 29.5.3 (backend WSL2, 20 CPUs alocadas) em um Intel Core Ultra 7 265KF com 32 GB de RAM, Windows 11 26200. As APIs não mudam no .NET 11 preview.

As três configurações que as pessoas realmente querem dizer

“WebApplicationFactory vs Testcontainers” é uma pergunta mal formulada, porque os dois vivem em camadas diferentes. O que as pessoas estão escolhendo é uma destas três configurações:

A. WAF + fake em processoB. WAF + TestcontainersC. Testcontainers de ponta a ponta
Onde a app rodaNo seu processo de testeNo seu processo de testeEm um contêiner que você compilou
TransporteTestServer, sem socketTestServer, sem socketSocket real, Kestrel real
Banco de dadosSQLite / em memória / mockMotor real em um contêinerMotor real em um contêiner
Exige DockerNãoSimSim
Custo do fixture (medido)~10 ms~1,7 s~1,7 s mais compilar a imagem
Permite ponto de interrupção no código da appSimSimNão
Permite trocar um serviço por um fakeSimSimNão
Testa seu Dockerfile / entrypointNãoNãoSim
Testa HTTPS, HTTP/2, limites do KestrelNãoNãoSim
Detecta violações de restrição no bancoNão (veja abaixo)SimSim

A e B são o mesmo código com uma string de conexão diferente. C é algo genuinamente distinto e é a única linha em que o “vs” é uma escolha real de fato, porque em C você perde o ConfigureTestServices por completo: a aplicação é um artefato lacrado e você só consegue falar com ela por HTTP.

A maioria dos times quer B, recorre a A porque o Docker pareceu lento, e nunca avalia C a sério. Os números abaixo dizem que A é mais barato do que você imagina ser caro, que B é mais barato do que você imagina, e que o motivo para escolher B não tem nada a ver com desempenho.

A medição

O sistema sob teste é uma minimal API com um POST /orders que grava via EF Core e um GET /orders que lê de volta. Order.Sku está configurado com HasMaxLength(16) e um índice único. O harness sobe um factory novo três vezes por configuração, no mesmo processo, de modo que a rodada 1 inclui o JIT e a construção do modelo do EF, e as rodadas 2 e 3 mostram o estado estacionário.

// .NET 10.0.201, C# 14, Mvc.Testing 10.0.1, Testcontainers.PostgreSql 4.13.0
var sw = Stopwatch.StartNew();
var pg = new PostgreSqlBuilder("postgres:17.6-alpine").Build();
await pg.StartAsync();
var containerStart = sw.ElapsedMilliseconds;

sw.Restart();
await using var factory = new PostgresFactory(pg.GetConnectionString());
var client = factory.CreateClient();
var boot = sw.ElapsedMilliseconds;

Configuração A, WebApplicationFactory<T> sobre uma conexão SQLite em memória, sem Docker:

RodadaSubida do factoryCriação do esquemaPrimeira requisição100 escritas100 leituras
1129 ms309 ms64 ms205 ms193 ms
211 ms2 ms4 ms49 ms70 ms
34 ms7 ms3 ms49 ms67 ms

Configuração B, o mesmo factory apontando para uma instância PostgreSQL do Testcontainers, com a imagem já baixada:

RodadaSubida do contêinerSubida do factoryCriação do esquemaPrimeira requisição100 escritas100 leiturasEncerramento
12933 ms5 ms198 ms4 ms210 ms191 ms321 ms
21403 ms5 ms42 ms6 ms131 ms197 ms300 ms
31424 ms4 ms32 ms5 ms81 ms81 ms306 ms

Duas coisas saem daí que contrariam o senso comum.

O factory em si é de graça nos dois casos. Subir o WebApplicationFactory<T> custa de 4 a 5 ms depois que o processo esquenta, qualquer que seja o banco de dados por trás. Quando alguém diz que “testes de integração são lentos”, quase nunca está falando do TestServer.

O custo por requisição é praticamente o mesmo. 100 idas e voltas por todo o pipeline de middleware, model binding, EF Core e de volta custam 49 ms contra o SQLite e 81 ms contra um Postgres em contêiner no estado estacionário. Isso dá 0,3 ms de diferença por requisição, sobre um socket de loopback para dentro do WSL2. O banco de dados ser real não é o que deixa sua suíte lenta.

O que é caro é o fixture: cerca de 1,7 segundo entre subir e derrubar o contêiner, por fixture, contra uns 10 ms da opção em processo. Multiplique pelo número de classes de teste que possuem cada uma o seu próprio contêiner e você tem a sua resposta. Uma suíte com 40 fixtures com contêiner próprio gasta 68 segundos sem fazer nada além de subir e derrubar o Postgres.

Vale registrar o custo a frio separadamente, porque é o que a sua primeira execução de CI paga: baixar postgres:17.6-alpine do zero levou 11,3 segundos para uma imagem de 106 MB. Esse é o extremo barato. Uma imagem de desenvolvimento do SQL Server é mais de uma ordem de grandeza maior, e é por isso que o guia do Testcontainers com SQL Server dedica uma seção a cachear essa camada no CI.

O resultado que decide a questão

Desempenho não é o eixo. Este é:

// .NET 10.0.201, EF Core 10.0.1
// Order.Sku is configured HasMaxLength(16)
db.Orders.Add(new Order { Sku = "TOOLONGSKU-0123456789", Total = 1m });
await db.SaveChangesAsync();

Contra o contêiner:

postgres: 22001: value too long for type character varying(16)

Contra o SQLite em memória:

sqlite:   ACCEPTED, stored 21 chars

O SQLite não impõe limite de comprimento em varchar. O EF Core emite fielmente TEXT para uma string com HasMaxLength(16), o SQLite guarda os 21 caracteres sem reclamar, e o teste que deveria provar que a sua validação funciona passa. Em produção a mesma escrita lança exceção. Essa divergência sozinha é o argumento inteiro, e ela se generaliza: o SQLite difere do Postgres e do SQL Server na precisão de decimais, na sensibilidade a maiúsculas dos identificadores, na precisão de DateTime, no comportamento de escritas concorrentes e em quase toda consulta FromSql que você vier a escrever. O provedor em memória do EF Core é ainda pior, já que não impõe nenhuma semântica relacional.

Então a regra não é “sempre use Testcontainers” nem é “Testcontainers é lento demais”. É: no momento em que o que um teste verifica depende de algo que o motor do banco de dados impõe, um banco de dados falso transforma esse teste em uma mentira. Violações de restrição, exclusões em cascata, tokens de concorrência rowversion (veja concorrência otimista com um token rowversion), SQL cru, migrações e tudo que toca o tradutor de consultas pertencem à configuração B.

Quando escolher cada uma

Escolha A (WAF, sem Docker) quando o teste for sobre a superfície HTTP. /orders/{id:int} rejeita abc com um 400? O atributo [Authorize(Policy = "Admin")] devolve 403 para quem não é administrador? A resposta serializa total como número e não como string? O manipulador de exceções produz um corpo ProblemDetails? Nada disso se importa se o banco de dados é real, e muitos desses testes nem precisam de banco: registre um repositório falso via ConfigureTestServices e pule a persistência inteira. Esses são os testes que você quer rodar a cada tecla digitada, e com 10 ms de preparação eles conseguem.

Escolha B (WAF + Testcontainers) quando a verificação chegar ao motor de armazenamento. Esse é o padrão para testes de repositório, testes de consulta do EF Core, verificação de migrações e qualquer endpoint cujo comportamento interessante seja um caminho de erro do banco. É também a única forma honesta de testar que suas migrações realmente se aplicam a um banco vazio, que é uma classe de falha que nenhum fake pega e que derruba a produção.

Escolha C (tudo em contêineres) quando o artefato for o que está sob teste. Você está verificando que o Dockerfile gera uma imagem executável, que o entrypoint lê as variáveis de ambiente que o seu chart do Helm define, que o TLS termina corretamente ou que a negociação de HTTP/2 funciona. O TestServer não consegue lhe dizer nada disso porque nunca abre um socket. C é um punhado de testes de fumaça no fim do pipeline, não uma estratégia de testes.

Deixando B barato: reutilização

Os 1,7 segundo por fixture não são um custo fixo. O Testcontainers oferece reutilização de contêineres há um bom tempo, e isso transforma o custo do fixture em um detalhe irrelevante durante o desenvolvimento local:

// Testcontainers 4.13.0
var pg = new PostgreSqlBuilder("postgres:17.6-alpine")
    .WithReuse(true)
    .Build();
await pg.StartAsync();
// deliberately not disposed: reuse keeps the container alive between runs

Medido em três subidas consecutivas no mesmo processo:

SubidaDuraçãoID do contêiner
11812 ms81ae62b0f2b4
2103 ms81ae62b0f2b4
381 ms81ae62b0f2b4

O mesmo contêiner, 81 ms em vez de 1812. A reutilização casa por um hash da configuração do contêiner, então mudar a tag da imagem, o ambiente ou o mapeamento de portas produz corretamente um contêiner novo.

O ponto de atenção é a limpeza. A documentação do Testcontainers é explícita ao dizer que habilitar a reutilização desabilita o resource reaper, então o Ryuk não vai remover o contêiner por você, e chamar DisposeAsync() em um contêiner reutilizável o para em vez de apagá-lo. Um contêiner velho carregando o esquema da semana passada vai continuar atendendo seus testes tranquilamente até você removê-lo na mão. Essa propriedade de guardar estado entre execuções é o que faz da reutilização uma otimização de desenvolvimento local e não de CI: coloque-a atrás de uma checagem de variável de ambiente para que seu pipeline sempre receba um motor limpo.

Repare que, diferentemente da implementação em Java, o Testcontainers para .NET não exige nenhuma habilitação em ~/.testcontainers.properties. WithReuse(true) basta sozinho, o que é conveniente e também o motivo de o controle ficar por sua conta.

A outra alavanca, que pesa mais no CI, é compartilhar um contêiner entre muitas classes de teste em vez de um por classe. No xUnit isso é um collection fixture ou um assembly fixture em vez de IClassFixture<T>; as diferenças entre frameworks estão cobertas na comparação entre xUnit v3, NUnit e MSTest. Compartilhe o contêiner, isole os dados: dê a cada classe de teste o seu próprio esquema ou o seu próprio banco no servidor compartilhado, ou limpe com um truncate entre testes.

Três erros que você vai encontrar montando isso

Os três saíram de construir o harness deste artigo, nas versões atuais dos pacotes.

Solution root could not be located using application root. O WebApplicationFactory<T> localiza o content root da aplicação subindo a árvore de diretórios a partir do assembly de testes em busca de um arquivo .sln ou .slnx, a menos que o target do MSBuild do Microsoft.AspNetCore.Mvc.Testing tenha carimbado um WebApplicationFactoryContentRootAttribute no seu assembly de testes. Um projeto de testes que não faz parte de um arquivo de solução, algo cada vez mais comum com os layouts da era dotnet run app.cs, quebra no primeiro CreateClient(). Ou você adiciona os projetos a uma solução, ou sobrescreve CreateHost e define o content root explicitamente.

Services for database providers 'Npgsql.EntityFrameworkCore.PostgreSQL', 'Microsoft.EntityFrameworkCore.Sqlite' have been registered in the service provider. Only a single database provider can be registered in a service provider. Essa é a clássica falha ao trocar o DbContext, e o conselho que você vai achar no Stack Overflow está desatualizado. Remover DbContextOptions<TContext> não basta mais, porque o AddDbContext no EF Core 9 e posteriores também registra um IDbContextOptionsConfiguration<TContext> que ainda carrega o provedor de produção. Remova os três:

// .NET 10.0.201, EF Core 10.0.1
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureTestServices(services =>
    {
        services.RemoveAll(typeof(IDbContextOptionsConfiguration<OrdersDbContext>));
        services.RemoveAll(typeof(DbContextOptions<OrdersDbContext>));
        services.RemoveAll(typeof(DbContextOptions));
        services.AddDbContext<OrdersDbContext>(o => o.UseNpgsql(_connectionString));
    });
}

A alternativa mais limpa, se o Program.cs for seu, é não registrar um provedor que você pretende substituir: leia a string de conexão da configuração e deixe o factory de testes fornecê-la via ConfigureAppConfiguration. Aí não há nada para remover.

'PostgreSqlBuilder.PostgreSqlBuilder()' is obsolete. A partir do Testcontainers 4.13.0 os construtores sem parâmetros dos módulos estão obsoletos e a imagem precisa ser passada ao construtor: new PostgreSqlBuilder("postgres:17.6-alpine"). É o desfecho da mudança da 4.10 que parou de fazer os módulos usarem por padrão uma tag escolhida pelos mantenedores. Hoje é um aviso e mais adiante será um erro, e é a decisão certa: uma tag de imagem flutuante significa que um pipeline de CI que passou ontem pode falhar hoje por motivos que não têm nada a ver com o seu commit.

O que eu faria

Por padrão, configuração B para qualquer coisa com um repositório na pilha de chamadas, e configuração A para todo o resto. Concretamente: um contêiner compartilhado por assembly, WithReuse(true) localmente, um reset com truncate entre testes em vez de um contêiner por classe, e um projeto de teste rápido à parte, sem dependência de Docker, para os testes de superfície HTTP, para que dotnet test nesse projeto continue abaixo de um segundo.

Não use SQLite nem o provedor em memória como substituto do seu motor de produção. Use-os quando o banco de dados for genuinamente incidental ao que você está verificando, e seja honesto: a essa altura você está escrevendo um teste HTTP que por acaso precisa que exista uma camada de persistência. Os 30 ms por cada cem requisições que você economiza não valem um teste verde que estaria vermelho em produção. Se você quiser um fake mesmo assim, mockar o DbContext sem quebrar o change tracking é um fake mais honesto que um dialeto de SQL diferente.

E recorra à configuração C com parcimônia. É uma capacidade real, não uma versão melhor de B: ela testa o artefato em vez do código, então o lugar dela é ao lado dos seus testes de fumaça de implantação, e não na suíte que o time roda antes do push.

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar