Melhores práticas para teste de unidade
Práticas recomendadas ao escrever testes de unidade para manter seus testes resilientes e fáceis de entender.
Porque testes de unidade são importantes?
Os testes de unidade são fundamentais para garantir a qualidade e a robustez de um código, além disso, garantem que aplicações sejam desenvolvidas e entregues com maior eficácia e com menos chances de problemas futuros. Eles fornecem uma maneira confiável de verificar se cada e unidade de código, como métodos e classes, funciona corretamente individualmente. Há muitos benefícios em escrever testes unitários, como:
- Menos tempo realizando testes funcionais
- Proteção contra regressão
- Documentação executável
- Menos código acoplado
Neste post, vamos explorar algumas dessas melhores práticas com .NET usando uma ferramenta de teste muito utilizada no mercado, XUnit.
Nomenclatura dos testes
Uma boa nomenclatura deve garantir que seja facilmente identificado o que está sendo testado, qual o cenário do teste e qual deve ser seu comportamento e resultado, além de fornecer documentação.
Uma convenção bem interessante para manter uma boa nomenclatura, sugere que o nome do seu teste deve consistir em três partes:
- O nome do método que está sobre teste.
- O cenário sob o qual ele está sendo testado.
- O comportamento esperado quando o cenário é chamado.
Na prática isso ficaria da seguinte forma:
<nome-do-método>_<cenário-do-teste>_<comportamento-esperado>
Ruim:
[Fact]
public void DeveRetornarSaldo()
{
// Arrange
var bankAccountService = new BankAccountService();
// Act
var actual = bankAccountService.GetBalance(1);
// Assert
Assert.Equal(1_000, actual);
}
[Fact]
public void NãoDeveRetornarSaldo()
{
// Arrange
var bankAccountService = new BankAccountService();
// Act
Func<object> action = () => bankAccountService.GetBalance(0);
// Assert
Assert.Throws<NotFoundAccountException>(action);
}
Melhor:
[Fact]
public void GetBalance_DadoQueExistaContaComIdInformado_EntãoRetorneOSaldo()
{
// Arrange
var bankAccountService = new BankAccountService();
// Act
var actual = bankAccountService.GetBalance(1);
// Assert
Assert.Equal(1_000, actual);
}
[Fact]
public void GetBalance_DadoQueNãoExistaContaComIdInformado_EntãoDispareExceçãoDeContaNãoEncontrada()
{
// Arrange
var bankAccountService = new BankAccountService();
// Act
Func<object> action = () => bankAccountService.GetBalance(0);
// Assert
Assert.Throws<NotFoundAccountException>(action);
}
É um padrão comum organizar os testes usando os 3 A’s (Act, Arrange, Assert), onde cada um representa uma parte do teste:
Arrange: Organização dos objetos, crie-os e configure-os conforme necessário.
Act: Execução do objeto que está em teste.
Assert: Afirmações que algo está como esperado.
Essa organização deixa explicitamente separado o que está sendo testado das etapas de organização e afirmação, facilitando a leitura.
Mantenha seus testes simples e independentes
Um dos princípios fundamentais dos testes de unidade é manter a simplicidade e a independência de cada teste. Ao aderir a esses princípios, você garante que seus testes sejam confiáveis, fáceis de entender e capazes de fornecer feedback rápido sobre a qualidade do seu código.
Por que manter seus testes de unidade simples?
- Legibilidade: Testes de unidade simples são mais fáceis de ler e entender.
- Manutenibilidade: Testes de unidade simples são mais fáceis de manter ao longo do tempo.
- Foco na funcionalidade: Testes de unidade simples têm a vantagem de se concentrar diretamente na funcionalidade que está sendo testada.
Ruim:
[Fact]
public void PayBill_DadoQueContaInformadaNãoExisteOuNãoPossuiSaldoSuficiente_EntãoDispareExceçãoDeContaNãoEncontradaOuSaldoInsuficiente()
{
// Arrange
var invalidInputs = new (int accountId, decimal amount)[]
{
(0, 100),
(1, 1_000_000),
};
var bankAccountService = new BankAccountService();
// Act
var actions = new List<Action>();
foreach (var (accountId, amount) in invalidInputs)
{
actions.Add(() => bankAccountService.PayBill(accountId, amount));
}
// Assert
Assert.Throws<NotFoundAccountException>(actions[0]);
Assert.Throws<InsufficientBalanceException>(actions[1]);
}
Se você precisa utilizar condições lógicas como
if
,while
,for
,switch
ou outras condições… Algo está ruim. Pare, reavalie como foi estruturado o teste, e refatore.
Melhor:
[Fact]
public void PayBill_DadoQueContaInformadaNãoExiste_EntãoDispareExceçãoDeContaNãoEncontrada()
{
// Arrange
var bankAccountService = new BankAccountService();
// Act
var action = () => bankAccountService.PayBill(0, 100);
// Assert
Assert.Throws<NotFoundAccountException>(action);
}
[Fact]
public void PayBill_DadoQueContaInformadaNãoPossuiSaldoSuficiente_EntãoDispareExceçãoDeSaldoInsuficiente()
{
// Arrange
var bankAccountService = new BankAccountService();
// Act
var action = () => bankAccountService.PayBill(1, 100);
// Assert
Assert.Throws<InsufficientBalanceException>(action);
}
Como manter seus testes de unidade independentes?
- Evite compartilhar estado: Cada teste deve ser independente e não depender de outros testes ou do estado global do sistema.
- Isolar dependências externas: Utilize mocks, stubs ou fakes para isolar as dependências externas do código que está sendo testado.
- Mantenha os testes curtos e focados: Testes de unidade devem ser rápidos de executar e focados em um único comportamento ou cenário de teste.
- Utilize dados de teste apropriados: Garanta que seus testes usem dados de teste apropriados para validar cenários diferentes.
Ruim:
private readonly BankAccountService _bankAccountService;
private readonly PaymentProofIssuanceService _paymentProofIssuanceService;
public BankAccountServiceTest()
{
_paymentProofIssuanceService = new();
_bankAccountService = new(_paymentProofIssuanceService);
}
[Fact]
public void Deposit_DadoQueExisteContaInformada_EntãoIncrementeOSaldoDaConta()
{
// Arrange
var accountId = 1;
var depositValue = 100;
// Act
_bankAccountService.Deposit(accountId, depositValue);
// Assert
Assert.Equal(100, _bankAccountService.GetBalance(accountId));
}
[Fact]
public void PayBill_DadoQueContaExistaEPossuaSaldoSuficiente_EntãoDecrementeOSaldoEEmitaOComprovante()
{
// Arrange
var accountId = 1;
var payValue = 50;
// Act
_bankAccountService.PayBill(accountId, payValue);
// Assert
Assert.Equal(50, _bankAccountService.GetBalance(accountId));
Assert.True(_bankAccountService.HasProofOfPaymentBeenIssued);
}
Primeiro problema dos testes acima, eles são dependentes, e precisariam ser executados na ordem correta, pois estão compartilhando um estado (Saldo da conta). Tendo em principio que uma conta inicia com saldo zerado, o primeiro teste sobre o método Deposit sempre deveria ser executado antes do que o segundo, para que o valor do saldo não estive mais zerado na execução do segundo teste.
Segundo problema, estamos dependendo de uma implementação externa a classe atual, a PaymentProofIssuanceService. Isso não está isolando a parte que realmente queremos testar, pois, dependemos que essa classe (PaymentProofIssuanceService) já tenha sido implementada, e que não haja nenhum bug. Isso sem contar as outras dependências que podem existir dentro dela. Por isso a importância de “mockar” dependências externas, tornando as controláveis para que os testes fiquem isolados e independentes.
Melhor:
[Fact]
public void Deposit_DadoQueExisteContaInformada_EntãoIncrementeOSaldoDaConta()
{
// Arrange
var accountId = 1;
var depositValue = 100;
var paymentProofIssuanceService = new Mock<PaymentProofIssuanceService>();
var bankAccountService = new BankAccountService(paymentProofIssuanceService.Object);
// Act
bankAccountService.Deposit(accountId, depositValue);
// Assert
Assert.Equal(100, bankAccountService.GetBalance(accountId));
paymentProofIssuanceService.VerifyAll();
}
[Fact]
public void PayBill_DadoQueContaPossuaSaldoSuficiente_EntãoDecrementeOSaldoEEmitaOComprovante()
{
// Arrange
var accountId = 1;
var payValue = 100;
var paymentProofIssuanceService = new Mock<PaymentProofIssuanceService>();
paymentProofIssuanceService
.Setup(x => x.Generate(accountId, payValue))
.Returns(true);
var bankAccountService = new BankAccountService(paymentProofIssuanceService.Object);
bankAccountService.Deposit(accountId, 150);
// Act
bankAccountService.PayBill(accountId, payValue);
// Assert
Assert.Equal(50, _bankAccountService.GetBalance(accountId));
Assert.True(_bankAccountService.HasProofOfPaymentBeenIssued);
}
Note que para cada teste está sendo instanciando a classe sobre teste, isso é uma boa maneira de isolar os testes. A técnica de SetUp (Construtor) e TearDown (Dispose) é outra maneira de manter os testes isolados também.
Use valores aleatórios como parâmetro de entrada de seus testes
O uso de valores aleatórios como parâmetro de entrada nos testes de unidade é uma prática recomendada para melhorar a qualidade e a cobertura dos testes. Essa abordagem permite explorar uma variedade maior de cenários, detectar limites e garantir a independência dos testes.
Um pacote muito utilizado para esse fim, é o AutoFixture. O uso dele facilita a geração de valores randômicos para tipos nativos do C# e para criação de objetos complexos (Modelos, Entidades, etc), que podem ser bem chatos de criar na mão.
Ruim:
[Fact]
public void Deposit_DadoQueSejaFornecidoValoresPositivos_EntãoIncrementeOSaldo()
{
// Arrange
var accountId = 1;
var depositValue = 1_000;
var paymentProofIssuanceService = new Mock<PaymentProofIssuanceService>();
var bankAccountService = new BankAccountService(paymentProofIssuanceService.Object);
// Act
bankAccountService.Deposit(accountId, depositValue);
// Assert
Assert.Equal(1_000, bankAccountService.GetBalance(accountId));
}
Melhor:
[Fact]
public void Deposit_DadoQueSejaFornecidoValoresPositivos_EntãoIncrementeOSaldo()
{
// Arrange
var fixture = new Fixture();
var accountId = fixture.Create<int>();
var depositValue = fixture.Create<decimal>();
var paymentProofIssuanceService = new Mock<PaymentProofIssuanceService>();
var bankAccountService = new BankAccountService(paymentProofIssuanceService.Object);
// Act
bankAccountService.Deposit(accountId, depositValue);
// Assert
Assert.Equal(depositValue, bankAccountService.GetBalance(accountId));
}
Poderíamos usar o [Theory] junto dos atributos [InlineData], [DataMember] ou [ClassData], para gerar múltiplos cenários com valores diferentes, porém, o uso do mesmo faz mais sentido quando existem cenários que requerem entradas com valores específicos.
Mensurar cobertura de código testado
Para garantir a eficácia dos testes de unidade, é essencial medir a cobertura de código testado. A cobertura de código é uma métrica que indica a porcentagem de código que é exercida pelos testes automatizados. Ela oferece insights valiosos sobre a qualidade e a abrangência dos testes, ajudando a identificar áreas do código que não foram adequadamente testadas. Com .NET, podemos usar a CLI com o comando test, e em conjunto uma ferramenta chamada ReportGenerator.
Algumas práticas recomendadas para mensurar a cobertura de código testado:
- Utilizar ferramentas de cobertura de código: Existem diversas ferramentas que fornecem relatórios detalhados que mostram quais partes do código foram executadas pelos testes e quais partes ficaram sem cobertura.
- Definir metas de cobertura: Estabelecer metas de cobertura é uma prática recomendada para orientar o processo de teste.
- Integrar a medição de cobertura ao processo de CI/CD: Para garantir que a cobertura de código seja sempre avaliada, é recomendado integrar a medição de cobertura ao processo de integração contínua e entrega contínua (CI/CD).
Usar a cobertura de código para teste de unidade — .NET | Microsoft Learn
Uma ótima documentação de referência sobre o processo de coleta da cobertura de código em .NET
Os testes de unidade desempenham um papel fundamental na garantia da qualidade do software, ajudando a identificar problemas e prevenir falhas. Neste post, exploramos algumas práticas importantes para aprimorar os testes de unidade.