Partilhar via


Práticas recomendadas de teste de unidade com .NET Core e .NET Standard

Existem inúmeros benefícios em escrever testes de unidade; Eles ajudam na regressão, fornecem documentação e facilitam um bom design. No entanto, testes de unidade difíceis de ler e frágeis podem causar estragos em sua base de código. Este artigo descreve algumas práticas recomendadas em relação ao design de teste de unidade para seus projetos .NET Core e .NET Standard.

Neste guia, você aprende algumas práticas recomendadas ao escrever testes de unidade para manter seus testes resilientes e fáceis de entender.

Por John Reese com agradecimento especial a Roy Osherove

Porquê o teste unitário?

Há várias razões para usar testes de unidade.

Menos tempo realizando testes funcionais

Os testes funcionais são caros. Eles geralmente envolvem abrir o aplicativo e executar uma série de etapas que você (ou outra pessoa) deve seguir para validar o comportamento esperado. Essas etapas nem sempre são conhecidas pelo testador. Eles terão que entrar em contato com alguém mais experiente na área para realizar o teste. O teste em si pode levar segundos para alterações triviais ou minutos para alterações maiores. Por fim, esse processo deve ser repetido para cada alteração que você fizer no sistema.

Os testes de unidade, por outro lado, levam milissegundos, podem ser executados pressionando um botão e não exigem necessariamente nenhum conhecimento do sistema em geral. Se o teste passa ou não é da responsabilidade do testador, não do indivíduo.

Proteção contra regressão

Defeitos de regressão são defeitos que são introduzidos quando uma alteração é feita no aplicativo. É comum que os testadores não apenas testem seu novo recurso, mas também testem recursos que existiam anteriormente para verificar se os recursos implementados anteriormente ainda funcionam conforme o esperado.

Com o teste de unidade, é possível executar novamente todo o conjunto de testes após cada compilação ou mesmo depois de alterar uma linha de código. Dando a você a confiança de que seu novo código não interrompe a funcionalidade existente.

Documentação executável

Pode nem sempre ser óbvio o que um determinado método faz ou como ele se comporta dada uma determinada entrada. Você pode se perguntar: Como esse método se comporta se eu passar uma cadeia de caracteres em branco? Nulo?

Quando você tem um conjunto de testes de unidade bem nomeados, cada teste deve ser capaz de explicar claramente a saída esperada para uma determinada entrada. Além disso, deve poder verificar se funciona efetivamente.

Código menos acoplado

Quando o código está firmemente acoplado, pode ser difícil fazer o teste de unidade. Sem criar testes de unidade para o código que você está escrevendo, o acoplamento pode ser menos aparente.

Escrever testes para seu código naturalmente desacoplará seu código, porque seria mais difícil testar de outra forma.

Características de um bom teste unitário

  • Rápido: Não é incomum que projetos maduros tenham milhares de testes de unidade. Os testes de unidade devem levar pouco tempo para serem executados. Milésimos de segundo.
  • Isolado: os testes de unidade são autônomos, podem ser executados isoladamente e não dependem de fatores externos, como um sistema de arquivos ou banco de dados.
  • Repetível: A execução de um teste de unidade deve ser consistente com seus resultados, ou seja, ele sempre retorna o mesmo resultado se você não alterar nada entre as execuções.
  • Autoverificação: O teste deve ser capaz de detetar automaticamente se passou ou falhou sem qualquer interação humana.
  • Oportuno: um teste de unidade não deve levar um tempo desproporcionalmente longo para ser escrito em comparação com o código que está sendo testado. Se você achar que testar o código leva uma grande quantidade de tempo em comparação com escrever o código, considere um design que seja mais testável.

Cobertura do código

Uma alta porcentagem de cobertura de código é frequentemente associada a uma maior qualidade de código. No entanto, a medição em si não pode determinar a qualidade do código. Definir uma meta percentual de cobertura de código excessivamente ambiciosa pode ser contraproducente. Imagine um projeto complexo com milhares de ramificações condicionais e imagine que você estabeleceu uma meta de 95% de cobertura de código. Atualmente o projeto mantém 90% de cobertura de código. O tempo necessário para contabilizar todos os casos extremos nos 5% restantes pode ser um empreendimento enorme, e a proposta de valor diminui rapidamente.

