Capítulo 16: Testes Automatizados com xUnit, FluentAssertions e Mocks

Especialização em Sistemas com C# • C# 12 & .NET 8 • POO Moderna, LINQ, Concorrência e Arquitetura Corporativa


🗺️ Mapa Conceitual da Pirâmide de Testes e Ciclo xUnit no .NET

flowchart TD
    subgraph PIRAMIDE["Pirâmide de Qualidade de Software"]
        E2E["Testes E2E (Playwright / Postman) - Poucos e Lentos"]
        Integration["Testes de Integração (WebApplicationFactory + TestContainers)"]
        Unit["Testes Unitários (xUnit + FluentAssertions + NSubstitute/Moq) - Milhares e Rápidos"]
    end

    subgraph XUNIT_LIFECYCLE["Ciclo de Vida do xUnit (.NET)"]
        Constructor["Construtor da Classe de Teste (Setup Isolado por Teste)"]
        Fact["Execução de [Fact] ou [Theory]"]
        Dispose["IDisposable.Dispose / IAsyncDisposable (TearDown)"]
        Fixture["IClassFixture<T> / ICollectionFixture<T> (Recursos Compartilhados)"]
    end

    Unit --> Constructor
    Constructor --> Fact
    Fact --> Dispose
    Integration --> Fixture

    style PIRAMIDE fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
    style XUNIT_LIFECYCLE fill:#ede7f6,stroke:#7e57c2,stroke-width:2px
    style Unit fill:#c8e6c9,stroke:#4caf50,stroke-width:2px
    style Integration fill:#fff9c4,stroke:#fbc02d,stroke-width:2px

🏛️ 1. A Filosofia do xUnit: Isolamento Extremo e Sem Efeitos Colaterais

Ao contrário de outros frameworks históricos como NUnit ou MSTest que utilizavam anotações [SetUp] e [TearDown] reaproveitando a mesma instância da classe de teste, o xUnit instancia uma nova classe de teste para cada método executado.

Diferenças de Ciclo de Vida:

Recurso NUnit / MSTest (Legado) xUnit (.NET 8 Moderno) Vantagem de Design
Inicialização [SetUp] antes de cada teste Construtor da classe Garante imutabilidade e estado limpo
Finalização [TearDown] IDisposable.Dispose() Segue o idioma canônico do C#
Contexto Compartilhado Variáveis estáticas de classe IClassFixture<TContext> Sem concorrência acidental entre threads
Coleção Compartilhada Suítes manuais ICollectionFixture<TContext> Isolamento controlado de bases de dados

⚙️ 2. Asserções Fluentes com FluentAssertions

Embora o xUnit possua a classe estática Assert, o ecossistema corporativo adota amplamente a biblioteca FluentAssertions. Ela transforma asserções imperativas em sentenças legíveis no formato Given-When-Then, facilitando a leitura por desenvolvedores e analistas de negócio.

// Asserção Tradicional xUnit:
Assert.Equal(200, resposta.StatusCode);
Assert.NotNull(resposta.Conteudo);
Assert.Contains("sucesso", resposta.Mensagem);

// FluentAssertions (Legibilidade Canônica):
resposta.StatusCode.Should().Be(200);
resposta.Conteudo.Should().NotBeNull();
resposta.Mensagem.Should().ContainEquivalentOf("sucesso");
resposta.TempoRespostaMs.Should().BeLessThan(250);

🧪 3. Parametrização Robusta com [Theory] e [InlineData]

O xUnit divide os testes em duas categorias fundamentais:

  1. [Fact]: Teste invariante que valida uma condição absoluta independente de entradas variáveis.
  2. [Theory]: Teste parametrizado orientado a dados, executado múltiplas vezes contra massas de teste.
