Práticas recomendadas de teste de unidade para .NET
Existem inúmeros benefícios em escrever testes de unidade. Eles ajudam na regressão, fornecem documentação e facilitam um bom design. Mas quando os testes de unidade são difíceis de ler e frágeis, eles podem causar estragos em sua base de código. Este artigo descreve algumas práticas recomendadas para projetar testes de unidade para dar suporte a seus projetos .NET Core e .NET Standard. Você aprende técnicas para manter seus testes resilientes e fáceis de entender.
Por John Reese com um agradecimento especial ao Roy Osherove
Benefícios dos testes unitários
As seções a seguir descrevem vários motivos para escrever testes de unidade para seus projetos .NET Core e .NET Standard.
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 têm 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. O corredor de teste determina se o teste passa ou falha, não o indivíduo.
Proteção contra regressão
Defeitos de regressão são erros 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 existentes ainda funcionam conforme o esperado. Com o teste de unidade, você pode executar novamente todo o conjunto de testes após cada compilação ou mesmo depois de alterar uma linha de código. Essa abordagem ajuda a aumentar 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 string em branco ou null? Quando você tem um conjunto de testes de unidade bem nomeados, cada teste deve explicar claramente a saída esperada para uma determinada entrada. Além disso, o ensaio 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 o teu código naturalmente desacopla o código, pois de outra forma seria mais difícil de testar.
Características dos bons testes unitários
Existem várias características importantes que definem um bom teste de unidade:
- Fast: 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 de forma isolada 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 os seus resultados. O teste devolve sempre o mesmo resultado se não alterares nada entre execuções.
- Autoverificação: O teste deve detectar 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ê descobrir que testar o código leva uma grande quantidade de tempo em comparação com a escrita do código, considere um design mais testável.
Cobertura de código e qualidade de 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 suponha que se defina uma meta de cobertura de código% a 95%. Atualmente, o projeto mantém 90% cobertura de código. O tempo necessário para levar em conta todos os casos extremos nos 5% restantes pode ser um empreendimento enorme, e a proposta de valor perde rapidamente o seu apelo.
Uma alta porcentagem de cobertura de código não é um indicador de sucesso, e não implica alta qualidade de código. Ele representa apenas a quantidade de código coberto por testes de unidade. Para obter mais informações, consulte a cobertura de testes de unidade do código .
Terminologia de testes unitários
Vários termos são usados com frequência no contexto de testes de unidade: fake, mocke stub. Infelizmente, esses termos podem ser mal aplicados, por isso é importante entender o uso correto.
Fake: Um falso é um termo genérico que pode ser usado para descrever um esboço ou um objeto simulado. Se o objeto é um esboço ou uma simulação depende do contexto em que o objeto é usado. Em outras palavras, um "fake" pode referir-se a um esboço ou uma simulação.
Mock: Um objeto simulado é um objeto falso no sistema que decide se um teste de unidade passa ou falha. Uma simulação começa como falsa e permanece falsa até entrar numa operação
Assert
.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 código:
var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);
Este código mostra um esboço conhecido como simulação. Mas, neste cenário, o esboço é realmente um esboço. O objetivo do código é passar a ordem como um meio de instanciar o objeto Purchase
(o sistema em teste). O nome da classe MockOrder
é enganoso porque a ordem é um esboço e não uma simulação.
O código a seguir mostra um design mais preciso:
var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);
Quando a classe é renomeada para FakeOrder
, a classe é mais genérica. A aula pode ser usada como um simulado ou um esboço, de acordo com os requisitos do caso de teste. No primeiro exemplo, a classe FakeOrder
é usada como um stub e não é usada durante a operação Assert
. O código passa a classe FakeOrder
para a classe Purchase
apenas para satisfazer os requisitos do construtor.
Para usar a classe como um simulado, você pode atualizar o código:
var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(mockOrder.Validated);
Neste design, o código verifica uma propriedade no falso (afirmando contra ele) e, portanto, a classe mockOrder
é uma simulação.
Importante
É importante implementar a terminologia corretamente. Se chamares os teus stubs de "mocks", outros desenvolvedores farão suposições falsas sobre a tua intenção.
O principal a lembrar acerca de mocks em comparação com stubs é que os mocks são semelhantes aos stubs, exceto no processo de Assert
. Você executa Assert
operações num objeto simulado, mas não num stub.
Melhores práticas
Existem várias práticas recomendadas importantes a serem seguidas ao escrever testes de unidade. As seções a seguir fornecem exemplos que mostram como aplicar as práticas recomendadas ao seu código.
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 injeção de dependência do .NET. 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.
Siga os padrões de nomenclatura de teste
O nome do seu teste deve consistir em três partes:
- Nome do método a ser testado
- Cenário em que o método está a ser testado
- Comportamento esperado quando o cenário é invocado
Os padrões de nomenclatura são importantes porque ajudam a expressar o propósito e a aplicaçã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 e não ter que 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.
Código original
[Fact]
public void Test_Single()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}
Aplicar as melhores práticas
[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}
Marque os seus testes
O padrão "Organizar, Agir, Afirmar" é uma abordagem comum para escrever testes de unidade. Como o nome indica, o padrão consiste em três tarefas principais:
- Organizar seus objetos, criá-los e configurá-los conforme necessário
- Ato sobre um objeto
- Afirmar que algo está como esperado
Ao seguir o padrão, você pode separar claramente o que está sendo testado das tarefas Organizar e Afirmar. O padrão também ajuda a reduzir a oportunidade de asserções se misturarem com o código na tarefa Act.
A legibilidade é um dos aspetos mais importantes ao escrever um teste de unidade. Separar cada ação de padrão dentro do teste destaca claramente as dependências necessárias para chamar seu código, como seu código é chamado e o que você está tentando afirmar. Embora possa ser possível combinar algumas etapas e reduzir o tamanho do teste, o objetivo geral é torná-lo o mais legível possível.
Código original
[Fact]
public void Add_EmptyString_ReturnsZero()
{
// Arrange
var stringCalculator = new StringCalculator();
// Assert
Assert.Equal(0, stringCalculator.Add(""));
}
Aplicar as melhores práticas
[Fact]
public void Add_EmptyString_ReturnsZero()
{
// Arrange
var stringCalculator = new StringCalculator();
// Act
var actual = stringCalculator.Add("");
// Assert
Assert.Equal(0, actual);
}
Escreva testes que passem minimamente
A entrada para um teste de unidade deve ser a informação mais simples necessária para verificar o comportamento que você está testando no momento. A abordagem minimalista ajuda os testes a se tornarem mais resilientes a futuras alterações na base de código e se concentram na verificação do comportamento ao longo da implementação.
Os testes que incluem mais informações do que o necessário para passar no teste atual 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 confirmar.
Código original
[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("42");
Assert.Equal(42, actual);
}
Aplicar as melhores práticas
[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}
Evite cordas mágicas
Magic strings são valores de string codificados diretamente em seus testes de unidade, sem qualquer código, comentário extra ou contexto. Esses valores tornam seu código menos legível e mais difícil de manter. 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.
Tip
Garanta que o seu objetivo seja demonstrar o máximo de intenção possível no seu código de teste de unidade. Em vez de usar cadeias mágicas, atribua quaisquer valores codificados a constantes.
Código original
[Fact]
public void Add_BigNumber_ThrowsException()
{
var stringCalculator = new StringCalculator();
Action actual = () => stringCalculator.Add("1001");
Assert.Throws<OverflowException>(actual);
}
Aplicar as melhores práticas
[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 lógica de programação em testes de unidade
Ao escrever os seus testes de unidade, evite a concatenação manual de strings, condições lógicas, como if
, while
, for
e switch
, e outras condições. Se você incluir lógica em seu conjunto de testes, a chance de introduzir bugs aumenta drasticamente. O último lugar onde você quer 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 pode 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.
Tip
Se a adição de lógica em seu teste parecer inevitável, considere dividir o teste em dois ou mais testes diferentes para limitar os requisitos de lógica.
Código original
[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;
}
}
Aplicar as melhores práticas
[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);
}
Use métodos auxiliares em vez de Setup e Teardown
Se você precisar de um objeto ou estado semelhante para seus testes, use um método auxiliar em vez de Setup
e Teardown
atributos, se existirem. Os métodos auxiliares são preferidos em relação a esses atributos por vários motivos:
- Menos confusão ao ler os testes porque todo o código é visível de dentro de cada teste
- Menor probabilidade de configurar demasiado ou pouco para o teste em causa
- Menor chance de compartilhar o estado entre os testes, o que cria dependências indesejadas entre eles
Em estruturas de teste de unidade, o atributo Setup
é chamado antes de cada teste de unidade dentro da sua suite de testes. Alguns programadores veem esse comportamento como útil, mas muitas vezes resulta em testes inchados e difíceis de ler. Cada teste geralmente tem requisitos diferentes para configuração e execução. Infelizmente, o atributo Setup
força você a usar exatamente os mesmos requisitos para cada teste.
Observação
Os atributos SetUp
e TearDown
são removidos no xUnit versão 2.x e posterior.
Código original
Aplicar as melhores práticas
private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
stringCalculator = new StringCalculator();
}
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
var stringCalculator = CreateDefaultStringCalculator();
var actual = stringCalculator.Add("0,1");
Assert.Equal(1, actual);
}
// More tests...
// More tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
var result = stringCalculator.Add("0,1");
Assert.Equal(1, result);
}
private StringCalculator CreateDefaultStringCalculator()
{
return new StringCalculator();
}
Evite várias tarefas de Agir
Ao escrever seus testes, tente incluir apenas uma tarefa Act por teste. Algumas abordagens comuns para implementar uma única tarefa de ato incluem a criação de um teste separado para cada ato ou o uso de testes parametrizados. Há vários benefícios em usar uma única tarefa Act para cada teste:
- Você pode facilmente discernir qual tarefa Act está falhando se o teste falhar.
- Você pode garantir que o teste seja focado em apenas um caso.
- Você ganha uma imagem clara sobre por que seus testes estão falhando.
As tarefas de vários atos precisam ser declaradas individualmente e você não pode garantir que todas as tarefas de declaração sejam executadas. Na maioria das estruturas de teste de unidade, depois que uma tarefa Assert falha em um teste de unidade, todos os testes subsequentes são automaticamente considerados como falha. O processo pode ser confuso porque algumas funcionalidades de trabalho podem ser interpretadas como falhas.
Código original
[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
// Act
var actual1 = stringCalculator.Add("");
var actual2 = stringCalculator.Add(",");
// Assert
Assert.Equal(0, actual1);
Assert.Equal(0, actual2);
}
Aplicar as melhores práticas
[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 com métodos públicos
Na maioria dos casos, você não precisa testar um método privado em seu código. Os métodos privados são um detalhe de implementação e nunca existem isoladamente. Em algum momento do processo de desenvolvimento, você introduz um método voltado para o público para chamar o método privado como parte de sua implementação. Quando você escreve seus testes de unidade, o que lhe importa é o resultado final do método público que chama o privado.
Considere o seguinte cenário de código:
public string ParseLogLine(string input)
{
var sanitizedInput = TrimInput(input);
return sanitizedInput;
}
private string TrimInput(string input)
{
return input.Trim();
}
Em termos de teste, sua primeira reação pode ser escrever um teste para o método TrimInput
para garantir que ele funcione conforme o esperado. No entanto, é possível que o método ParseLogLine
manipule o objeto sanitizedInput
de uma maneira que você não espera. O comportamento desconhecido pode tornar seu teste em relação ao método TrimInput
inútil.
Um teste melhor neste cenário é verificar o método ParseLogLine
voltado para o público:
public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
var parser = new Parser();
var result = parser.ParseLogLine(" a ");
Assert.Equals("a", result);
}
Quando você encontrar um método privado, localize o método público que chama o método privado e escreva seus testes em relação ao método público. Só porque um método privado retorna um resultado esperado, não significa que o sistema que eventualmente chama o método privado usa o resultado corretamente.
Manipule referências estáticas de stub com costuras
Um princípio de um teste de unidade é que ele deve ter controle total do sistema em teste. No entanto, esse princípio pode ser problemático quando o código de produção inclui chamadas para referências estáticas (por exemplo, DateTime.Now
).
Examine o seguinte cenário de código:
public int GetDiscountedPrice(int price)
{
if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
{
return price / 2;
}
else
{
return price;
}
}
Você pode escrever um teste de unidade para este código? Você pode tentar executar uma tarefa Assert no price
:
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ê rapidamente percebe que há alguns problemas com o seu teste:
- Se o conjunto de testes for executado na terça-feira, o segundo teste passa, mas o primeiro teste falha.
- 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, precisa introduzir uma de costura no 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;
}
}
Você também precisa escrever uma nova versão do seu conjunto de testes:
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 sobre o valor DateTime.Now
e pode extrair qualquer valor ao chamar o método.