Uma alta porcentagem de cobertura de código não é um indicador de sucesso, nem implica alta qualidade de código. Ele representa apenas a quantidade de código que é coberta por testes de unidade. Para obter mais informações, consulte Cobertura do código de teste de unidade.

Vamos falar a mesma língua

O termo mock é, infelizmente, muitas vezes mal utilizado quando se fala de testes. Os pontos a seguir definem os tipos mais comuns de falsificações ao escrever testes de unidade:

Falso - Um falso é um termo genérico que pode ser usado para descrever um esboço ou um objeto simulado. Se é um esboço ou um simulado depende do contexto em que é usado. Por outras palavras, um falso pode ser um esboço ou um simulado.

Mock - Um objeto simulado é um objeto falso no sistema que decide se um teste de unidade foi aprovado ou falhou. Um simulado começa como um Fake até ser afirmado contra.

Stub - Um stub é um substituto controlável para uma dependência (ou colaborador) existente no sistema. Usando um stub, você pode testar seu código sem lidar diretamente com a dependência. Por padrão, um esboço começa como falso.

Considere o seguinte trecho de código:

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

O exemplo anterior seria de um esboço sendo referido como uma simulação. Neste caso, é um esboço. Você está apenas passando na Ordem como um meio de poder instanciar Purchase (o sistema em teste). O nome MockOrder também é enganoso porque, novamente, a ordem não é uma simulação.

Uma melhor abordagem seria:

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Ao renomear a classe para FakeOrder, você tornou a classe muito mais genérica. A aula pode ser usada como um simulado ou um esboço, o que for melhor para o caso de teste. No exemplo anterior, FakeOrder é usado como um esboço. Você não está usando FakeOrder de qualquer forma durante a afirmação. FakeOrder foi passado para a Purchase classe para satisfazer os requisitos do construtor.

Para usá-lo como um simulado, você pode fazer algo como o seguinte código:

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

Neste caso, você está verificando uma propriedade no Fake (afirmando contra ele), portanto, no trecho de código anterior, o mockOrder é um Mock.

Importante

É importante que esta terminologia esteja correta. Se você chamar seus stubs de "simulados", outros desenvolvedores farão suposições falsas sobre sua intenção.

A principal coisa a lembrar sobre mocks versus stubs é que mocks são como stubs, mas você afirma contra o objeto mock, enquanto você não afirma contra um stub.

Melhores práticas

Aqui estão algumas das melhores práticas mais importantes para escrever testes de unidade.

Evite dependências de infraestrutura

Tente não introduzir dependências na infraestrutura ao escrever testes de unidade. As dependências tornam os testes lentos e frágeis e devem ser reservados para testes de integração. Você pode evitar essas dependências em seu aplicativo seguindo o Princípio de Dependências Explícitas e usando a Injeção de Dependência. Você também pode manter seus testes de unidade em um projeto separado de seus testes de integração. Essa abordagem garante que seu projeto de teste de unidade não tenha referências ou dependências em pacotes de infraestrutura.

Nomeando seus testes

O nome do seu teste deve consistir em três partes:

  • O nome do método que está sendo testado.
  • O cenário em que está a ser testado.
  • O comportamento esperado quando o cenário é invocado.

Porquê?

Os padrões de nomenclatura são importantes porque expressam explicitamente a intenção do teste. Os testes são mais do que apenas garantir que seu código funcione, eles também fornecem documentação. Apenas olhando para o conjunto de testes de unidade, você deve ser capaz de inferir o comportamento do seu código sem sequer olhar para o código em si. Além disso, quando os testes falham, você pode ver exatamente quais cenários não atendem às suas expectativas.

Incorreto:

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Melhor:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Organizar os testes

Arrange, Act, Assert é um padrão comum quando o teste de unidade. Como o nome indica, consiste em três ações principais:

  • Organize seus objetos, crie-os e configure-os conforme necessário.
  • Agir sobre um objeto.
  • Afirmar que algo está como esperado.