[Theory]
[InlineData("usuario@dominio.com", true)]
[InlineData("invalido@", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void ValidarEmail_DeveValidarCorretamente(string? email, bool esperado)
{
    // Arrange & Act
    bool resultado = ValidadorEmail.EhValido(email);

    // Assert
    resultado.Should().Be(esperado);
}

Para cenários complexos de dados tabulares ou objetos pesados, usa-se [MemberData] apontando para uma propriedade estática IEnumerable<object[]> ou [ClassData<T>].


🎭 4. Mocks, Stubs e Duplos de Teste com NSubstitute ou Moq

Em testes de unidade, dependências externas (como bancos de dados, gateways de pagamento e serviços de e-mail) devem ser substituídas por Test Doubles. O NSubstitute e o Moq são os frameworks mais populares em .NET.

// Exemplo com NSubstitute:
var servicoNotificacao = Substitute.For<IServicoNotificacao>();
var repositorio = Substitute.For<IClienteRepositorio>();

repositorio.ObterPorIdAsync(42).Returns(new Cliente(42, "Maria Silva", "maria@corp.com"));

var handler = new CadastrarPedidoHandler(repositorio, servicoNotificacao);
await handler.ExecutarAsync(new PedidoComando(42, 1500m));

// Verificando se o serviço de notificação recebeu a chamada esperada:
await servicoNotificacao.Received(1).NotificarClienteAsync(
    Arg.Is<string>(email => email == "maria@corp.com"),
    Arg.Any<string>()
);

💻 5. Laboratório Prático de Arquitetura: Testando Serviços com xUnit e FluentAssertions

Abaixo está a implementação de uma suíte corporativa de testes testando regras financeiras de cálculo de comissão de vendas e limites operacionais.

using System;
using System.Collections.Generic;
using FluentAssertions;
using Xunit;

namespace TreinamentoDotNet.Testes;

// 1. Domínio de Negócio sob Teste
public record PropostaCredito(Guid Id, string Cliente, decimal ValorSolicitado, int ScoreSerasa);

public enum StatusAprovacao
{
    Aprovado,
    RejeitadoPorScore,
    RejeitadoPorValorExcedente
}

public record ResultadoAnalise(StatusAprovacao Status, decimal TaxaJuros);

public class MotorCreditoService
{
    public const decimal LimiteMaximoSemGarantia = 50_000m;
    public const int ScoreMinimo = 600;

    public ResultadoAnalise Avaliar(PropostaCredito proposta)
    {
        ArgumentNullException.ThrowIfNull(proposta);

        if (proposta.ValorSolicitado <= 0)
            throw new ArgumentOutOfRangeException(nameof(proposta), "Valor solicitado deve ser positivo.");

        if (proposta.ScoreSerasa < ScoreMinimo)
            return new ResultadoAnalise(StatusAprovacao.RejeitadoPorScore, 0m);

        if (proposta.ValorSolicitado > LimiteMaximoSemGarantia)
            return new ResultadoAnalise(StatusAprovacao.RejeitadoPorValorExcedente, 0m);

        // Taxa decresce conforme o score de crédito
        decimal taxa = proposta.ScoreSerasa switch
        {
            >= 850 => 0.012m, // 1.2% ao mês
            >= 700 => 0.018m, // 1.8% ao mês
            _      => 0.025m  // 2.5% ao mês
        };

        return new ResultadoAnalise(StatusAprovacao.Aprovado, taxa);
    }
}

// 2. Suíte de Testes com xUnit e FluentAssertions
public class MotorCreditoServiceTests : IDisposable
{
    private readonly MotorCreditoService _sut; // System Under Test

    public MotorCreditoServiceTests()
    {
        // Executado antes de CADA teste (isolamento total)
        _sut = new MotorCreditoService();
    }

    public void Dispose()
    {
        // Executado após CADA teste (limpeza de recursos se necessário)
    }

    [Fact]
    public void Avaliar_QuandoPropostaForNula_DeveLancarArgumentNullException()
    {
        // Act
        Action act = () => _sut.Avaliar(null!);

        // Assert
        act.Should().Throw<ArgumentNullException>()
           .WithParameterName("proposta");
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1500)]
    public void Avaliar_QuandoValorForInvalido_DeveLancarArgumentOutOfRangeException(decimal valorInvalido)
    {
        var proposta = new PropostaCredito(Guid.NewGuid(), "Ana Silva", valorInvalido, 750);

        Action act = () => _sut.Avaliar(proposta);

        act.Should().Throw<ArgumentOutOfRangeException>();
    }

    [Theory]
    [InlineData(300, StatusAprovacao.RejeitadoPorScore)]
    [InlineData(599, StatusAprovacao.RejeitadoPorScore)]
    public void Avaliar_QuandoScoreAbaixoDoMinimo_DeveRejeitar(int score, StatusAprovacao statusEsperado)
    {
        var proposta = new PropostaCredito(Guid.NewGuid(), "Bruno Costa", 10_000m, score);

        var resultado = _sut.Avaliar(proposta);

        resultado.Status.Should().Be(statusEsperado);
        resultado.TaxaJuros.Should().Be(0m);
    }

    [Fact]
    public void Avaliar_QuandoValorExcederLimite_DeveRejeitarPorValorExcedente()
    {
        var proposta = new PropostaCredito(Guid.NewGuid(), "Carla Dias", 50_000.01m, 800);

        var resultado = _sut.Avaliar(proposta);

        resultado.Status.Should().Be(StatusAprovacao.RejeitadoPorValorExcedente);
        resultado.TaxaJuros.Should().Be(0m);
    }

    [Theory]
    [InlineData(900, 0.012)]
    [InlineData(750, 0.018)]
    [InlineData(650, 0.025)]
    public void Avaliar_QuandoAprovado_DeveAplicarTaxaCorrespondenteAoScore(int score, decimal taxaEsperada)
    {
        var proposta = new PropostaCredito(Guid.NewGuid(), "Daniel Rocha", 20_000m, score);

        var resultado = _sut.Avaliar(proposta);

        resultado.Status.Should().Be(StatusAprovacao.Aprovado);
        resultado.TaxaJuros.Should().Be(taxaEsperada);
    }
}