Mantendo os Testes Valiosos: Por Que a Resistência à Refatoração É Tão Importante para os Testes?
Um bom teste de unidade não se quebra com uma nova roupa; ele se importa com o que você faz, não como você se veste.
Quanto mais nos aprofundamos nos testes e nos princípios a serem seguidos, técnicas e melhores práticas, mais o conjunto de testes beneficiará o software e a equipe de desenvolvimento. Criar uma suíte de testes confiável e valiosa é essencial. Obviamente é uma tarefa difícil, mas não é impossível! Então, vamos examinar mais profundamente um conceito muito importante: a resistência à refatoração de testes unitários. Vamos entender mais sobre esse princípio essencial e espero que este artigo possa te ajudar.
Se gostar do conteúdo, por favor, compartilhe e deixe seu like no post! Isso me ajuda e incentiva a continuar a trazer conteúdos em forma de texto também!😄
O que é resistência à refatoração? 🧐
Acho importante explorar ao máximo a palavra, vamos buscar a ajuda do dicionário e entender o significado e a origem da palavra resistir:
Ação ou efeito de resistir, de não ceder nem sucumbir.
A definição acima é interessante. Afinal, como um teste não pode ceder diante de determinada situação? Nós entenderemos em breve. Agora veja a origem da palavra resistência:
A palavra “resistir” tem origem na palavra latina “resistentia”, que por sua vez é derivada do verbo “resistere”. O verbo "resistere" é composto de duas partes: o prefixo "re-", que significa "contra" ou "de novo", e o verbo "sistere", que significa "ficar de pé", "parar" ou "sustentar" . Portanto, a palavra “resistência” implica a capacidade de se opor ou suportar algo, seja uma força, um estresse ou uma mudança. - Fonte Etimonline.
Temos muito que entender aqui. Gostaria de destacar esta parte do texto acima:
Portanto, a palavra “resistência” implica a capacidade de se opor ou apoiar algo, seja uma força, um estresse ou uma mudança.
Gostaria que você memorizasse rapidamente as palavras “mudar” e “apoiar (suportar)”. Como isso está relacionado com a resistência à refatoração que nossos testes deveriam ter, principalmente pensando em testes unitários?
Na engenharia de software, especificamente no contexto de testes de software, o termo “resistência” é usado para descrever a capacidade de um teste de suportar alterações no código sem falhar, desde que o comportamento observável do software não seja alterado. O que se espera de um teste é facilitar as refatorações enquanto mantém a funcionalidade existente. Este é um princípio importante que beneficia enormemente o conjunto de testes! Afinal, queremos testes que falhem quando o comportamento for quebrado, porque o comportamento observável do software é o que importa para o cliente e para os especialistas do domínio.
Nos livros Unit Testing Principles, Practices, and Patterns - de Vladimir Khorikov e Effective Software Testing: A Developer's Guide - de Mauricio Aniche, os autores explicam a importância de aplicar esse princípio (conceito) aos testes. Vamos entender melhor o tema abaixo!
Por que esse conceito é tão importante? 🚨
Para entender melhor o papel dos testes resistentes à refatoração dentro de uma suíte de testes robusta e confíavel, podemos fazer uma analogia.
Pense em um eletricista que precisa verificar o funcionamento e a integridade das instalações elétricas de uma residência. Normalmente, a ferramenta de teste usada pelos eletricistas com mais frequência é um multímetro, que mede tensão, corrente e resistência elétrica para realizar testes e diagnosticar problemas. O multímetro foi projetado para fornecer resultados precisos e confiáveis independentemente de pequenas variações no ambiente, como temperatura e umidade, ou alterações na configuração do circuito elétrico que não afetem seu funcionamento geral.
Mas vamos supor que o multímetro que um eletricista utiliza forneça informações imprecisas, que precisam de ajustes ou arredondamentos o tempo todo, ou o eletricista precise ficar atento e até mesmo realizar alguma manutenção manual no multímetro, ou seja, a ferramenta que deveria estar ajudando agora passa a transmitir insegurança, desconfiança e uma leve perda de tempo. Nenhum eletricista quer isso!
A resistência à refatoração em testes unitários é semelhante à confiabilidade do multímetro. Se após cada refatoração você precisar de alterações nos testes unitários, isso indica que algo não está certo na suíte de testes. Testes frágeis e sensíveis à refatoração podem levar a um grande número de falsos positivos, tornando mais difícil para os desenvolvedores identificar e corrigir problemas reais no código.
Assim como um multímetro deve fornecer resultados precisos e confiáveis sob diferentes condições, os testes unitários resistentes à refatoração devem ser capazes de resistir a alterações de código, desde que o comportamento observável do software não seja alterado. Isto permite aos desenvolvedores modificar e melhorar o código com confiança, assim como um eletricista pode confiar no multímetro para diagnosticar problemas e verificar a integridade das instalações elétricas.
Além da confiabilidade e segurança, existe o fator tempo. Testes frágeis, por causa da refatoração, podem consumir muito tempo dependendo da complexidade e do nível de experiência da equipe para lidar com essas falhas. É importante lembrar que o teste é uma ferramenta para nos ajudar a ganhar tempo, e não a perder cada vez mais tempo. Vejamos os principais prejuízos que testes com características falhas trazem ao software:
Dificuldade em manter o código: Quando os testes são frágeis e quebram facilmente após refatorações, os desenvolvedores podem hesitar em fazer alterações no código. Isso pode levar ao acúmulo de dívida técnica e dificultar a manutenção do código no longo prazo.
Os desenvolvedores perdem a confiança nos testes: Se os testes falham frequentemente devido à falta de resistência à refatoração, começamos a desconfiar da qualidade dos testes e, por extensão, do próprio software. Isso pode levar a menos confiança na eficácia dos testes e na qualidade geral do código.
Maior tempo de desenvolvimento: Testes que quebram com frequência exigem que os desenvolvedores gastem mais tempo corrigindo e ajustando os testes, em vez de se concentrarem em escrever novo código ou melhorar a funcionalidade existente. Isso pode resultar em atrasos e aumento dos custos de desenvolvimento.
Desencorajamento da refatoração: Um prejuízo que tenho visto é que isso desencoraja os desenvolvedores de fazer melhorias no código porque eles podem ter medo de quebrar os testes existentes. Isso pode levar a uma relutância em resolver problemas de design e a uma diminuição na qualidade geral do software.
Dificuldade em detectar problemas reais: Quando os testes não são resistentes à refatoração, pode ser difícil distinguir as falhas reais das falsas. Isso pode fazer com que os desenvolvedores ignorem problemas reais ou gastem tempo investigando problemas que não afetam o comportamento do software.
Podemos mostrar um exemplo mais prático disso acontecendo, imagine um sistema de gestão de estoque:
public class InventoryManager
{
private List<Product> _products;
public InventoryManager()
{
_products = new List<Product>();
}
public void AddProduct(Product product)
{
if (product == null)
{
throw new ArgumentNullException("Product cannot be null.");
}
_products.Add(product);
}
public List<Product> GetProducts()
{
return _products;
}
}
Agora veja o teste unitário:
public class InventoryManagerTests
{
[Fact]
public void AddProduct_ThrowsArgumentNullException_WhenProductIsNull()
{
// Arrange
var inventoryManager = new InventoryManager();
// Act
var exception = Assert.Throws<Exception>(() => inventoryManager.AddProduct(null));
// Check if the exception message is correct
Assert.Equal("Product cannot be null.", exception.Message);
}
}
Este exemplo é clássico e serve muito bem para fins didáticos. O teste de unidade verifica a mensagem de exceção para garantir que ela seja igual a "Product cannot be null."
.
Já fiz muitos testes como esse, e o maior problema que sempre enfrentei foi a famosa mudança na mensagem que quebraria o teste. Embora possa parecer uma boa ideia à primeira vista, torna o teste frágil à refatoração.
Se um dia a mensagem for alterada, o teste falhará, mesmo que a funcionalidade principal de lançar uma exceção ainda funcione. Queremos evitar isso!
Então é importante pensar e analisar se isso é ou não essencial para o teste, neste caso, vemos que não importa qual mensagem é lançada, apenas queremos que o comportamento ocorra. A refatoração para o teste poderia ser assim:
[Fact]
public void AddProduct_ThrowsArgumentNullException_WhenProductIsNull()
{
// Arrange
var inventoryManager = new InventoryManager();
// Act & Assert
Assert.Throws<Exception>(() => inventoryManager.AddProduct(null));
}
// Method
public void AddProduct(Product product)
{
if (product == null)
{
throw new Exception("Product cannot be null!!!!"); // Any change in the message does not affect the test!
}
_products.Add(product);
}
Qualquer alteração na mensagem não afeta o teste! Talvez isso ainda não seja suficiente para te convencer, por isso vou fornecer outro exemplo um pouco diferente.
Imagine que você está na equipe de e-commerce e precisa implementar uma regra de negócio que concede um desconto de 10% em pedidos acima de $100. Você escreve um teste unitário para garantir que o desconto seja aplicado. O teste verifica o valor do desconto chamando diretamente um método que calcula o desconto baseado no valor total do pedido. No entanto, a implementação é frágil porque se a lógica de aplicação do desconto for refatorada para considerar outros fatores, como categorias de produtos ou promoções especiais durante certos períodos do ano, o teste falhará, mesmo que a regra de negócio de "10% de desconto em pedidos acima de $100" ainda seja respeitada.
Vamos ver um exemplo de um teste que parece comum de se escrever mas que não é resistente a refatorações:
public class OrderTests
{
[Fact]
public void OrderHasCorrectDiscount_WhenExceedingThreshold()
{
// Arrange
var order = new Order();
order.AddItem(new OrderItem { Price = 110.00 });
var expectedDiscount = 110.00 * 0.10; // 10% de desconto
// Act
order.ApplyDiscounts(); // Supondo que este método aplica todos os descontos relevantes
// Assert
Assert.Equal(expectedDiscount, order.Discount);
}
}
O teste OrderHasCorrectDiscount_WhenExceedingThreshold
apresenta um problema significativo em termos de manutenção e flexibilidade. O teste assume que o desconto é sempre de 10% para pedidos acima de $100, o que significa que ele está rigidamente acoplado a essa regra de negócio específica.
Acoplamento com a Lógica de Negócio: O teste está diretamente vinculado à regra de negócio atual de que todos os pedidos acima de $100 recebem um desconto de 10%. Se a política de descontos mudar, o teste falhará, mesmo que a nova política seja corretamente implementada.
Falta de Flexibilidade: O teste não é flexível o suficiente para acomodar mudanças nas regras de negócio, como descontos variáveis baseados em categorias de produtos, promoções sazonais ou descontos progressivos.
Duplicação de Lógica: O teste duplica a lógica de cálculo do desconto que está sendo testada. Idealmente, o teste não deve replicar a lógica que está presente no código de produção, pois isso aumenta o risco de erros paralelos e torna o teste menos útil como uma ferramenta de verificação independente.
Manutenção Difícil: Qualquer mudança na forma como os descontos são aplicados exigirá que o teste seja atualizado. Isso pode levar a uma manutenção onerosa e aumentar o risco de o teste se tornar obsoleto ou incorreto após mudanças no código de produção.
Para resolver esses problemas, o teste pode ser reescrito para verificar se um desconto foi aplicado quando as condições corretas são atendidas, sem se prender a um valor específico de desconto.
public class OrderTests
{
[Fact]
public void DiscountIsApplied_WhenOrderExceedsThreshold()
{
// Arrange
var order = new Order();
order.AddItem(new OrderItem { Price = 110.00 }); // Preço acima do limiar para desconto
// Act
order.ApplyDiscounts(); // Supõe-se que este método aplica todos os descontos relevantes
// Assert
// Verifique se o desconto aplicado é diferente de zero.
Assert.NotEqual(0, order.Discount);
}
}
E se esse campo order.Discount
for um booleano? Melhor ainda!
public class OrderTests
{
[Fact]
public void DiscountIsApplied_WhenOrderExceedsThreshold()
{
// Arrange
var order = new Order();
order.AddItem(new OrderItem { Price = 110.00 }); // Preço acima do limiar para desconto
// Act
order.ApplyDiscounts(); // Supõe-se que este método aplica todos os descontos relevantes
// Assert
// Verifique se a propriedade booleana 'DiscountApplied' é verdadeira.
Assert.True(order.DiscountApplied, "Discount should be applied for orders exceeding the threshold.");
}
}
ATENÇÃO! ⚠️
Lembre-se que o exemplo é apenas didático! A estrutura e o design do teste unitário dependem fortemente do design do código subjacente. O teste de unidade reflete muito o design do seu código e suas escolhas, se algo não cheira bem, provavelmente precisa o design do código precisa ser reconsiderado!
Além disso, a abordagem de testar se um desconto foi aplicado sem verificar o valor exato é útil para garantir que a lógica de alto nível está funcionando conforme esperado. No entanto, isso não substitui a necessidade de testes unitários mais específicos que verificam os cálculos de desconto. O exemplo que forneci é uma pequena parte de um sistema, lógicamente descontos podem variar muito de cada região, país e contexto de negócio. Cabe aos programadores avaliarem as estrátegias ideais e também cenários e comportamentos que devem ser importantes ou não para testes de unidade.
Em muitos casos, pode ser que os testes de ponta a ponta feitos por um framework de automação em conjunto com a equipe de QA seja o suficiente para avaliar o cenário! Para complementar veja o cenário de testes abaixo, mundando um pouco o exemplo.
Regras de negócio hipotéticas para validação de voucher:
Um voucher deve ter um código único.
Um voucher deve estar dentro do prazo de validade.
Um voucher pode ter um valor mínimo de compra.
Um voucher pode ser limitado a um número específico de usos.
public class VoucherTests
{
private VoucherService _service;
private IRepository<Voucher> _voucherRepository; // Abstração do repositório para acessar dados do voucher.
public VoucherTests()
{
_service = new VoucherService(_voucherRepository);
}
[Fact]
public void VoucherService_Validate_ReturnsTrueForValidVoucher()
{
// Arrange
var validVoucher = new Voucher
{
Code = "VALID2023",
ExpiryDate = DateTime.UtcNow.AddDays(10), // Data de validade no futuro
MinimumSpend = 50,
RemainingUses = 5
};
_voucherRepository.Add(validVoucher); // Adiciona o voucher ao repositório simulado
// Act
var validationResult = _service.ValidateVoucher(validVoucher.Code, 100); // Valor da compra de 100
// Assert
Assert.True(validationResult.IsValid, "Voucher válido deve retornar válido.");
}
[Fact]
public void VoucherService_Validate_ReturnsFalseForExpiredVoucher()
{
// Arrange
var expiredVoucher = new Voucher
{
Code = "EXPIRED2023",
ExpiryDate = DateTime.UtcNow.AddDays(-1), // Data de validade no passado
MinimumSpend = 0,
RemainingUses = 1
};
_voucherRepository.Add(expiredVoucher);
// Act
var validationResult = _service.ValidateVoucher(expiredVoucher.Code, 100);
// Assert
Assert.False(validationResult.IsValid, "Voucher expirado não deve retornar válido.");
}
// Testes adicionais para outras regras como valor mínimo de compra e limite de usos...
}
public class VoucherService
{
private IRepository<Voucher> _repository;
public VoucherService(IRepository<Voucher> repository)
{
_repository = repository;
}
public ValidationResult ValidateVoucher(string code, decimal purchaseAmount)
{
var voucher = _repository.FindByCode(code);
if (voucher == null || voucher.ExpiryDate < DateTime.UtcNow ||
voucher.MinimumSpend > purchaseAmount || voucher.RemainingUses <= 0)
{
return new ValidationResult { IsValid = false };
}
voucher.RemainingUses--;
_repository.Update(voucher);
return new ValidationResult { IsValid = true };
}
}
// Representa o resultado da validação
public class ValidationResult
{
public bool IsValid { get; set; }
}
// Interface para um repositório genérico.
public interface IRepository<T>
{
void Add(T entity);
void Update(T entity);
T FindByCode(string code);
}
O exemplo acima ilustra um teste de unidade bem estruturado que se concentra no comportamento da aplicação em vez de detalhes específicos de implementação, vamos conversar rapidamente sobre os principais pontos positivos:
Uso de Abstrações: A utilização da interface
IRepository<Voucher>
desacopla o serviço de teste de qualquer implementação específica do repositório de dados. Isso significa que o serviço pode ser testado independentemente de como os vouchers são armazenados ou recuperados, permitindo que o back-end do repositório seja alterado sem quebrar o teste.Foco no Resultado Final: O teste verifica o resultado final da operação de validação (
IsValid
), em vez de verificar como esse resultado foi alcançado. Isso é um exemplo do princípio "Black Box Testing", onde o teste não se preocupa com os detalhes internos do método que está sendo testado.Testes Descritivos e Comportamentais: Os nomes dos métodos de teste descrevem o comportamento esperado do sistema:
ReturnsTrueForValidVoucher
eReturnsFalseForExpiredVoucher
. Isso facilita a compreensão do propósito do teste e o que ele está verificando.Resistência a Mudanças de Implementação: Como o teste verifica apenas a validade do retorno (
IsValid
), alterações internas no métodoValidateVoucher
que não afetem o comportamento observável não exigirão mudanças nos testes. Por exemplo, se o método de validação interna adicionasse novas regras de validação ou mudasse a ordem das verificações existentes, o teste ainda passaria, desde que o comportamento externo permanecesse consistente.
Quando você pode refatorar sem medo, você sabe que seus testes não estão apenas passando, estão compreendendo.
Agora, lembra que citei anteriormente que testes fracos resultam em falsos positivos? Pois bem vamos falar disso agora!
Cuidado com falsos positivos! 🔍
É importante entender primeiro o que é um falso positivo. Aqui está a definição:
Um falso positivo é um alarme falso. Ocorre quando um teste falha mesmo que a funcionalidade testada esteja funcionando corretamente, ou seja, o teste indica um problema que não existe. Isso pode levar a uma perda de tempo e recursos, pois os desenvolvedores precisam investigar e corrigir esses “problemas” inexistentes.
Como ocorrem os falsos positivos? Normalmente, após a refatoração ser feita, observe que para ser considerado um alarme falso, o comportamento do recurso deve permanecer o mesmo! Então, podemos dizer que existe uma relação entre falsos positivos e resistência à refatoração? Vamos entender isso com outra analogia:
Imagine que você está usando um teste rápido de COVID-19 para verificar se está infectado com o vírus. Esses testes são projetados para serem rápidos e convenientes, mas suponha que o teste que você está usando é muito sensível a variações de temperatura e umidade, o que não é incomum para reações químicas. Em um dia particularmente úmido ou frio, o teste indica um resultado positivo para COVID-19, mesmo que você não esteja realmente infectado. Isso seria um falso positivo, causado não pela presença do vírus, mas pelas condições ambientais.
Você segue as orientações e se isola, talvez até cancele compromissos importantes ou tome medicamentos desnecessariamente. No entanto, ao repetir o teste em condições controladas ou com um teste diferente que não é afetado pelo ambiente, você descobre que está, de fato, saudável e não tem o vírus.
A resistência à refatoração em testes unitários é semelhante à confiabilidade desses testes de COVID-19. Se um teste unitário é altamente sensível a mudanças no código que não afetam a funcionalidade (como refatorações), ele pode falhar e indicar que há um problema quando, na verdade, o comportamento do código permanece correto. Isso pode levar a um desperdício de tempo e recursos, assim como o isolamento desnecessário no caso do teste de COVID-19.
Assim como queremos que os testes de COVID-19 sejam confiáveis e não sejam influenciados por fatores externos irrelevantes, queremos que nossos testes unitários sejam resistentes a refatorações e mudanças no código que não alteram o comportamento pretendido. Isso garante que os testes só falhem quando houver um verdadeiro problema com a funcionalidade, e não devido a mudanças benignas na estrutura do código.
Por exemplo, um teste que acessa diretamente os campos privados de uma classe pode falhar se a estrutura interna da classe for alterada, mesmo que a funcionalidade em si não seja afetada. Então testar métodos privados é considerado uma má prática? Veja o próximo tópico.
Testar métodos privados causa falsos positivos?
Sim! Por que? Testar métodos privados pode levar a falsos positivos porque são considerados uma parte interna e não visível do código que não deve ser acessada diretamente.
Quando testamos métodos privados ou que deveriam ser privados, violamos o conceito de abstração e encapsulamento de dados, o que pode levar a mudanças futuras na implementação desses métodos que podem afetar o comportamento dos seus testes.
Além disso, testar métodos privados torna seus testes dependentes da implementação interna desses métodos, o que significa que se a implementação mudar, os testes falharão. Vejamos um exemplo:
// All methods are exposed, this is not good!
public class NameValidator
{
public List<string> InvalidNames { get; set; }
public NameValidator()
{
InvalidNames = new List<string> { "test", "admin", "user" };
}
public bool IsValidName(string name)
{
return IsNameLengthValid(name) && NameDoesNotContainNumbers(name) && NameIsNotInInvalidList(name);
}
public bool IsNameLengthValid(string name)
{
return name.Length >= 2 && name.Length <= 100;
}
public bool NameDoesNotContainNumbers(string name)
{
foreach (char c in name)
{
if (Char.IsDigit(c)) return false;
}
return true;
}
public bool NameIsNotInInvalidList(string name)
{
return !InvalidNames.Contains(name.ToLower());
}
}
Os métodos da classe NameValidator
devem ser privados porque são detalhes de implementação. Isso pode afetar os testes de unidade porque você pode acabar testando esses métodos diretamente, em vez de testar o comportamento geral do validador de nomes. Idealmente, você deve garantir o encapsulamento adequado dos detalhes da implementação e testar apenas a funcionalidade pública da classe. Por isso é fundamental saber o momento certo para utilizar métodos privados. Veja refatorando a classe:
using System;
using System.Collections.Generic;
public class NameValidator
{
private List<string> InvalidNames { get; set; }
public NameValidator()
{
InvalidNames = new List<string> { "test", "admin", "user" };
}
public bool IsValidName(string name)
{
return IsNameLengthValid(name) && NameDoesNotContainNumbers(name) && NameIsNotInInvalidList(name);
}
private bool IsNameLengthValid(string name)
{
return name.Length >= 2 && name.Length <= 100;
}
private bool NameDoesNotContainNumbers(string name)
{
foreach (char c in name)
{
if (Char.IsDigit(c)) return false;
}
return true;
}
private bool NameIsNotInInvalidList(string name)
{
return !InvalidNames.Contains(name.ToLower());
}
}
Legal, mas como ocultar detalhes de implementação de uma classe evita falsos positivos em testes? Quando refatoramos a classe e tornamos os métodos de validação privados, você está focando em testes unitários que verificam o comportamento esperado da classe como um todo, em vez de testar a implementação interna.
Ao tornar os métodos privados e focar em testes unitários para o método público, você minimiza a chance de falsos positivos. Isso ocorre porque você testará a combinação de todas as regras de validação juntas, e não uma função específica. Com esta abordagem, o teste só será aprovado se todas as regras de validação estiverem funcionando corretamente juntas, garantindo que a lógica geral da classe esteja funcionando conforme o esperado.
Se o desenvolvedor mantiver tudo na classe como acessível diretamente, ou seja public
, podemos cair em falsos positivos porque o desenvolvedor poderia testar cada método individualmente.
Esta situação pode levar a um falso positivo porque o teste individual do método interno pode passar, mas a validação completa da classe pode não estar funcionando como deveria!
Se não entendeu, fique tranquilo. Vamos lá! Por exemplo, um teste unitário para IsNameLengthValid
pode verificar se o nome tem entre 2 e 100 caracteres e passar. Um teste para NameDoesNotContainNumbers
pode verificar se o nome não contém números e também passar. E um teste para NameIsNotInInvalidList
pode verificar se o nome não está na lista de nomes inválidos e passar também.
No entanto, esses testes não garantem que o método IsValidName
funcione corretamente como um todo. Por exemplo, se houver uma lógica adicional no método IsValidName
que não está sendo coberta pelos testes dos métodos privados, ou se a forma como esses métodos são combinados no IsValidName
for alterada, isso pode levar a resultados inesperados que os testes unitários individuais não capturariam.
Para demonstrar o risco e o perigo de testar apenas métodos privados individualmente, vamos considerar a seguinte situação hipotética:
Suponha que o programador(a) decida adicionar uma nova regra de validação ao método IsValidName
que verifica se o nome não começa com um caractere especial. O desenvolvedor atualiza o método IsValidName
, mas esquece de criar um teste unitário para essa nova regra. Os testes existentes para os métodos privados IsNameLengthValid
, NameDoesNotContainNumbers
e NameIsNotInInvalidList
ainda passarão, porque eles não foram afetados pela mudança:
public bool IsValidName(string name)
{
return IsNameLengthValid(name) &&
NameDoesNotContainNumbers(name) &&
NameIsNotInInvalidList(name) &&
NameDoesNotStartWithSpecialCharacter(name); // Nova regra adicionada
}
private bool NameDoesNotStartWithSpecialCharacter(string name)
{
// Suponha que esta é a nova regra de validação
return !char.IsPunctuation(name[0]);
}
Agora, vamos ver como um teste unitário para o método IsValidName
poderia ser escrito para cobrir todas as regras de validação:
using Xunit;
public class NameValidatorTests
{
[Theory]
[InlineData("John", true)] // Nome válido
[InlineData("Jo", true)] // Nome válido com o comprimento mínimo
[InlineData("J", false)] // Nome muito curto
[InlineData("John123", false)] // Nome contém números
[InlineData("admin", false)] // Nome está na lista de inválidos
[InlineData("!John", false)] // Nome começa com um caractere especial
public void IsValidName_ReturnsCorrectResult(string name, bool expected)
{
// Arrange
var validator = new NameValidator();
// Act
var result = validator.IsValidName(name);
// Assert
Assert.Equal(expected, result);
}
}
Se o escritor desse teste tivesse garantido os testes apenas dos métodos privados individualmente, o teste para a nova regra NameDoesNotStartWithSpecialCharacter
poderia ter sido esquecido. No entanto, ao testar o método público IsValidName
, que é o ponto de entrada para a funcionalidade de validação de nomes, qualquer alteração ou adição de regras de validação será capturada.
Se o teste falhar para o caso de teste com "!John"
, isso indica que há algo de errado com a implementação da nova regra de validação ou que há uma regressão no comportamento esperado do método IsValidName
. Isso destaca a importância de testar o comportamento esperado da classe através de seus métodos públicos, garantindo que todas as regras de validação funcionem juntas como esperado e evitando falsos positivos que podem surgir ao testar apenas os métodos privados individualmente.
Testes robustos não gritam com cada mudança de vento; eles sussurram as direções corretas durante a refatoração.
Desta forma garantimos que a classe NameValidator
está funcionando corretamente sem nos preocupar com os detalhes internos de implementação. O que mais pode gerar falsos positivos? Vamos falar rapidamente sobre afirmações desnecessárias.
Asserts Desnecessários! 😅
Ter muitas afirmações pode tornar os testes frágeis para refatorações? Dependendo do teste e do comportamento que você está tentando abordar, sim! Especialmente se estivermos expondo detalhes de implementação ou detalhes que não são relevantes para testes. Podemos listar alguns motivos:
Acoplamento excessivo: quando um teste de unidade tem muitas afirmações, ele tende a ser acoplado demais aos detalhes de implementação do código. Isso significa que qualquer pequena alteração na implementação, mesmo que não afete o comportamento desejado, pode causar falha no teste.
A legibilidade e a manutenção tornam-se difíceis: Testes com muitas afirmações podem ser mais difíceis de ler e manter, especialmente se não estiverem bem estruturados. Isso pode levar a testes frágeis e propensos a erros, aumentando a chance de falsas falhas (falsos positivos) após refatorações que não afetam o comportamento observável do software.
O tempo de desenvolvimento aumenta: Se for difícil identificar onde está o problema, o tempo para corrigi-lo aumenta, o que gera estresse e desgaste mental aos desenvolvedores.
Vejamos um breve exemplo, considere o teste abaixo:
public class OrderTests
{
[Fact]
public void CreateOrder_ValidInput_CreatesOrderWithCorrectPropertiesAndTotal()
{
// Arrange
var customerId = 1;
var orderItems = new List<OrderItem>
{
new OrderItem { ProductId = 101, Quantity = 2, Price = 10 },
new OrderItem { ProductId = 102, Quantity = 1, Price = 5 }
};
// Act
var order = new Order(customerId, orderItems);
// Assert
Assert.Equal(customerId, order.CustomerId);
Assert.Equal(2, order.OrderItems.Count);
Assert.Equal(101, order.OrderItems[0].ProductId);
Assert.Equal(2, order.OrderItems[0].Quantity);
Assert.Equal(10, order.OrderItems[0].Price);
Assert.Equal(102, order.OrderItems[1].ProductId);
Assert.Equal(1, order.OrderItems[1].Quantity);
Assert.Equal(5, order.OrderItems[1].Price);
Assert.Equal(25, order.Total);
}
}
O teste verifica se CustomerId
e Total
estão corretos, o teste também verifica cada propriedade dos itens do pedido. Essas afirmações adicionais são desnecessárias para o teste, tornando-o mais sensível a refatorações e mais difícil de ler e manter. Se você escrever testes dessa maneira, talvez seja melhor reconsiderar os testes que focam em algumas asserções. Quanto menos detalhes colocarmos em nossos testes, melhor! Isso trará muitos benefícios para a suíte de testes final do projeto, que podemos citar:
Facilita a depuração: Quando um teste falha, é mais fácil identificar a causa se houver apenas um motivo para a falha. Com menos afirmações, você pode identificar rapidamente o comportamento específico que está sendo testado e encontrar a causa da falha.
Melhora a legibilidade: Testes com menos asserções são geralmente mais simples e fáceis de ler. Isso permite que outros desenvolvedores entendam rapidamente o propósito do teste e o comportamento esperado do código.
Testes focados: Evitar muitas afirmações ajuda a manter os testes focados em um único comportamento ou unidade de funcionalidade. Isso torna os testes mais robustos e menos propensos a falhar devido a alterações não relacionadas no código.
A manutenção fica mais fácil: Testes com menos asserções são geralmente mais fáceis de manter, pois estão menos ligados aos detalhes de implementação do código. Isso significa que quando você precisar fazer alterações em seu código, será menos provável que você precise fazer alterações correspondentes em seus testes.
Traz mais confiança no conjunto de testes: Um conjunto de testes bem estruturado com testes focados e menos
Asserts
aumenta a confiança na eficácia dos testes. Isso pode levar a uma detecção mais rápida e precisa de problemas de código, melhorando a qualidade geral do software.
Tente colocar essas dicas em prática na próxima vez que estiver escrevendo testes, busque primeiro a simplicidade. Os testes devem ser ferramentas úteis que agilizam nosso trabalho! Vamos concluir com um tópico que muitos tem dúvidas.
Avaliando a Exposição de Mensagens ao Usuário nos Asserts de Testes de Unidade
Mensagens de texto que são apresentadas ao usuário final em uma aplicação desempenham um papel importante na experiência do usuário. Elas podem ser instruções, mensagens de erro, confirmações, entre outras. Em testes de unidade, a verificação dessas mensagens pode ser crucial para garantir que a comunicação do software está correta e alinhada com as expectativas e requisitos do usuário final.
Quando Incluir Mensagens em Asserts
1. Consistência da Mensagem é Crítica: Quando a exatidão da mensagem é vital, como em mensagens legais, termos de serviço, ou instruções de segurança, os asserts devem verificar se a mensagem é exatamente como deveria ser.
2. Fluxo de Negócio Dependente da Mensagem: Se a mensagem faz parte do fluxo de negócios e qualquer alteração pode confundir o usuário ou alterar a interpretação de uma funcionalidade, é importante incluir a verificação desta no teste.
3. Estabilidade das Mensagens: Se as mensagens raramente mudam ou são controladas por uma equipe que entende a importância da estabilidade na interface do usuário, os asserts podem ser utilizados para assegurar que elas permaneçam inalteradas.
Quando Evitar Mensagens em Asserts
1. Mensagens Sujeitas a Mudanças Frequentes: Se o texto é frequentemente alterado para melhorar a UX/UI ou por razões de marketing, evite assertivas diretas, pois isso pode levar a uma manutenção pesada dos testes.
2. Internacionalização e Localização: Para aplicativos que são traduzidos para várias línguas, testar mensagens exatas pode ser impraticável e ineficiente. Neste caso, é preferível testar códigos de erro ou identificadores de mensagens.
3. Personalização de Mensagens: Se as mensagens são personalizadas para o usuário ou configuráveis pelos clientes, é melhor testar a lógica de personalização do que o conteúdo exato.
Estratégias de Assertiva
1. Usar Códigos ou Identificadores: Em vez de mensagens textuais completas, teste códigos de erro ou identificadores de mensagens que são menos propensos a mudanças e são mais fáceis de verificar.
2. Abstrair Mensagens: Utilize recursos de abstração de mensagens, como arquivos de recursos ou constantes, que podem ser referenciados nos testes, minimizando o impacto das mudanças de texto.
3. Testar Estrutura, Não Conteúdo: Confirme a estrutura da mensagem (por exemplo, se contém um link ou botão) em vez do conteúdo exato, o que permite flexibilidade na redação sem sacrificar a integridade do teste.
Conclusão
O objetivo principal do teste de unidade é garantir que, à medida que o sistema evolui, a funcionalidade permaneça consistente e confiável. Os testes que são resistentes à refatoração desempenham um papel crítico para atingir esse objetivo. Eles fornecem uma rede de segurança que permite aos desenvolvedores melhorar o design, a estrutura e o desempenho do código sem medo de introduzir inadvertidamente regressões ou bugs.
A resistência à refatoração é essencial para manter o valor dos testes a longo prazo. Quando os testes estão fortemente acoplados aos detalhes da implementação, qualquer alteração no código, por mais trivial que seja, pode fazer com que os testes falhem, levando a um alarme falso que desperdiça o tempo do desenvolvedor e corrói a confiança no conjunto de testes. Em vez disso, ao projetar testes que se concentram no comportamento e nos resultados – o que o código deve fazer, e não como o faz – garantimos que eles permaneçam relevantes e informativos, independentemente das alterações internas na base de código.
Além disso, os testes resistentes à refatoração facilitam um processo de desenvolvimento mais ágil e adaptável. Eles capacitam os desenvolvedores a refatorar o código com confiança, sabendo que os testes continuarão a proteger contra regressões. Isto é particularmente importante nas práticas modernas de desenvolvimento, onde a refatoração não é apenas uma tarefa única, mas um processo contínuo de melhorias incrementais.
Em última análise, o verdadeiro valor de um teste não é determinado pela sua capacidade de passar, mas pela sua capacidade de sinalizar quando o comportamento pretendido do sistema mudou. Os testes resistentes à refatoração mantêm seu valor ao longo do tempo, ajudando as equipes a fornecer software que não apenas seja funcional hoje, mas que também permaneça robusto e fácil de manter no futuro. Eles garantem que os testes não apenas cumpram requisitos, mas contribuam ativamente para a qualidade e integridade do ciclo de vida de desenvolvimento de software.