Porquê?

  • Separa claramente o que está sendo testado das etapas de organizar e afirmar .
  • Menos chance de misturar asserções com o código "Act".

A legibilidade é um dos aspetos mais importantes ao escrever um teste. Separar cada uma dessas ações dentro do teste destaca claramente as dependências necessárias para chamar seu código, como seu código está sendo chamado e o que você está tentando afirmar. Embora possa ser possível combinar algumas etapas e reduzir o tamanho do teste, o objetivo principal é torná-lo o mais legível possível.

Incorreto:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

Melhor:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add("");

    // Assert
    Assert.Equal(0, actual);
}

Escreva testes minimamente aprovados

A entrada a ser usada em um teste de unidade deve ser a mais simples possível para verificar o comportamento que você está testando no momento.

Porquê?

  • Os testes tornam-se mais resilientes a futuras alterações na base de código.
  • Mais próximo do comportamento de teste do que da implementação.

Os testes que incluem mais informações do que o necessário para passar no teste têm uma maior probabilidade de introduzir erros no teste e podem tornar a intenção do teste menos clara. Ao escrever testes, você quer se concentrar no comportamento. Definir propriedades extras em modelos ou usar valores diferentes de zero quando não é necessário, apenas prejudica o que você está tentando provar.

Incorreto:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

Melhor:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Evite cordas mágicas

Nomear variáveis em testes de unidade é importante, se não mais importante, do que nomear variáveis no código de produção. Os testes de unidade não devem conter cadeias mágicas.

Porquê?

  • Evita a necessidade de o leitor do teste inspecionar o código de produção, a fim de descobrir o que torna o valor especial.
  • Mostra explicitamente o que você está tentando provar em vez de tentar realizar.

Cordas mágicas podem causar confusão ao leitor dos seus testes. Se uma cadeia de caracteres parecer fora do comum, eles podem se perguntar por que um determinado valor foi escolhido para um parâmetro ou valor de retorno. Esse tipo de valor de cadeia de caracteres pode levá-los a examinar mais de perto os detalhes da implementação, em vez de se concentrar no teste.

Gorjeta

Ao escrever testes, você deve procurar expressar o máximo de intenção possível. No caso de cadeias mágicas, uma boa abordagem é atribuir esses valores a constantes.

Incorreto:

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

Melhor:

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

Evite a lógica nos testes

Ao escrever seus testes de unidade, evite concatenação manual de cadeia de caracteres, condições lógicas, como if, while, fore , e switchoutras condições.

Porquê?

  • Menos chance de introduzir um bug dentro de seus testes.
  • Concentre-se no resultado final, em vez de detalhes de implementação.

Quando você introduz lógica em seu conjunto de testes, a chance de introduzir um bug aumenta drasticamente. O último lugar que você deseja encontrar um bug é dentro do seu conjunto de testes. Você deve ter um alto nível de confiança de que seus testes funcionam, caso contrário, você não confiará neles. Testes em que você não confia, não fornecem nenhum valor. Quando um teste falha, você quer ter a sensação de que algo está errado com seu código e que ele não pode ser ignorado.

Gorjeta

Se a lógica no seu teste parecer inevitável, considere dividir o teste em dois ou mais testes diferentes.

Incorreto:

[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
    var stringCalculator = new StringCalculator();
    var expected = 0;
    var testCases = new[]
    {
        "0,0,0",
        "0,1,2",
        "1,2,3"
    };

    foreach (var test in testCases)
    {
        Assert.Equal(expected, stringCalculator.Add(test));
        expected += 3;
    }
}

Melhor:

[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add(input);

    Assert.Equal(expected, actual);
}

Prefira métodos auxiliares para configurar e derrubar

Se você precisar de um objeto ou estado semelhante para seus testes, prefira um método auxiliar do que usar Setup e Teardown atributos, se existirem.

Porquê?

  • Menos confusão ao ler os testes, uma vez que todo o código é visível de dentro de cada teste.
  • Menor chance de configurar muito ou pouco para o teste dado.
  • Menor chance de compartilhar o estado entre os testes, o que cria dependências indesejadas entre eles.

