Unidade Fada Fada Mágica: DSL em C #

Quantas vezes aconteceu que, quando você escreve um teste de unidade em funcionamento, analisa o código e ele é ... ruim? E você pensa assim: "Este é um teste, vamos deixar assim ...". Não,% username%, portanto não o deixe. Os testes são uma parte importante do sistema que fornece suporte ao código e é muito importante que essa parte também seja suportada. Infelizmente, não temos muitas maneiras de garantir isso (não escreveremos testes para testes), mas ainda existem alguns.


Em nossa escola de desenvolvedores do Dodo DevSchool, destacamos, entre outros, os seguintes critérios para um bom teste:

  • reprodutibilidade: executar testes no mesmo código e entrada sempre leva ao mesmo resultado;
  • foco: deve haver apenas uma razão para o teste cair;
  • compreensibilidade: bem, aqui está claro. :)

Como você gosta desse teste em termos desses critérios?

[Fact] public void AcceptOrder_Successful() { var ingredient1 = new Ingredient("Ingredient1"); var ingredient2 = new Ingredient("Ingredient2"); var ingredient3 = new Ingredient("Ingredient3"); var order = new Order(DateTime.Now); var product1 = new Product("Pizza1"); product1.AddIngredient(ingredient1); product1.AddIngredient(ingredient2); var orderLine1 = new OrderLine(product1, 1, 500); order.AddLine(orderLine1); var product2 = new Product("Pizza2"); product2.AddIngredient(ingredient1); product2.AddIngredient(ingredient3); var orderLine2 = new OrderLine(product2, 1, 650); order.AddLine(orderLine2); var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); var service = new PizzeriaService(orderRepositoryMock.Object, ingredientsRepositoryMock.Object); service.AcceptOrder(order); orderRepositoryMock.Verify(r => r.Add(order), Times.Once); ingredientsRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); } 

Para mim - muito ruim.

É incompreensível: por exemplo, eu não posso nem alocar blocos de organizar, agir e afirmar.

Não reproduzível: a propriedade DateTime.Now é usada. E, finalmente, é fora de foco, porque tem 2 razões para a queda: as chamadas para métodos de dois repositórios são verificadas.

Além disso, embora a nomeação dos testes esteja além do escopo deste artigo, ainda presto atenção ao nome: com esse conjunto de propriedades negativas, é difícil formulá-lo de tal maneira que, ao olhar para o nome do teste, uma pessoa externa entenda imediatamente por que esse teste geralmente está no projeto.
Se você não pode nomear concisa o teste, algo está errado com o teste.
Como o teste é incompreensível, vamos lhe contar o que está acontecendo nele:

  1. Os ingredientes são criados.
  2. A partir dos ingredientes, produtos (pizzas) são criados.
  3. Um pedido é criado a partir dos produtos.
  4. Um serviço é criado para o qual os repositórios estão úmidos.
  5. O pedido é passado para o método AcceptOrder do serviço.
  6. Verifica-se que os métodos Add e ReserveIngredients dos respectivos repositórios foram chamados.

Então, como podemos melhorar esse teste? Você precisa tentar deixar no corpo do teste apenas o que é realmente importante. E para isso, pessoas inteligentes como Martin Fowler e Rebecca Parsons criaram o DSL (Domain Specific Language) . Aqui vou falar sobre os padrões DSL que usamos no Dodo para garantir que nossos testes de unidade sejam suaves e sedosos, e os desenvolvedores se sintam confiantes todos os dias.

O plano é o seguinte: primeiro tornaremos esse teste compreensível, depois trabalharemos na reprodutibilidade e terminaremos focando-o. Nós dirigimos ...

Descarte de ingredientes (objetos de domínio predefinidos)


Vamos começar com o bloco de criação de pedidos. A ordenação é uma das entidades do domínio central. Seria legal se pudéssemos descrever a ordem de tal maneira que mesmo as pessoas que não sabem escrever código, mas entendem a lógica do domínio, possam entender que tipo de ordem estamos criando. Para fazer isso, antes de tudo, precisamos abandonar o uso do resumo "Ingrediente1" e "Pizza1", substituindo-os por ingredientes reais, pizzas e outros objetos de domínio.

O primeiro candidato à otimização são os ingredientes. Tudo é simples com eles: eles não precisam de nenhuma personalização, apenas uma chamada para o construtor. Basta levá-los para um contêiner separado e nomeá-los para que fique claro para os especialistas em domínio:

 public static class Ingredients { public static readonly Ingredient Dough = new Ingredient("Dough"); public static readonly Ingredient Pepperoni = new Ingredient("Pepperoni"); public static readonly Ingredient Mozzarella = new Ingredient("Mozzarella"); } 

Em vez do completamente insano ingrediente1, ingrediente2 e ingrediente3, temos massa, calabresa e mussarela.
Use objetos de domínio predefinidos para entidades de domínio comumente usadas.

Construtor de produtos


A próxima entidade do domínio são produtos. Tudo é um pouco mais complicado com eles: cada produto consiste em vários ingredientes e teremos que adicioná-los ao produto antes do uso.

Aqui, o bom e velho padrão do Builder é útil. Aqui está minha versão de compilação do produto:

 public class ProductBuilder { private Product _product; public ProductBuilder(string name) { _product = new Product(name); } public ProductBuilder Containing(Ingredient ingredient) { _product.AddIngredient(ingredient); return this; } public Product Please() { return _product; } } 

Consiste em um construtor parametrizado, um método Containing personalizado e um método Please terminal. Se você não gosta de ser gentil com o código, pode substituir Please por Now . O construtor oculta construtores complexos e chamadas de método que configuram o objeto. O código se torna mais limpo e mais compreensível. De uma maneira boa, o construtor deve simplificar a criação do objeto para que o código fique claro para o especialista em domínio. Vale especialmente a pena usar um construtor para objetos que requerem configuração antes de iniciar o trabalho.

O construtor do produto permitirá que você crie designs como:

 var pepperoni = new ProductBuilder("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); 

As construções ajudam a criar objetos que precisam de personalização. Considere criar um construtor, mesmo se a configuração consistir em uma linha.

ObjectMother


Apesar do fato de a criação do produto ter se tornado muito mais decente, o designer do new ProductBuilder ainda parece muito feio. Corrija-o com o padrão ObjectMother (Pai).

O padrão é simples como 5 copeques: criamos uma classe estática e coletamos todos os construtores nela.

 public static class Create { public static ProductBuilder Product(string name) => new ProductBuilder(name); } 

Agora você pode escrever assim:

 var pepperoni = Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); 

O ObjectMother foi inventado para criação declarativa de objetos. Além disso, ajuda a introduzir novos desenvolvedores no domínio, como ao escrever a palavra Create IDE, você informará o que você pode criar neste domínio.

Em nosso código, ObjectMother às vezes é chamado de Not Create , mas Given . Eu gosto das duas opções. Se você tiver outras idéias, compartilhe nos comentários.
Para criar objetos declarativamente, use ObjectMother. O código se tornará mais limpo e será mais fácil para os novos desenvolvedores se aprofundarem no domínio.

Remoção do produto


Tornou-se muito melhor, mas os produtos ainda têm espaço para crescer. Temos um número limitado de produtos e, como ingredientes, eles podem ser coletados em uma classe separada e não inicializados para cada teste:

 public static class Pizza { public static Product Pepperoni => Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); public static Product Margarita => Create.Product("Margarita") .Containing(Ingredients.Dough) .Containing(Ingredients.Mozzarella) .Please(); } 

Aqui, chamei o contêiner de não Products , mas Pizza Este nome ajuda a ler o teste. Por exemplo, ajuda a remover perguntas como "O Pepperoni é uma pizza ou uma linguiça?".
Tente usar objetos de domínio real, não substitutos como Product1.

O construtor para o pedido (exemplo na parte de trás)


Agora aplicamos os padrões descritos para criar um construtor de pedidos, mas agora não vamos do construtor, mas do que gostaríamos de receber. É assim que eu quero criar um pedido:

 var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); 

Como podemos conseguir isso? Obviamente, precisamos de construtores para o pedido e a linha do pedido. Com o construtor para fazer o pedido, tudo fica claro. Aqui está:

 public class OrderBuilder { private DateTime _date; private readonly List<OrderLine> _lines = new List<OrderLine>(); public OrderBuilder Dated(DateTime date) { _date = date; return this; } public OrderBuilder With(OrderLine orderLine) { _lines.Add(orderLine); return this; } public Order Please() { var order = new Order(_date); foreach (var line in _lines) { order.AddLine(line); } return order; } } 

