Mantendo os Testes Valiosos: O que é o princípio DAMP? É possível aplicá-lo aos testes?
Se o teste em si não for autoexplicativo, há algo errado!
Falar em testes de unidade gera muitas dúvidas e questionamentos sobre como montar bons testes e mantê-los valiosos à medida que o software é desenvolvido e cresce. Muitos desenvolvedores buscam estudar as melhores práticas para escrever seus testes. Neste post falaremos sobre um tema que ocorreu em um grupo de discussão sobre teste de software do qual participo:
O que é DAMP?
É possível aplicar DRY/DAMP em um conjunto de testes?
Como colocar em prática o DAMP em testes de unidade?
Vamos começar entendendo melhor o que significa a sigla.
DAMP e seu propósito
DAMP (tradução é Frases Descritivas e Significativas) não é levado muito a sério ainda. Por que estou dizendo isso? Na minha experiência profissional, sempre me deparei com testes que demoram muito para serem compreendidos. Às vezes os testes verificavam comportamentos simples ou na maioria das vezes os testes sempre testavam partes importantes do software. Especialmente para nós que desenvolvemos funcionalidades que seguem fluxos complexos que contêm regras críticas de negócio, é importante ter testes nomes claros, que nos informam sobre o que está sendo colocado a prova. Isso facilita muito o dia a dia de qualquer programador que necessita entender o fluxo do domínio em que está trabalhando.
Mas qual deveria ser o propósito do DAMP nos testes de software? O objetivo é trazer testes claros, descritivos e de fácil compreensão, utilizando nomes de variáveis, funções que descrevam exatamente o que está sendo testado e o comportamento esperado. Esta é uma proposta difícil de cumprir sempre!
Mas queria destacar um ponto importante antes de mergulhar mais no DAMP, existe um certo mal entendido em como se aplicar o DAMP e DRY, alguns argumentam que não é possível aplicar os dois princípios ao mesmo tempo nos testes, vamos direto ao assunto no tópico abaixo.
Equilibrando os princípios DRY/DAMP nos testes
Vladimir Khorikov, no seu artigo intitulado ‘DRY vs DAMP in Unit Tests’, discute a importância de equilibrar esses dois princípios ao escrever testes de unidade. Ele argumenta que, enquanto o princípio DRY é crucial no desenvolvimento de software para evitar duplicação de código e facilitar a manutenção, nos testes de unidade, seguir estritamente o DRY pode, paradoxalmente, tornar o código de teste mais difícil de manter e entender.
Khorikov defende o uso de uma abordagem mais DAMP nos testes de unidade, porque isso melhora sua legibilidade e os torna mais úteis como documentação do comportamento do sistema. Ele enfatiza que, nos testes, a clareza deve ser priorizada sobre a redução da duplicação de código. Testes de unidade devem ser autocontidos e fáceis de entender independentemente do resto do conjunto de testes, o que é vital para que sejam eficazes tanto como testes quanto como documentação.
Ele também aborda como a duplicação em testes de unidade, quando gerida corretamente, não é tão prejudicial quanto no código de produção. Em testes, um certo grau de redundância é aceitável se isso ajudar a esclarecer o propósito do teste e como ele está validando o código sob teste.
Resumindo, Khorikov sugere que, ao escrever testes de unidade, os desenvolvedores devem ser pragmáticos na aplicação dos princípios DRY e DAMP. A ideia é manter os testes simples, diretos e fáceis de entender, mesmo que isso signifique repetir código similar em múltiplos testes. Ele argumenta que a manutenibilidade dos testes de unidade se beneficia mais da clareza e da facilidade de compreensão do que da concisão absoluta. Vamos ver mais alguns detalhes desse assunto!
What-to 🆚 How-to
Vladimir Khorikov vai além e destaca uma distinção importante entre "what-to" e "how-to" ao escrever testes de unidade, que é essencial para entender como aplicar corretamente os princípios DRY e DAMP.
What-to
O "what-to" refere-se ao que está sendo testado. Esses são os requisitos ou comportamentos específicos que um teste de unidade visa verificar. O foco aqui é descrever claramente o cenário de teste e o que se espera como resultado. Por exemplo, um teste para verificar se uma conta bancária não permite saques que excedam o saldo seria um "what-to". Este aspecto dos testes deve ser descrito de forma detalhada e expressiva, usando o princípio DAMP para garantir que qualquer pessoa que leia o teste possa entender imediatamente seu propósito e o que está sendo testado.
How-to
O "how-to", por outro lado, diz respeito a como esses testes são implementados. Inclui os detalhes técnicos e os mecanismos de como o teste executa as verificações e configurações necessárias para testar o comportamento desejado. Por exemplo, o setup de mocks ou o processo de criação de objetos de teste específicos são considerados "how-to". Aqui, o princípio DRY é aplicável porque muitas dessas operações técnicas são genéricas e podem ser reutilizadas através de métodos auxiliares ou setup compartilhado para evitar a duplicação de código e simplificar a manutenção dos testes.
Aplicação Prática
Khorikov sugere que, ao escrever testes, você deve garantir que os "what-to" sejam claros e expressivos, aplicando DAMP para tornar os testes legíveis e fáceis de entender. Ao mesmo tempo, os "how-to" podem ser abstraídos e reutilizados, aplicando DRY para minimizar a duplicação de código entre os testes.
Exemplo:
What-to: Um teste que verifica se um usuário pode se registrar em um site apenas se todos os campos obrigatórios forem preenchidos corretamente.
How-to: O código que configura o ambiente de teste, cria um objeto de usuário com todos os campos preenchidos, e invoca a função de registro pode ser abstraído em métodos auxiliares para ser reutilizado em vários testes que precisam de um objeto de usuário pré-configurado.
Esta abordagem permite que os testes permaneçam focados e expressivos sobre o que eles estão testando, enquanto mantêm a base de código de teste limpa, organizada e fácil de manter.
Benefícios deste Equilíbrio
Clareza e Compreensão: Ao aplicar DAMP nos "what-to", os testes tornam-se autoexplicativos, facilitando para qualquer desenvolvedor entender o que está sendo testado sem ter que mergulhar em detalhes complexos da implementação. Isso é especialmente útil em ambientes onde vários desenvolvedores ou equipes possam trabalhar em diferentes partes do sistema.
Manutenção Eficiente: Ao aplicar DRY aos "how-to", reduz-se a duplicação de código de configuração e inicialização nos testes. Isso simplifica as atualizações nos testes quando mudanças são feitas no código subjacente ou na lógica de negócios, pois as mudanças em uma lógica compartilhada de setup ou tearddown são propagadas automaticamente para todos os testes que a utilizam.
Foco no Teste: Separar o "what-to" do "how-to" ajuda a manter o foco no objetivo do teste. Isso assegura que cada teste só está preocupado em verificar uma coisa específica, o que melhora a qualidade dos testes e torna mais fácil identificar falhas.
Assim, ao adotar essa abordagem balanceada, você não só garante que seus testes são robustos e confiáveis, mas também mantém sua base de código de testes organizada, compreensível e fácil de manter. Ao distinguir entre "what-to" e "how-to", você pode escrever testes que não só verificam eficazmente o software, mas também servem como uma forma clara de documentação do comportamento do sistema.
Bom agora podemos focar no DAMP, vamos ver algumas convenções de nomenclaturas para nossos testes!
Convenções de nomenclatura
Aqui está um exemplo:
No teste acima, podemos ter clareza sobre qual comportamento está sendo testado? Bom, vamos analisar alguns pontos:
Qualquer desenvolvedor que precise revisitar esses testes sabe do que se trata o objeto
Voucher
?Quais são os atributos da classe
Voucher
? Que comportamento inválido precisamos verificar?A afirmação
Assert.Equal(6, result.Errors.Count)
parece estar preocupada com um número específico de erros, isso é relevante para o cenário de teste?Devo me preocupar com a lista de erros?
Além disso, o nome do teste não nos ajuda muito a entender o seu real propósito. Sabemos que é um teste para verificar se um Voucher
é inválido, mas desenvolvedores experientes sabem que tal teste não pode afirmar e garantir que todos os comportamentos estão ocorrendo. O DAMP resolve esse problema. Vamos melhorar este teste primeiro refatorando seu nome para uma convenção de nomenclatura que deixe claro para outras pessoas que estão lendo este teste:
public void When_VoucherIsInactive_Expect_VoucherIsInvalid()
public void When_VoucherIsInactive_Expect_VoucherIsNoLongerValid()
public void When_VoucherActiveIsFalse_Expect_VoucherIsNotActive()
Agora com essas sugestões de nomes podemos explicar o objetivo do teste. Quando um voucher deixa de estar ativo ele é marcado como falso no atributo ativo, portanto se este atributo for falso espera-se que o resultado seja uma mensagem de erro que deverá ser exibida ao usuário.
Adicionei dois nomes, pois cada desenvolvedor pode pensar em nomes diferentes de acordo com suas necessidades e experiências. Aprendi que não há problema em voltar e revisitar o nome de um método de teste e tentar encontrar palavras e textos melhores para descrever seu propósito, podemos fazer isso sempre.
Vamos agora focar no DisplayName
que este teste deve mostrar. Podemos melhorar essa parte também? Sim com certeza! Um ponto interessante e importante é que um QA pode olhar atentamente para essas descrições e ter uma noção melhor se os testes estão escritos e estão em conformidade com os requisitos de software.
Além disso, se o cliente para quem você trabalha exige ver os testes de software, você pode direcioná-lo para os testes unitários, mostrando que cada cenário possui testes bem descritos e fáceis de localizar dentro do projeto.
Então para o caso do voucher com o campo ativo falso, poderíamos ter esta descrição:
[Fact(DisplayName = "GIVEN that a voucher is no longer active in the system WHEN a user tries to use it THEN the discount cannot be applied.")]
// another example
[Fact(DisplayName = "Should prevent a user from getting a discount on a purchase by entering a voucher that is no longer active in the system")]
Confesso que podemos melhorar ainda mais. Se você tiver sugestões ou opiniões diferentes, compartilhe nos comentários. Mas após a leitura destas duas descrições de testes entendemos que o comportamento esperado é evitar que o usuário obtenha desconto em um produto/compra utilizando um Voucher
que não está mais ativo no sistema.
Este teste oferece segurança aos especialistas do domínio, pois garante que esse comportamento seja sempre validado pelos testes. Afinal, se o cupom de desconto estiver inativo, o usuário não poderá obter descontos em produtos.
No artigo, “Como escolher a melhor convenção de nomenclatura de método de teste de unidade” (How to Choose the Best Unit Test Method Naming Convention), que recomendo a todos que leiam, você verá as convenções mais comumente usadas para nomenclatura de testes.
Espero que esses pontos tenham esclarecido algumas dúvidas. Estou mostrando opções do que você pode seguir e como organizar objetivamente essas partes importantes do teste. Talvez o escopo de projeto no qual está trabalhando já possua um padrão de nomenclatura que é seguido durante todo o ciclo de desenvolvimento. Mas talvez o seu projeto não tenha nenhuma, por isso é importante estudar e avaliar com todos os membros da equipe qual norma eles mais se identificam e têm mais facilidade para aderir ao projeto.
Vamos prosseguir entendendo agora a importância de ter Arranges claros!
Mantenha a seção Arrange fácil de ler e entender
Se você utiliza o padrão AAA (Arrange-Act-Assert) para organizar o código utilizado nos métodos de teste ou até mesmo outros como GIVEN-WHEN-THEN, você sabe que este bloco dentro dos testes é importante para evitar erros de dependência durante a execução do teste, como por exemplo, System.NullReferenceException: Object reference not set to an instance of an object.
O que é muito comum de ocorrer quando o teste tenta acessar um objeto ou uma referência que ainda não foi inicializada ou configurada. Vejamos mais detalhadamente como esses padrões contribuem para evitar tais problemas:
Clareza e Estrutura
Arrange (GIVEN): Nesta fase, você configura todos os objetos, dependências, mocks e dados necessários para o teste. Isso garante que tudo que será usado no teste esteja pronto e disponível. Se essa etapa não for feita corretamente, você poderá tentar usar um objeto que não foi inicializado, resultando em
NullReferenceException
.Act (WHEN): Aqui, você executa a ação que está sendo testada, como chamar um método ou executar uma função. Esta parte do teste deve se concentrar apenas na ação, sem preocupações adicionais sobre configurações ou inicializações, que já deveriam ter sido resolvidas na fase de Arrange.
Assert (THEN): Na última fase, você verifica os resultados da ação. Isso inclui checar os valores retornados, os estados dos objetos e qualquer efeito colateral esperado. As assertivas focam em confirmar se o resultado da ação está conforme o esperado.
Prevenção de Erros de Execução
Seguir esses padrões impede erros de execução porque cada etapa do teste é claramente definida e separada:
Prevenção de
NullReferenceException
: Ao garantir que todos os objetos estão apropriadamente criados e atribuídos na etapa de Arrange, você elimina o risco de referenciar um objeto nulo durante as fases de Act e Assert.Foco na Responsabilidade Única: Cada parte do teste tem uma responsabilidade clara e definida, o que ajuda a evitar configurações incompletas ou inadequadas que poderiam levar a falhas durante a execução do teste.
Esta parte é essencial para que os testes unitários possam ser executados da maneira correta para atingir seu objetivo. Você já se deparou com testes pouco claros, longos e difíceis de ler em seus blocos de Arrange? Isso pode não ser tão ruim, mas se todos os testes dependerem de uma preparação longa, a legibilidade do teste diminuirá. Aqui está um exemplo do por que isso pode acontecer:
// Implementation Code
public class Order
{
public string CustomerId { get; set; }
public string OrderNumber { get; set; }
public List<OrderLine> Lines { get; set; }
public decimal Value { get { /* return the order's calculated value */ } }
public Order()
{
this.Lines = new List<OrderLine>();
}
}
public class OrderLine
{
public string ItemId { get; set; }
public int QuantityOrdered { get; set; }
public decimal UnitPrice { get; set; }
}
public class OrderManager
{
private ICustomerService customerService;
private IInventoryService inventoryService;
public OrderManager(ICustomerService customerService, IInventoryService inventoryService)
{
// Guard clauses omitted to make example smaller
this.customerService = customerService;
this.inventoryService = inventoryService;
}
// This is the method being tested.
// Return false if this order's value is greater than the customer's credit limit.
// Return false if there is insufficient inventory for any of the items on the order.
// Return false if any of the items on the order on hold.
public bool IsOrderShippable(Order order)
{
// Return false if the order's value is greater than the customer's credit limit
decimal creditLimit = this.customerService.GetCreditLimit(order.CustomerId);
if (creditLimit < order.Value)
{
return false;
}
// Return false if there is insufficient inventory for any of this order's items
foreach (OrderLine orderLine in order.Lines)
{
if (orderLine.QuantityOrdered > this.inventoryService.GetInventoryQuantity(orderLine.ItemId)
{
return false;
}
}
// Return false if any of the items on this order are on hold
foreach (OrderLine orderLine in order.Lines)
{
if (this.inventoryService.IsItemOnHold(orderLine.ItemId))
{
return false;
}
}
// If we are here, then the order is shippable
return true;
}
}
Testes:
// This code does not compile, it only tries to simulate
[TestClass]
public class OrderManagerTests
{
[Fact]
public void IsOrderShippable_OrderIsShippable_ShouldReturnTrue()
{
//ARRANGE
// Setup inventory on-hand quantities for this test
Mock<IInventoryService> inventoryService = new Mock<IInventoryService>();
inventoryService.Setup(e => e.GetInventoryQuantity("ITEM-1")).Returns(10);
inventoryService.Setup(e => e.GetInventoryQuantity("ITEM-2")).Returns(20);
inventoryService.Setup(e => e.GetInventoryQuantity("ITEM-3")).Returns(30);
// Configure each item to be not on hold
inventoryService.Setup(e => e.IsItemOnHold("ITEM-1")).Returns(false);
inventoryService.Setup(e => e.IsItemOnHold("ITEM-2")).Returns(false);
inventoryService.Setup(e => e.IsItemOnHold("ITEM-3")).Returns(false);
// Setup the customer's credit limit
Mock<ICustomerService> customerService = new Mock<ICustomerService>();
customerService.Setup(e => e.GetCreditLimit("CUSTOMER-1")).Returns(1000m);
// Create the order being tested
Order order = new Order { CustomerId = "CUSTOMER-1" };
order.Lines.Add(new OrderLine { ItemId = "ITEM-1", QuantityOrdered = 10, UnitPrice = 1.00m });
order.Lines.Add(new OrderLine { ItemId = "ITEM-2", QuantityOrdered = 20, UnitPrice = 2.00m });
order.Lines.Add(new OrderLine { ItemId = "ITEM-3", QuantityOrdered = 30, UnitPrice = 3.00m });
OrderManager orderManager = new OrderManager(
customerService: customerService.Object,
inventoryService: inventoryService.Object);
//ACT
bool isShippable = orderManager.IsOrderShippable(order);
//ASSERT
Assert.IsTrue(isShippable);
}
}
Meu Deus! Este é um teste muito longo! Mas normalmente pode ser comum ver esse tipo de teste em softwares corporativas. Certamente você já teve que escrever um teste desse tipo. Mas este teste está errado? Não, se ele busca verificar o comportamento e cumpre isso adequadamente não está errado. Mas precisamos ter todas essas dependências no bloco de organização para atingir o propósito do teste? Se quisermos alcançar apenas o caminho feliz, sim.
A questão é: como podemos expandir a cobertura dos testes para incluir cenários além do caminho feliz, que retorna falso ou gera exceções, especialmente quando esses cenários estão contidos em métodos privados da classe? Existe alguma estratégia para tornar os testes mais claros e legíveis?
Claro! Podemos começar a refatorar tentando separar os mocks do inventoryService
. Podemos criar métodos auxiliares dentro da classe de teste para conseguir isso. Também podemos criar um mock para criar o objeto Order
. Além disso, não precisamos configurar todos os mocks se quisermos apenas verificar o comportamento de retorno falso. O objetivo final é chegar a algo assim:
// This code does not compile, it only tries to simulate
[TestClass]
public class OrderManagerTests
{
[TestMethod]
public void IsOrderShippable_OrderValueExceedsCreditLimit_ShouldReturnFalse()
{
// Setup customer credit limit for this test
Mock<ICustomerService> customerService = new Mock<ICustomerService>();
customerService.Setup(e => e.GetCreditLimit("CUSTOMER-1")).Returns(100m);
// Create the order being tested with a value that exceeds the customer's credit limit
Order order = new Order { CustomerId = "CUSTOMER-1" };
order.Lines.Add(new OrderLine { ItemId = "ITEM-1", QuantityOrdered = 10, UnitPrice = 15.00m });
// Create the order manager and test the IsOrderShippable method
OrderManager orderManager = new OrderManager(
customerService: customerService.Object,
inventoryService: null);
bool isShippable = orderManager.IsOrderShippable(order);
// Verify that the order is not shippable because its value exceeds the customer's credit limit
Assert.IsFalse(isShippable);
}
}
Inicialmente montamos a simulação de atendimento para retornar um limite de crédito de 100. Em seguida, criamos um pedido com valor de 150, que ultrapassa o limite de crédito do cliente.
Posteriormente, criamos uma instância do gerenciador de pedidos com serviço de estoque nulo, já que este teste não está relacionado à lógica de estoque, e então chamamos o método IsOrderShippable
com o pedido como parâmetro de entrada.
Como você pode ver, este teste é bastante compacto. Não há simulações desnecessárias que você não espera que o código do cenário atinja. É importante compreender que este é apenas um cenário de exemplo, softwares corporativos tendem a ter mais regras. A nota ao rodapé vou deixar pontos fortes desse teste de uma maneira mais clara e direta.1
Mas a diferença agora está bastante clara, conseguimos reduzir o bloco Arrange para um cenário onde se espera que IsOrderShippable
retorne falso.
Ok, mas e o caso de sucesso (retorno verdadeiro)? Existe alguma maneira de torná-lo mais legível? Sim, veja o exemplo abaixo:
[TestClass]
public class OrderManagerTests
{
private Mock<IInventoryService> _inventoryService;
private Mock<ICustomerService> _customerService;
private OrderManager _orderManager;
[TestInitialize]
public void TestInitialize()
{
_inventoryService = new Mock<IInventoryService>();
_customerService = new Mock<ICustomerService>();
_orderManager = new OrderManager(_customerService.Object, _inventoryService.Object);
}
[Fact]
public void IsOrderShippable_ShouldReturnTrue()
{
// ARRANGE
SetupInventoryForOrder();
SetupCustomerCreditLimit();
Order order = CreateTestOrder();
// ACT
bool isShippable = _orderManager.IsOrderShippable(order);
//ASSERT
Assert.IsTrue(isShippable);
}
private void SetupInventoryForOrder()
{
_inventoryService.Setup(e => e.GetInventoryQuantity("ITEM-1")).Returns(10);
_inventoryService.Setup(e => e.GetInventoryQuantity("ITEM-2")).Returns(20);
_inventoryService.Setup(e => e.GetInventoryQuantity("ITEM-3")).Returns(30);
_inventoryService.Setup(e => e.IsItemOnHold("ITEM-1")).Returns(false);
_inventoryService.Setup(e => e.IsItemOnHold("ITEM-2")).Returns(false);
_inventoryService.Setup(e => e.IsItemOnHold("ITEM-3")).Returns(false);
}
private void SetupCustomerCreditLimit()
{
_customerService.Setup(e => e.GetCreditLimit("CUSTOMER-1")).Returns(1000m);
}
private Order CreateTestOrder()
{
Order order = new Order { CustomerId = "CUSTOMER-1" };
order.Lines.Add(new OrderLine { ItemId = "ITEM-1", QuantityOrdered = 10, UnitPrice = 1.00m });
order.Lines.Add(new OrderLine { ItemId = "ITEM-2", QuantityOrdered = 20, UnitPrice = 2.00m });
order.Lines.Add(new OrderLine { ItemId = "ITEM-3", QuantityOrdered = 30, UnitPrice = 3.00m });
return order;
}
}
Ao usar métodos auxiliares privados, você pode reduzir o tamanho e aumentar a clareza do seu código e testes, proporcionando melhor legibilidade. Além disso, caso o programador(a), queira entender mais profundamente o teste e seus mocks, pode acessar os métodos auxiliares e verificar cada configuração que está sendo realizada. É importante nomear corretamente os métodos e ser explícito sobre seus parâmetros para que o leitor possa entender claramente o tipo de dados que está sendo organizado ao ler o nome e os parâmetros do método.
Podemos usar padrões conhecidos, Object Mother e Data Builder. Test Data Builder é uma boa opção, não é a única, existem outras maneiras de criar objetos para testes, mas isso é tema para outro artigo. Você pode criar classes específicas que têm a responsabilidade de alocar os construtores de dados necessários para o teste. Como no exemplo abaixo:
using System;
using DomainModels;
namespace UnitTestProject.Builders
{
public class EmployeeBuilder
{
private int _id;
private string _firstName;
private string _lastName;
private DateTime _birthDate;
private Address _address;
public EmployeeBuilder()
{
_id = 0;
_firstName = string.Empty;
_lastName = string.Empty;
_birthDate = DateTime.Today;
}
public EmployeeBuilder WithId(int id)
{
_id = id;
return this;
}
public EmployeeBuilder WithFirstName(string firstName)
{
_firstName = firstName;
return this;
}
public EmployeeBuilder WithLastName(string lastName)
{
_lastName = lastName;
return this;
}
public EmployeeBuilder WithBirthDate(DateTime date)
{
_birthDate = date;
return this;
}
public EmployeeBuilder WithAddress(Address address)
{
_address = address;
return this;
}
public Employee Build()
{
return new Employee(_id, _firstName, _lastName, _birthDate, _address);
}
}
}
Abaixo vamos ver como ficou o teste escrito usando o construtor para facilitar a leitura da seção Arrange:
[TestMethod]
public void TestNestedObjectUsingBuilders()
{
// Example for didactic purposes only
var employee = new EmployeeBuilder()
.WithFirstName("test")
.WithAddress(new AddressBuilder().WithCity("Chicago").Build())
.Build();
Assert.AreEqual("Chicago", employee.Address.City);
}
Com isso, conseguimos testes mais claros e focados, que proporcionam:
Melhores nomes para testes;
Testes mais legíveis;
Diagnóstico mais rápido de problemas e cenários quando um teste específico falha;
Mas antes de optar por Data Builders, analise bem se a complexidade do projeto, o design e a arquitetura do projeto levam a cenários tão extremos.
Gostaria de enfatizar que seções extensas do Arrange podem indicar outro fator. Falta de abstração no código. Não vou me aprofundar neste tema agora porque é um assunto profundo e com muitas opiniões e caminhos. Mas uma coisa é certa, temos que ter cuidado e analisar se o nosso código reflete uma abstração amigável ao design do código. Posso listar dois pontos de como a falta de abstração pode afetar as seções Arrange dos testes unitários de várias maneiras:
Complexidade nas dependências: Sem abstração, as dependências entre classes ficam complexas e difíceis de realizar uma configuração dos objetos necessários para testes de forma ágil.
Dificuldade em encontrar erros: Quando há falta de abstração, o programador pode ter dificuldade em localizar a causa raiz do motivo pelo qual aquele teste não está passando, pois a configuração do ambiente de teste pode ser confusa e difícil de depurar.
Neste tópico podemos entender um pouco melhor a importância do DAMP no bloco Arrange. Se você pensar no princípio DAMP para organizar seus testes, você começará a notar uma melhora na qualidade e legibilidade dos seus testes, isso é fato! Principalmente porque o princípio incentiva a organização. Ser DAMP nos blocos Arrange significa manter a clareza.
Seja descritivo e organizado na configuração inicial de um teste unitário. Alguns acham que a aplicação do conceito DAMP só é possível para melhorar os nomes e as descrições de métodos ou variáveis. Mas o objetivo principal é promover a legibilidade do código em todos os sentidos, seja no código de teste ou de implementação. Neste post, aplicamos na prática para testes. Mas também podemos aplicar o conceito ao código de produção.
Por fim, lembra do teste da classe Voucher? Veja como o bloco Arrange ficou mais descritiva:
// BEFORE
var voucher = new Voucher("", null,
null, 0, DiscountTypeVoucher.Percent, DateTime.Now.AddDays(-1), false, true);
// AFTER
var voucher = new Voucher(code: "PROMO-15-OFF",
percentDiscount: 15,
discountValue: 150,
quantity: 1,
typeDiscountVoucher: TypeDiscountVoucher.Percentage,
expirationDate: DateTime.Now.AddDays(10),
active: false
used: false);
E neste caso, optei por não separar para um padrão Data Builder. Porque a seção Arrange deste teste é muito básica. Mas com certeza o teste agora está muito mais descritivo!
Os benefícios do DAMP Essa abordagem é benéfica no dia a dia dos desenvolvedores porque facilita a manutenção, o entendimento e a colaboração entre os membros da equipe. Aqui estão algumas dicas úteis para se inscrever no DAMP:
Escreva nomes descritivos para testes e funções: Use nomes que descrevam o propósito e o comportamento esperado do teste ou função. Isso facilitará a compreensão do teste sem a necessidade de analisar detalhadamente o código.
Evite abreviações: use nomes completos e significativos que expliquem claramente o propósito.
Use convenções de nomenclatura consistentes: siga um padrão de nomenclatura consistente para variáveis, funções e testes. Isso tornará mais fácil identificar padrões e localizar testes específicos.
Mantenha os testes simples e focados: cada teste deve ter uma finalidade específica e testar apenas um aspecto do código. Evite testes longos e complexos que tentam verificar muitos comportamentos diferentes.
Reduza a ambiguidade: Testes com nomes vagos ou genéricos podem levar a interpretações erradas e erros. Ao aplicar o princípio DAMP, a equipe evita ambigüidades e garante que os testes sejam precisos e específicos, resultando em maior qualidade geral do código.
A comunicação é mais eficiente dentro das equipes: Nomes de testes descritivos e significativos facilitam a comunicação entre os membros da equipe e ajudam a compreender rapidamente o propósito e o escopo do teste. Isso economiza tempo e esforço, pois os desenvolvedores podem identificar facilmente a funcionalidade testada sem precisar analisar detalhadamente o código de teste.
Ao aplicar constantemente essas pequenas dicas, a qualidade dos testes e do próprio código melhorará.
Possíveis problemas ao não aderir ao DAMP
Poderíamos listar vários aqui, mas citarei apenas aqueles que pude presenciar em projetos em que trabalhei:
Legibilidade reduzida: Testes de unidade com nomes pouco claros, nomes que não tem relação com o objetivo de negócio ou ambíguos são mais difíceis de ler e entender, tornando o processo de revisão de código mais demorado e menos eficiente. Isso pode afetar a qualidade do software, pois os erros podem passar despercebidos.
Risco de duplicação: Aqui está um problema sério! Os testes podem ser duplicados e criados acidentalmente, aumentando a complexidade do código e desperdiçando recursos, principalmente o tempo dos desenvolvedores.
Falta de confiança nos testes: Testes mal nomeados podem minar a confiança na qualidade dos testes unitários e na qualidade geral do software, o que pode levar a menos ênfase na prática de testes e, consequentemente, a um maior risco de problemas não detectados.
Falta de compreensão: Testes mal nomeados podem tornar a depuração mais difícil e demorada, já trabalhei em equipes onde era necessário analisar detalhadamente o código de teste para identificar a causa de uma falha. E isso custou muito tempo!
Caso algum ou vários pontos aconteçam dentro do sistema em que você está trabalhando, fique atento a eles e faça planos de ação para resolver o problema junto com outros membros da equipe. Quanto mais o tempo passa, pior fica o cenário!
Conclusão
Como vimos em alguns exemplos, o DAMP vai muito além de nomear ou definir convenções, ele nos ajuda a trazer mais clareza, principalmente aos nossos testes. A abordagem deste princípio permite que os testes sejam mais precisos e eficazes, seguindo o conceito temos mais clareza, e os testes passam a agregar ainda mais valor ao software. Seguindo algumas dicas podemos continuar testando com valor! Espero que tenha sido uma leitura útil e agradável, muito obrigado pela leitura e até o próximo post! 😉
Livros que recomendo:
Effective Software Testing: A developer's guide - by Mauricio Aniche
Unit Testing Principles, Practices, and Patterns - by Vladimir Khorikov
Isolamento de Componentes: Ao criar mocks separados para serviços como o
ICustomerService
, você está isolando o componente que está sendo testado (neste caso,OrderManager
) de dependências externas. Isso garante que o teste seja focado apenas no comportamento do componente em questão.Uso de Métodos Auxiliares: A criação de métodos auxiliares para configurar mocks e objetos complexos, como a criação de uma ordem, simplifica cada teste individual, tornando-o mais fácil de ler e manter. Isso permite que cada método de teste seja conciso e focado em uma única condição ou cenário de teste.
Configuração Mínima de Mocks: A prática de não configurar todos os mocks a menos que seja estritamente necessário para o teste em questão ajuda a evitar sobrecarga e foca no que é relevante para cada teste específico. Isso reduz o ruído no código de teste e melhora a clareza.
Validação de Cenários Específicos: O teste
IsOrderShippable_OrderValueExceedsCreditLimit_ShouldReturnFalse
claramente define e valida um cenário específico — verificando se a ordem não é enviável quando o valor da ordem excede o limite de crédito do cliente. Esse foco ajuda a garantir que os testes sejam relevantes e úteis.Assertivas Claras: O uso de
Assert.IsFalse(isShippable)
diretamente relaciona a condição esperada do teste com o resultado obtido, tornando os resultados dos testes diretos e fáceis de entender.
Excelente conteúdo! Sou adepto dos testes de unidade e sempre que possível gosto de frisar sua importância no ciclo de desenvolvimento de um software, além estar sempre em busca de aperfeiçoar meu conhecimento no assunto.
Uma dúvida, e que talvez possa ser usada como ideia para um próximo artigo: Até onde vai ou qual a responsabilidade das classes de mocks/builders?
Pergunto isso porque normalmente tenho classes específicas (como a EmployeeBuilder do seu exemplo) para fazer a criação de objetos com dados fakes. A partir delas, disponibilizo métodos e extensões mais genéricos e deixo a responsabilidade para os métodos de testes de criar e customizar os objetos de acordo com suas necessidades. Entretanto, já vi casos onde eram criados métodos específicos dentro das próprias builders para atender a diversos cenários de testes, que em sua grande parte acabavam inflando essas classes builders e deixando o código menos legível, o que acabou me gerando dúvidas se de fato estava correto usar uma abordagem como essa.