Em estruturas de teste de unidade, Setup é chamado antes de cada teste de unidade dentro do seu conjunto de testes. Embora alguns possam ver isso como uma ferramenta útil, geralmente acaba levando a testes inchados e difíceis de ler. Cada teste geralmente terá requisitos diferentes, a fim de colocar o teste em funcionamento. Infelizmente, Setup obriga-o a utilizar exatamente os mesmos requisitos para cada teste.

Nota

xUnit removeu tanto o SetUp quanto o TearDown a partir da versão 2.x

Incorreto:

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
// more tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}

Melhor:

[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// more tests...
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

Evite atos múltiplos

Ao escrever os testes, tente incluir apenas um ato por teste. As abordagens comuns à utilização de um único ato incluem:

  • Crie um teste separado para cada ato.
  • Use testes parametrizados.

Porquê?

  • Quando o teste falha, fica claro qual ato está falhando.
  • Garante que o teste seja focado em apenas um caso.
  • Dá-lhe uma imagem completa do motivo pelo qual os seus testes estão a falhar.

Vários atos precisam ser afirmados individualmente e não é garantido que todas as asserções serão executadas. Na maioria das estruturas de teste de unidade, quando um Assert falha em um teste de unidade, os testes de procedimento são automaticamente considerados como falhando. Este tipo de processo pode ser confuso, pois a funcionalidade que está realmente funcionando, será mostrada como falhando.

Incorreto:

[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
    // Act
    var actual1 = stringCalculator.Add("");
    var actual2 = stringCalculator.Add(",");

    // Assert
    Assert.Equal(0, actual1);
    Assert.Equal(0, actual2);
}

Melhor:

[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add(input);

    // Assert
    Assert.Equal(expected, actual);
}

Validar métodos privados por métodos públicos de teste de unidade

Na maioria dos casos, não deve haver necessidade de testar um método privado. Os métodos privados são um detalhe de implementação e nunca existem isoladamente. Em algum momento, haverá um método voltado para o público que chama o método privado como parte de sua implementação. O que você deve se preocupar é com o resultado final do método público que chama o privado.

Considere o seguinte caso:

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

Sua primeira reação pode ser começar a escrever um teste porque TrimInput você quer garantir que o método está funcionando conforme o esperado. No entanto, é totalmente possível que ParseLogLine manipule sanitizedInput de tal forma que você não espera, tornando um teste contra TrimInput inútil.

O verdadeiro teste deve ser feito contra o método ParseLogLine voltado para o público, porque é com isso que, em última análise, você deve se preocupar.

public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

Com este ponto de vista, se você vir um método privado, encontre o método público e escreva seus testes contra esse método. Só porque um método privado retorna o resultado esperado, não significa que o sistema que eventualmente chama o método privado usa o resultado corretamente.

Referências estáticas de stub

Um dos princípios de um teste de unidade é que ele deve ter controle total do sistema em teste. Este princípio pode ser problemático quando o código de produção inclui chamadas para referências estáticas (por exemplo, DateTime.Now). Considere o seguinte código:

public int GetDiscountedPrice(int price)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Como é que este código pode ser testado por unidade? Você pode tentar uma abordagem como:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(2, actual)
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(1, actual);
}

Infelizmente, você perceberá rapidamente que há alguns problemas com seus testes.

  • Se o conjunto de testes for executado em uma terça-feira, o segundo teste será aprovado, mas o primeiro teste falhará.
  • Se o conjunto de testes for executado em qualquer outro dia, o primeiro teste será aprovado, mas o segundo teste falhará.

Para resolver esses problemas, você precisará introduzir uma costura em seu código de produção. Uma abordagem é encapsular o código que você precisa controlar em uma interface e fazer com que o código de produção dependa dessa interface.

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Seu conjunto de testes agora se torna o seguinte:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(2, actual);
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(1, actual);
}

Agora, o conjunto de testes tem controle total e DateTime.Now pode extrair qualquer valor ao chamar o método.