Testing
Escopo: C#. Visão transversal: shared/standards/testing.md.
Um teste é a descrição executável do comportamento esperado. Quando ele falha, a mensagem precisa contar três coisas sem que ninguém abra o código: o que foi chamado, o que voltou e o que se esperava.
Os exemplos usam a divisão AAA (Arrange Act Assert · Preparar Executar Verificar), com as declarações agrupadas e a verificação isolada por uma linha em branco. O code style vale dentro do teste também: a comparação recebe variáveis com nome (actualPrice, expectedName), sem cálculo, acesso a propriedade ou valor solto escrito no meio do Assert.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| AAA (Arrange Act Assert · Preparar Executar Verificar) | Convenção que divide o teste em três fases explícitas |
| xUnit (framework de teste do .NET) | Framework padrão do projeto: [Fact] para casos únicos, [Theory] parametrizadas |
| mock (dados fictícios) | Objeto que substitui dependência real e expõe verificações de chamada (Moq, NSubstitute) |
| stub (substituto passivo) | Implementação fixa que devolve valor pré-definido sem verificar interação |
| fake (implementação simplificada) | Substituto funcional, mais leve que o real (ex: InMemoryRepository) |
| fixture (estado compartilhado de teste) | Objeto que prepara contexto reutilizado entre testes (IClassFixture<T>) |
| assert (verificação de resultado) | Última fase do teste; recebe variáveis nomeadas, nunca expressões inline |
| actual (valor atual) | O que o código devolveu de fato na execução do teste; é o valor que está sob verificação |
| expected (valor esperado) | O que o código deveria ter devolvido; é o valor que você escreve à mão no teste, como referência |
| FluentAssertions (biblioteca de asserts fluentes) | API que torna asserts legíveis: actualOrder.Should().BeEquivalentTo(expectedOrder) |
O guia usa xUnit como referência: é o framework mais adotado no ecossistema .NET e dispensa o boilerplate (código repetitivo de cerimônia) de [TestClass].
using Xunit;
A ordem do assert varia por framework: cada script de teste indica a convenção no topo.
- xUnit:
Assert.Equal(expected, actual): expected primeiro - MSTest:
Assert.AreEqual(expected, actual): expected primeiro - NUnit:
Assert.That(actual, Is.EqualTo(expected)): actual primeiro
As três fases do teste
Preparar o cenário, executar a operação e declarar o valor esperado ficam juntos, em um bloco. A verificação vem depois de uma linha em branco. Escrever tudo dentro do Assert, como em Assert.Equal(90m, ApplyDiscount(new Order { ... })), economiza linhas e atrapalha na hora que o teste quebra: a mensagem de falha mostra números sem dizer de onde vieram.
❌ Ruim: tudo inline, fases invisíveis
[Fact]
public void AppliesDiscount()
{
Assert.Equal(90m, ApplyDiscount(new Order { Price = 100m, DiscountPct = 10 }));
}
✅ Bom: setup agrupado, asserção isolada por linha em branco
[Fact]
public void AppliesTenPercentDiscountToOrderPrice()
{
var order = new Order { Price = 100m, DiscountPct = 10 };
var actualPrice = ApplyDiscount(order);
var expectedPrice = 90m;
Assert.Equal(expectedPrice, actualPrice);
}
Nomeie o esperado e o obtido antes de comparar
Dê nome aos dois lados da comparação: actualName para o que o código produziu, expectedName para o que deveria produzir. O Assert passa a se ler como uma frase. Mantenha a regra mesmo quando o valor já tem nome, porque a simetria entre expected e actual é o que deixa claro qual dos dois está sob teste.
❌ Ruim: literais inline, falha não diz o que era esperado
[Fact]
public void FormatsFullName()
{
Assert.Equal("John Doe", FormatName("John", "Doe"));
}
[Fact]
public void ReturnsActiveUsersOnly()
{
var users = new[] { new User("Alice", true), new User("Bob", false) };
Assert.Equal(new[] { new User("Alice", true) }, FilterActive(users));
}
✅ Bom: expected e actual declarados, assert semântico
[Fact]
public void FormatsFullName()
{
var actualName = FormatName("John", "Doe");
var expectedName = "John Doe";
Assert.Equal(expectedName, actualName);
}
[Fact]
public void ReturnsActiveUsersOnly()
{
var users = new[] { new User("Alice", true), new User("Bob", false) };
var actualUsers = FilterActive(users);
var expectedUsers = new[] { new User("Alice", true) };
Assert.Equal(expectedUsers, actualUsers);
}
O nome do teste conta o cenário e o resultado
AppliesDiscountWhenOrderTotalExceedsMinimum responde duas perguntas: em que situação, e o que acontece. É esse nome que aparece na lista de falhas do CI, então ele precisa se explicar sozinho. Test1 não diz nada. ApplyDiscount repete o nome do método e some com o cenário. Should na frente ocupa espaço sem acrescentar informação, e GivenWhenThen alonga o nome sem melhorar a leitura.
❌ Ruim: prefixo vazio, nome que repete a implementação
[Fact]
public void Test1() { /* ... */ }
[Fact]
public void ShouldApplyDiscount() { /* ... */ }
[Fact]
public void ApplyDiscount() { /* ... */ }
✅ Bom: cenário + resultado esperado no título
[Fact]
public void AppliesDiscountWhenOrderTotalExceedsMinimum() { /* ... */ }
[Fact]
public void ReturnsOriginalPriceWhenNoDiscountApplies() { /* ... */ }
[Fact]
public void ThrowsValidationExceptionWhenDiscountIsNegative() { /* ... */ }
Cada teste monta o próprio contexto
Um campo estático que um teste escreve e o outro lê cria uma ordem de execução obrigatória que ninguém declarou. O segundo teste passa quando roda depois do primeiro e falha quando roda sozinho, e a mensagem de erro não vai contar isso. Monte o cenário dentro de cada teste, mesmo que a linha se repita entre eles.
❌ Ruim: campo estático que um teste escreve e outro lê
public class OrderTests
{
private static Order _order;
[Fact]
public void CreatesOrder()
{
_order = new Order { Items = [new Item(1, 50m)] };
Assert.NotNull(_order.Id);
}
[Fact]
public void AppliesDiscount()
{
var actual = ApplyDiscount(_order, 10); // depende do teste anterior
var actualPrice = actual.Price;
var expected = 45m;
Assert.Equal(expected, actualPrice);
}
}
✅ Bom: cada teste isolado, sem dependência de execução
public class OrderTests
{
[Fact]
public void CreatesOrderWithGeneratedId()
{
var order = new Order { Items = [new Item(1, 50m)] };
var actualId = order.Id;
Assert.NotNull(actualId);
}
[Fact]
public void AppliesTenPercentDiscountToOrderPrice()
{
var order = new Order { Items = [new Item(1, 50m)], Total = 100m };
var actualOrder = ApplyDiscount(order, 10);
var actualPrice = actualOrder.Price;
var expectedPrice = 90m;
Assert.Equal(expectedPrice, actualPrice);
}
}
Verificar qual exceção foi lançada
Um try/catch que captura Exception e confirma que ela não é nula passa mesmo quando o código quebrou por um motivo diferente do esperado: um NullReferenceException acidental satisfaz o teste tão bem quanto o NotFoundException que ele queria provar. Assert.ThrowsAsync<NotFoundException> só passa quando a exceção lançada é do tipo declarado.
❌ Ruim: try/catch manual, tipo não verificado
[Fact]
public async Task ThrowsOnMissingOrder()
{
try
{
await FindOrderAsync(null);
}
catch (Exception ex)
{
Assert.NotNull(ex); // qualquer exceção passa
}
}
✅ Bom: Assert.ThrowsAsync com tipo explícito
[Fact]
public async Task ThrowsNotFoundExceptionWhenOrderDoesNotExist()
{
var invalidId = "nonexistent-id";
var act = () => FindOrderAsync(invalidId);
await Assert.ThrowsAsync<NotFoundException>(act);
}
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.