Testes automatizados são frequentemente a diferença entre um sistema que pode evoluir com confiança e um que ninguém quer tocar com medo de quebrar. No ecossistema .NET, as ferramentas estão maduras e a curva de aprendizado é menor do que parece.
Neste guia você vai ver testes de unidade com xUnit e Moq, testes de integração com banco de dados real, e como aplicar TDD de forma prática — sem dogma.
A pirâmide de testes
A pirâmide de testes define a proporção ideal: muitos testes de unidade (rápidos, baratos), alguns de integração (testam a cola entre componentes), e poucos end-to-end (lentos, frágeis). No .NET enterprise, uma distribuição razoável é 70% unit, 25% integração, 5% E2E.
Setup do projeto de testes
// Estrutura de projeto
src/
├── MyApp.Domain/
├── MyApp.Application/
├── MyApp.Infrastructure/
└── MyApp.API/
tests/
├── MyApp.UnitTests/
│ ├── Domain/
│ └── Application/
└── MyApp.IntegrationTests/
└── API/
// Pacotes para o projeto de unit tests:
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Moq
dotnet add package FluentAssertions
dotnet add package Microsoft.NET.Test.Sdk
// Pacotes para integration tests:
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Testcontainers.PostgreSql
dotnet add package Respawn
Testes de unidade com xUnit e Moq
Testes de unidade isolam uma única classe ou função. Dependências externas (repositórios, serviços de e-mail) são substituídas por mocks.
Testando entidades de domínio (sem mocks)
public class PedidoTests { [Fact] public void Confirmar_QuandoPedidoTemItens_DeveAlterarStatusParaConfirmado() { // Arrange var pedido = Pedido.Criar(Guid.NewGuid()); var produto = Produto.Criar("Notebook", Dinheiro.BRL(3000m)); pedido.AdicionarItem(produto, 1);// Act pedido.Confirmar(); // Assert pedido.Status.Should().Be(StatusPedido.Confirmado); } [Fact] public void Confirmar_QuandoPedidoSemItens_DeveLancarDomainException() { // Arrange var pedido = Pedido.Criar(Guid.NewGuid()); // Act & Assert var action = () => pedido.Confirmar(); action.Should().Throw<DomainException>() .WithMessage("*sem itens*"); } [Theory] [InlineData(1, 100, 100)] // 1 item: sem desconto [InlineData(3, 100, 300)] // 3 itens: sem desconto (limite) [InlineData(4, 100, 380)] // 4 itens: 5% de desconto public void CalcularTotal_DeveAplicarDescontoCorreto(int quantidade, decimal preco, decimal esperado) { // Arrange var pedido = Pedido.Criar(Guid.NewGuid()); var produto = Produto.Criar("Produto", Dinheiro.BRL(preco)); pedido.AdicionarItem(produto, quantidade); // Act var total = pedido.CalcularTotal(); // Assert total.Should().Be(esperado); }
}
Note o uso de [Theory] + [InlineData] para testar múltiplos cenários com o mesmo teste — uma das features mais úteis do xUnit.
Testando Application Services com Moq
public class ConfirmarPedidoHandlerTests { private readonly Mock<IPedidoRepository> _repositoryMock; private readonly Mock<IUnitOfWork> _unitOfWorkMock; private readonly Mock<IPublisher> _publisherMock; private readonly ConfirmarPedidoHandler _handler;public ConfirmarPedidoHandlerTests() { _repositoryMock = new Mock<IPedidoRepository>(); _unitOfWorkMock = new Mock<IUnitOfWork>(); _publisherMock = new Mock<IPublisher>(); _handler = new ConfirmarPedidoHandler( _repositoryMock.Object, _unitOfWorkMock.Object, _publisherMock.Object); } [Fact] public async Task Handle_PedidoExistenteComItens_DeveConfirmarEPublicarEvento() { // Arrange var pedidoId = Guid.NewGuid(); var pedido = CriarPedidoComItens(pedidoId); _repositoryMock .Setup(r => r.GetByIdAsync(pedidoId, It.IsAny<CancellationToken>())) .ReturnsAsync(pedido); // Act await _handler.Handle(new ConfirmarPedidoCommand(pedidoId), CancellationToken.None); // Assert pedido.Status.Should().Be(StatusPedido.Confirmado); _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once); _publisherMock.Verify(p => p.Publish(It.IsAny<PedidoConfirmadoEvent>(), It.IsAny<CancellationToken>()), Times.Once); } [Fact] public async Task Handle_PedidoNaoEncontrado_DeveLancarNotFoundException() { // Arrange _repositoryMock .Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) .ReturnsAsync((Pedido?)null); // Act & Assert var action = async () => await _handler.Handle( new ConfirmarPedidoCommand(Guid.NewGuid()), CancellationToken.None); await action.Should().ThrowAsync<NotFoundException>(); _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never); } private static Pedido CriarPedidoComItens(Guid id) { var pedido = Pedido.Criar(Guid.NewGuid()); var produto = Produto.Criar("Produto Teste", Dinheiro.BRL(100m)); pedido.AdicionarItem(produto, 1); // Forçar ID para testes (via reflection ou factory method): typeof(Pedido).GetProperty("Id")!.SetValue(pedido, id); return pedido; }
}
Testes de integração com banco de dados real
Testes de integração verificam se a aplicação funciona de ponta a ponta. Com Testcontainers, você sobe um PostgreSQL real (via Docker) durante os testes — sem mocks de banco, sem surpresas em produção.
public class PedidosIntegrationTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime { private readonly WebApplicationFactory<Program> _factory; private readonly PostgreSqlContainer _postgres; private HttpClient _client = null!;public PedidosIntegrationTests(WebApplicationFactory<Program> factory) { _postgres = new PostgreSqlBuilder() .WithImage("postgres:16-alpine") .Build(); _factory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Substituir connection string pelo container de teste var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions<AppDbContext>)); if (descriptor != null) services.Remove(descriptor); services.AddDbContext<AppDbContext>(options => options.UseNpgsql(_postgres.GetConnectionString())); }); }); } public async Task InitializeAsync() { await _postgres.StartAsync(); _client = _factory.CreateClient(); // Aplicar migrations no banco de teste using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); await db.Database.MigrateAsync(); } public async Task DisposeAsync() { await _postgres.DisposeAsync(); } [Fact] public async Task POST_ConfirmarPedido_Retorna200EStatusConfirmado() { // Arrange: criar pedido via API var criarResponse = await _client.PostAsJsonAsync("/api/pedidos", new { ClienteId = Guid.NewGuid() }); var pedidoCriado = await criarResponse.Content.ReadFromJsonAsync<PedidoDto>(); // Adicionar item await _client.PostAsJsonAsync($"/api/pedidos/{pedidoCriado!.Id}/itens", new { ProdutoId = Guid.NewGuid(), Quantidade = 2 }); // Act: confirmar pedido var confirmarResponse = await _client.PostAsync( $"/api/pedidos/{pedidoCriado.Id}/confirmar", null); // Assert confirmarResponse.StatusCode.Should().Be(HttpStatusCode.OK); var pedidoConfirmado = await _client .GetFromJsonAsync<PedidoDto>($"/api/pedidos/{pedidoCriado.Id}"); pedidoConfirmado!.Status.Should().Be("Confirmado"); }
}
TDD na prática: quando funciona de verdade
TDD (Test-Driven Development) significa escrever o teste antes do código. O ciclo é: Red (teste falha) → Green (código mínimo para passar) → Refactor (melhorar sem quebrar).
TDD funciona bem para: lógica de domínio complexa, algoritmos, casos com muitos edge cases. Funciona mal para: código de infra e configuração, testes de UI.
// 1. Red: escreva o teste primeiro (vai falhar porque a classe não existe) [Fact] public void CalcularFrete_ParaSaoPaulo_DeveRetornarFreteCorreto() { var calculadora = new CalculadoraFrete(); var frete = calculadora.Calcular("01310-100", 2.5m); // 2.5kg para SP frete.Should().Be(Dinheiro.BRL(15.90m)); }// 2. Green: implemente o mínimo para passar public class CalculadoraFrete { public Dinheiro Calcular(string cep, decimal pesoKg) { // Implementação mínima return Dinheiro.BRL(15.90m); } }
// 3. Refactor: generalize sem quebrar os testes existentes public class CalculadoraFrete { private static readonly Dictionary<string, decimal> _tarifasPorUF = new() { { “SP”, 15.90m }, { “RJ”, 18.50m }, { “MG”, 17.20m }, };
public Dinheiro Calcular(string cep, decimal pesoKg) { var uf = ObterUFDoCep(cep); var tarifaBase = _tarifasPorUF.GetValueOrDefault(uf, 25.00m); var adicionalPeso = pesoKg > 1m ? (pesoKg - 1m) * 2.50m : 0m; return Dinheiro.BRL(tarifaBase + adicionalPeso); } private static string ObterUFDoCep(string cep) { var prefixo = int.Parse(cep.Replace("-", "")[..5]); return prefixo switch { >= 1000 and <= 19999 => "SP", >= 20000 and <= 28999 => "RJ", >= 30000 and <= 39999 => "MG", _ => "BR" }; }
}
Boas práticas de nomenclatura de testes
Um bom nome de teste documenta o comportamento do sistema. Use o padrão Método_Cenário_ResultadoEsperado:
// RUIM — não diz nada [Fact] public void TestePedido() { } [Fact] public void Teste1() { }
// BOM — auto-documentado [Fact] public void Confirmar_PedidoSemItens_LancaDomainException() { } [Fact] public void CalcularTotal_ComMaisDe3Itens_AplicaDescontoDe5Porcento() { } [Fact] public void GetByEmail_EmailNaoCadastrado_RetornaNull() { }
Rodando testes no pipeline CI/CD
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: testdb
options: >-
—health-cmd pg_isready
—health-interval 10s
—health-timeout 5s
—health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Restore
run: dotnet restore
- name: Unit Tests
run: dotnet test tests/MyApp.UnitTests --no-restore --verbosity normal
- name: Integration Tests
env:
ConnectionStrings__Default: "Host=localhost;Database=testdb;Password=testpassword"
run: dotnet test tests/MyApp.IntegrationTests --no-restore --verbosity normal</code></pre>
Conclusão
Uma suite de testes bem estruturada é um investimento com retorno garantido: refatorações ficam mais seguras, bugs regressivos são detectados antes do deploy, e o onboarding de novos devs é mais rápido porque os testes documentam o comportamento esperado.
O ponto de partida mais prático: comece testando a camada de domínio. Entidades e value objects são fáceis de testar (sem mocks, sem setup complexo) e retornam muito valor imediato. Progrida para application services e integração conforme o projeto cresce.
Se você quer estruturar uma cultura de qualidade no seu time .NET — cobertura de testes, code review, CI/CD — a Neryx pode ajudar. Consultoria inicial gratuita.
Leitura complementar: