Testes .NET C# TDD xUnit

Testes automatizados em .NET: unit tests, integration tests e TDD na prática

Guia completo de testes em .NET com xUnit, Moq e TestContainers. Aprenda a escrever unit tests com mocks, integration tests com banco real e aplicar TDD.

N
Neryx Digital Architects
24 de fevereiro de 2026
13 min de leitura
240 profissionais leram
Categoria: .NET Público: Times de engenharia e produto Etapa: Aprendizado

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&lt;IPedidoRepository&gt;();
    _unitOfWorkMock = new Mock&lt;IUnitOfWork&gt;();
    _publisherMock = new Mock&lt;IPublisher&gt;();
    _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 =&gt; r.GetByIdAsync(pedidoId, It.IsAny&lt;CancellationToken&gt;()))
        .ReturnsAsync(pedido);

    // Act
    await _handler.Handle(new ConfirmarPedidoCommand(pedidoId), CancellationToken.None);

    // Assert
    pedido.Status.Should().Be(StatusPedido.Confirmado);
    _unitOfWorkMock.Verify(u =&gt; u.SaveChangesAsync(It.IsAny&lt;CancellationToken&gt;()), Times.Once);
    _publisherMock.Verify(p =&gt;
        p.Publish(It.IsAny&lt;PedidoConfirmadoEvent&gt;(), It.IsAny&lt;CancellationToken&gt;()),
        Times.Once);
}

[Fact]
public async Task Handle_PedidoNaoEncontrado_DeveLancarNotFoundException()
{
    // Arrange
    _repositoryMock
        .Setup(r =&gt; r.GetByIdAsync(It.IsAny&lt;Guid&gt;(), It.IsAny&lt;CancellationToken&gt;()))
        .ReturnsAsync((Pedido?)null);

    // Act & Assert
    var action = async () =&gt; await _handler.Handle(
        new ConfirmarPedidoCommand(Guid.NewGuid()),
        CancellationToken.None);

    await action.Should().ThrowAsync&lt;NotFoundException&gt;();
    _unitOfWorkMock.Verify(u =&gt; u.SaveChangesAsync(It.IsAny&lt;CancellationToken&gt;()), 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&lt;Program&gt; factory)
{
    _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    _factory = factory.WithWebHostBuilder(builder =&gt;
    {
        builder.ConfigureServices(services =&gt;
        {
            // Substituir connection string pelo container de teste
            var descriptor = services.SingleOrDefault(
                d =&gt; d.ServiceType == typeof(DbContextOptions&lt;AppDbContext&gt;));
            if (descriptor != null) services.Remove(descriptor);

            services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
                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&lt;AppDbContext&gt;();
    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&lt;PedidoDto&gt;();

    // 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&lt;PedidoDto&gt;($"/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 &gt; 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
    {
        &gt;= 1000 and &lt;= 19999 =&gt; "SP",
        &gt;= 20000 and &lt;= 28999 =&gt; "RJ",
        &gt;= 30000 and &lt;= 39999 =&gt; "MG",
        _ =&gt; "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:

Quer transformar esse aprendizado em plano de ação?

Se o tema deste artigo se parece com o momento do seu time, podemos ajudar a decidir o próximo passo com clareza.

Falar com um especialista

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.