Porém, com o OrderLine situação é mais interessante: primeiro, o método Please do terminal não é chamado aqui e, em segundo lugar, o acesso ao construtor é fornecido não pelo Create estático e não pelo construtor do próprio construtor. Resolveremos o primeiro problema usando o implicit operator e nosso construtor terá a seguinte aparência:

 public class OrderLineBuilder { private Product _product; private decimal _count; private decimal _price; public OrderLineBuilder Of(decimal count, Product product) { _product = product; _count = count; return this; } public OrderLineBuilder For(decimal price) { _price = price; return this; } public static implicit operator OrderLine(OrderLineBuilder b) { return new OrderLine(b._product, b._count, b._price); } } 

O segundo método nos ajudará a entender o método Extension para a classe Product :

 public static class ProductExtensions { public static OrderLineBuilder CountOf(this Product product, decimal count) { return Create.OrderLine.Of(count, product) } } 

Em geral, os métodos de extensão são grandes amigos do DSL. Eles podem fazer uma descrição declarativa e compreensível a partir de uma lógica completamente infernal.
Use métodos de extensão. Apenas use-os. :)
Depois de executar todas essas ações, obtivemos o seguinte código de teste:

 [Fact] public void AcceptOrder_Successful() { var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); var service = new PizzeriaService(orderRepositoryMock.Object, ingredientsRepositoryMock.Object); service.AcceptOrder(order); orderRepositoryMock.Verify(r => r.Add(order), Times.Once); ingredientsRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); } 

Aqui adotamos a abordagem que chamamos de "fada das fadas". É quando você escreve o código ocioso pela primeira vez como gostaria de vê-lo e tenta agrupar o que escreveu no DSL. Isso é muito útil para agir - às vezes você mesmo não consegue imaginar do que o C # é capaz.
Imagine que uma fada mágica chegou e permitiu que você escrevesse o código como quisesse e tente agrupar tudo o que está escrito em DSL.

Criando um serviço (padrão testável)


Com o pedido agora tudo é mais ou menos ruim. Chegou a hora de lidar com os mokas dos repositórios. Vale dizer aqui que o teste em si, que estamos considerando, é um teste de comportamento. Os testes comportamentais estão fortemente associados à implementação de métodos e, se é possível não escrever esses testes, é melhor não. No entanto, às vezes eles são úteis e, às vezes, você não pode ficar sem eles. A técnica a seguir ajuda a escrever exatamente testes de comportamento e, se você perceber de repente que deseja usá-lo, primeiro pense se é possível reescrever os testes de forma que eles verifiquem o estado, não o comportamento.

Então, quero ter certeza de que, no meu método de teste, não haja um único mok. Para isso, criarei um wrapper para PizzeriaService no qual encapsulo toda a lógica que verifica as chamadas de método:

 public class PizzeriaServiceTestable : PizzeriaService { private readonly Mock<IOrderRepository> _orderRepositoryMock; private readonly Mock<IIngredientRepository> _ingredientRepositoryMock; public PizzeriaServiceTestable(Mock<IOrderRepository> orderRepositoryMock, Mock<IIngredientRepository> ingredientRepositoryMock) : base(orderRepositoryMock.Object, ingredientRepositoryMock.Object) { _orderRepositoryMock = orderRepositoryMock; _ingredientRepositoryMock = ingredientRepositoryMock; } public void VerifyAddWasCalledWith(Order order) { _orderRepositoryMock.Verify(r => r.Add(order), Times.Once); } public void VerifyReserveIngredientsWasCalledWith(Order order) { _ingredientRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); } } 

Essa classe nos permitirá verificar as chamadas de método, mas ainda precisamos criá-la de alguma forma. Para fazer isso, usaremos o construtor que já conhecemos:

 public class PizzeriaServiceBuilder { public PizzeriaServiceTestable Please() { var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); return new PizzeriaServiceTestable(orderRepositoryMock, ingredientsRepositoryMock); } } 

No momento, nosso método de teste fica assim:

 [Fact] public void AcceptOrder_Successful() { var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyAddWasCalledWith(order); service.VerifyReserveIngredientsWasCalledWith(order); } 

