Vinicius Quinafelex Alves

🌐English version

[C#] Introdução ao teste unitário

Teste unitário é um estilo de teste automatizado para assegurar que um trecho de código está produzindo os resultados esperados. Testes unitários podem ser usados em qualquer processo de desenvolvimento, e está na base do test-driven development.

Um teste unitário não deve consumir recursos externos, como banco de dados, APIs ou disco rígido - por definição, um teste que valida múltiplas partes trabalhando em conjunto é chamado de teste de integração.

Estrutura

Um teste unitário pode ser expressado em três partes, também chamado de triplo A:
- Arranjar (arrange): Inicialização de objetos, recursos e variáveis necessárias para executar o teste
- Agir (act): Execução da função testada
- Assertar (assert): Verificar se a função testada gerou os resultados esperados

Existem múltiplos cenários e estratégias diferentes na escrita de testes unitários. Abaixo estão algumas implementações básicas usando a biblioteca NUnit.

Exemplo 1 - Testando o output

O método executado deve retornar um output consistente com os parâmetros. Por exemplo, um extension method StandardDeviation deve calcular o desvio padrão de um conjunto de valores.

[Test]
public void StandardDeviation_CorrectValue()
{
    // Arrange
    var values = new int[] { 1, 1, 1, 2 };
    
    // Act
    var result = values.StandardDeviation();

    // Assert
    Assert.AreEqual(0.5, result);
}

O teste escrito usa números decimais simples, mas em outros cenários pode ser necessário aplicar uma tolerância para o ponto flutuante.

Exemplo 2 - Testando o estado

O método executado pode alterar algum estado. Por exemplo, a classe DistinctList permite adicionar valores, ignorando valores duplicados, e todas as inclusões válidas alteram a contagem de elementos.

[TestCase(new int[] { }, 0)]
[TestCase(new int[] { 0 }, 1)]
[TestCase(new int[] { 0, 0 }, 1)]
[TestCase(new int[] { 0, 1 }, 2)]
public void DistinctList_AddDuplicated_ChangesCount(int[] values, int expectedCount)
{
    // Arrange
    var list = new DistinctList<int>();
    
    // Act
    foreach(var value in values)
        list.Add(value);

    // Assert
    var count = list.Count();
    Assert.AreEqual(expectedCount, count);
}

Este teste verifica o valor de Count em diferentes cenários: quando não ocorre inserção, quando ocorre apenas uma inserção, quando ocorre inserção de valor duplicado e quando ocorre inserção de múltiplos valores.

Exemplo 3 - Testando chamada de método

Certas funcionalidades só devem ser disparadas em determinadas condições. Por exemplo, um AuthenticationService só deve enviar um e-mail quando o usuário possuir um endereço de e-mail.

[TestCase(null, 0)]
[TestCase("test@test.com", 1)]
public void DistinctList_AddDuplicated_ChangesCount(string? email, int emailsSent)
{
    // Arrange
    var mockService = new EmailServiceCount();
    var user = new User(email);

    IEmailService emailService = mockService;
    var authService = new AuthenticationService(emailService);

    // Act
    authService.Approve(user);

    // Assert
    Assert.AreEqual(emailsSent, mockService.EmailsSent);
}

Neste cenário, foi usado um mock do EmailService. Neste mock, o Send apenas contabiliza quantas vezes a função foi chamada, sem tentar enviar um e-mail. Mocking pode ser feito manualmente, como no trecho abaixo, ou usando uma biblioteca de mocking como o moq.

public class EmailServiceCount : IEmailService
{
    public int EmailsSent { get; private set; } = 0;

    public void Send(MailMessage email)
    {
        EmailsSent++;
    }
}

Criação e manutenção

Bibliotecas de teste automatizados costumam ser gratuítas, mas é necessário tempo e esforço dos desenvolvedores para escrever e realizar manutenção nestes testes.

Porém, implementar testes normalmente costumam valer a pena, pois é possível detectar bugs mais rápido, garantir que novas funcionalidades sejam implementadas corretamente, expor erros quando ocorrem modificações em funcionalidades existentes, e no geral deixam o software mais previsível e confiável.

Teste automatizado não é um recurso que necessariamente precisa substituir testes manuais ou cobrir 100% do código fonte. É possível se beneficiar dele mesmo que seja implementado apenas nas funcionalidades principais, o que já ajuda a otimizar a garantia de qualidade do software.

Em comparação com testes manuais, testes automatizados podem ser reexecutados rapidamente a qualquer momento, deixando o processo geral de testes mais enxuto, barato e rápido, e permitindo uma validação constante dentro de um processo de continuous delivery.

Testabilidade de código

Teste unitário é uma das formas mais baratas de teste automatizado, mas mesmo que a maioria das funções possam ser testadas via automação, nem todos os estilos de código são bons para a escrita de testes unitários, pois alguns estilos tornam a escrita dos testes difícil, custosa ou impossível. Por exemplo:

- Funções que usam variáveis imprevisíveis, que não podem ser acessadas externamente ou variáveis globais
- Funções que diretamente se comunicam com recursos externos, como um banco de dados ou uma API
- Funções que possuem muitas regras de negócio ou que possuem muitos caminhos alternativos

Felizmente, a maioria dos códigos podem ser refatorados para melhorar a testabilidade do código e permitir a criação de testes unitários. Códigos com alta testabilidade implicam em funções menores e mais granulares, que são mais fáceis de entender, reutilizar, estender e modificar.