Capítulo 16: Testes Automatizados com xUnit, FluentAssertions e Mocks
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:
[Fact]: Teste invariante que valida uma condição absoluta independente de entradas variáveis.[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);
}
}