Testar chamadas de método não é o único motivo pelo qual a classe Testable pode ser usada. Aqui, por exemplo, aqui nosso Dima Pavlov o usa para refatoração complexa de um código legado.
Testável é capaz de salvar a situação nos casos mais difíceis. Para testes de comportamento, ajuda a agrupar verificações de chamadas feias em métodos bonitos.
Nesse momento importante, terminamos de entender a compreensibilidade do teste. Resta torná-lo reproduzível e focado.

Reprodutibilidade (extensão literal)


O padrão de extensão literal não está diretamente relacionado à reprodutibilidade, mas nos ajudará com isso. Nosso problema no momento é que usamos a data DateTime.Now como a data do pedido. Se, de repente, a partir de alguma data, a lógica da aceitação de pedidos for alterada, em nossa lógica de negócios, teremos que, pelo menos por algum tempo, suportar 2 lógicas de aceitação de pedidos, separando-as verificando como if (order.Date > edgeDate) . Nesse caso, nosso teste pode cair quando a data do sistema passar pelo limite. Sim, corrigiremos isso rapidamente e até faremos dois de um teste: um verificará a lógica antes da data limite e o outro depois. No entanto, é melhor evitar essas situações e tornar imediatamente todos os dados de entrada constantes.

"E onde está o DSL?" - você pergunta. O fato é que é conveniente inserir datas nos testes por meio dos métodos de extensão, por exemplo, 3.May(2019) . Essa forma de gravação será compreensível não apenas para os desenvolvedores, mas também para os negócios. Para fazer isso, basta criar uma classe estática

 public static class DateConstructionExtensions { public static DateTime May(this int day, int year) => new DateTime(year, 5, day); } 

Naturalmente, as datas não são as únicas coisas para se usar esse padrão. Por exemplo, se introduzirmos a quantidade de ingredientes na composição dos produtos, poderíamos escrever algo como 42.Grams("flour") .
Objetos e datas quantitativos são criados convenientemente através dos métodos de extensão conhecidos.

Focus


Por que é importante manter os testes focados? O fato é que testes focados são mais fáceis de manter, mas ainda precisam ser suportados. Por exemplo, eles precisam ser alterados ao alterar o código e excluídos ao visualizar recursos antigos. Se os testes não estiverem focados, ao alterar a lógica, será necessário entender os grandes testes e cortar partes da funcionalidade testada. Se os testes estiverem focados e seus nomes estiverem claros, você precisará remover os testes obsoletos e escrever novos. Se os testes tiverem uma boa DSL, isso não será um problema.

Então, depois que terminamos de escrever o DSL, tivemos a oportunidade de fazer esse teste focado dividindo-o em dois testes:

 [Fact] public void WhenAcceptOrder_AddIsCalled() { var order = Create.Order .Dated(3.May(2019)) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyAddWasCalledWith(order); } [Fact] public void WhenAcceptOrder_ReserveIngredientsIsCalled() { var order = Create.Order .Dated(3.May(2019)) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyReserveIngredientsWasCalledWith(order); } 

Ambos os testes foram curtos, claros, reprodutíveis e focados.

Observe que agora os nomes dos testes refletem o propósito para o qual foram escritos e agora qualquer desenvolvedor que entrou no meu projeto entenderá por que cada um dos testes foi gravado e o que acontece nesse teste.
O foco dos testes os torna suportados. Um bom teste deve ser focado.
E agora, já posso ouvir você gritando comigo: “Yura, que porra é você? Escrevemos um milhão de códigos apenas para fazer alguns testes bonitos? Sim exatamente. Embora tenhamos apenas alguns testes, faz sentido investir em DSL e torná-los compreensíveis. Depois de escrever DSL, você recebe um monte de brindes:

  • Torna-se fácil escrever novos testes. Não é necessário configurar-se por 2 horas para testes de unidade, basta tirar e escrever.
  • Os testes se tornam compreensíveis e legíveis. Qualquer desenvolvedor que observe o teste entende por que ele foi escrito e o que ele verifica.
  • O limite para ingressar em testes (e talvez no domínio) para novos desenvolvedores é reduzido. Por exemplo, através do ObjectMother, você pode descobrir facilmente quais objetos podem ser criados no domínio.
  • E, finalmente, é bom trabalhar com testes e, como resultado, o código se torna mais suportado.

Exemplos de código-fonte e testes estão disponíveis aqui .

Source: https://habr.com/ru/post/pt451598/


All Articles