paint-brush
Apresentando o Builder: seu amigo no desenvolvimento orientado a testes (TDD)por@easytdd
482 leituras
482 leituras

Apresentando o Builder: seu amigo no desenvolvimento orientado a testes (TDD)

por Kazys Račkauskas21m2024/08/23
Read on Terminal Reader

Muito longo; Para ler

Builder: Seu companheiro no desenvolvimento orientado a testes (TDD) O builder permite que os desenvolvedores construam objetos de dados de teste passo a passo, usando uma interface fluente que melhora a legibilidade e reduz a verbosidade. A classe builder é um excelente lugar para definir e coletar todos os objetos comuns e de casos extremos. Em muitos casos, apenas uma parte das propriedades é relevante para testes específicos.
featured image - Apresentando o Builder: seu amigo no desenvolvimento orientado a testes (TDD)
Kazys Račkauskas HackerNoon profile picture

Padrão do Construtor

Hoje, falarei sobre o padrão builder no desenvolvimento orientado a testes. Se você já trabalha com testes, provavelmente já percebeu o quanto pode ser demorado criar todos os dados de entrada. Frequentemente, o mesmo conjunto de dados, ou dados com pequenas diferenças, é usado em muitos testes no conjunto de testes de um sistema. O Builder ajuda aqui. Ele serve a dois propósitos:


  • O construtor permite que os desenvolvedores construam objetos de dados de teste passo a passo, usando uma interface fluente que melhora a legibilidade e reduz a verbosidade.


  • A classe builder é um excelente lugar para definir e coletar todos os objetos comuns e de caso extremo. Por exemplo, para um Passenger, pode ser um Man, Woman, Boy, Girl, Infant, etc. Para um Itinerary, pode ser One-way, Round trip, Direct, Indirect, etc.


Para fins de exemplo, usarei a classe Invoice , uma versão bem simplificada poderia ser algo como isto:

 public class Invoice { public Invoice( string invoiceNo, string customer, string countryCode, DateTime invoiceDate, IReadOnlyList<InvoiceLine> lines) { InvoiceNo = invoiceNo; InvoiceDate = invoiceDate; Customer = customer; CountryCode = countryCode; Lines = lines; } public string InvoiceNo { get; } public string Customer { get; } public string CountryCode { get; } public DateTime InvoiceDate { get; } public decimal TotalAmount => Lines.Sum(x => x.TotalPrice); public IReadOnlyList<InvoiceLine> Lines { get; } } public class InvoiceLine { public InvoiceLine( string itemCode, decimal unitCount, decimal unitPrice, decimal vat) { ItemCode = itemCode; UnitCount = unitCount; UnitPrice = unitPrice; Vat= vat; } public string ItemCode { get; } public decimal UnitCount { get; } public decimal UnitPrice { get; } public decimal Vat { get; } public decimal TotalPrice => UnitCount * UnitPrice * (1 + Vat / 100); }

Para criar um objeto Invoice , tenho que fornecer muitos valores aos construtores de Invoice e InvoiceLine . Em muitos casos, apenas uma parte das propriedades é relevante para testes específicos. Aqui, os construtores entram para ajudar.


O Builder para InvoiceLine poderia ser parecido com isto:

 public partial class InvoiceLineBuilder { private string _itemCode; private decimal _unitCount; private decimal _unitPrice; private decimal _vat; public static implicit operator InvoiceLine(InvoiceLineBuilder builder) => builder.Build(); public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder( "001", 1, 100, 21 ); } public InvoiceLineBuilder( string itemCode, decimal unitCount, decimal unitPrice, decimal vat) { _itemCode = itemCode; _unitCount = unitCount; _unitPrice = unitPrice; _vat = vat; } public InvoiceLine Build() { return new InvoiceLine( _itemCode, _unitCount, _unitPrice, _vat ); } public InvoiceLineBuilder WithItemCode(string value) { _itemCode = value; return this; } public InvoiceLineBuilder WithUnitCount(decimal value) { _unitCount = value; return this; } public InvoiceLineBuilder WithUnitPrice(decimal value) { _unitPrice = value; return this; } public InvoiceLineBuilder WithVat(decimal vat) { _vat = value; return this; } }


O Builder for Invoice poderia ser parecido com isto:

 public partial class InvoiceBuilder { private string _invoiceNo; private string _customer; private string _countryCode; private DateTime _invoiceDate; private IReadOnlyList<InvoiceLine> _lines; public static implicit operator Invoice(InvoiceBuilder builder) => builder.Build(); public static InvoiceBuilder Default() { return new InvoiceBuilder( "S001", "AB VeryImportantCustomer", "SV", DateTime.Parse("2024-01-01"), new [] { InvoiceLineBuilder .Default() .Build() } ); } public InvoiceBuilder( string invoiceNo, string customer, string countryCode, DateTime invoiceDate, IReadOnlyList<InvoiceLine> lines) { _invoiceNo = invoiceNo; _customer = customer; _countryCode = countryCode; _invoiceDate = invoiceDate; _lines = lines; } public Invoice Build() { return new Invoice( _invoiceNo, _invoiceDate, _lines ); } public InvoiceBuilder WithInvoiceNo(string value) { _invoiceNo = value; return this; } public InvoiceBuilder WithCustomer(string value) { _customer = value; return this; } public InvoiceBuilder WithCountryCode(string value) { _countryCode = value; return this; } public InvoiceBuilder WithInvoiceDate(DateTime value) { _invoiceDate = value; return this; } public InvoiceBuilder WithLines(IReadOnlyList<InvoiceLine> value) { _lines = value; return this; } public InvoiceBuilder WithLines(params InvoiceLine[] value) { _lines = value; return this; } }


Caso um teste precise de um objeto Invoice apenas para sua propriedade de preço total, então Invoice pode ser criado assim:

 var invoice = InvoiceBuilder .Default() .WithLines( InvoiceLineBuilder .Default .WithUnitPrice(158) );


Como o preço total é calculado pela soma das linhas da fatura, e a contagem de unidades padrão para a linha da fatura é 1, então é suficiente definir o preço unitário para a linha da fatura. Se uma funcionalidade semelhante for necessária em vários testes, podemos ir além e adicionar o seguinte método ao InvoiceBuilder :

 public static InvoiceBuilder DefaultWithTotalPrice(decimal totalPrice) { return new InvoiceBuilder( "S001", DateTime.Parse("2023-01-01"), new[] { InvoiceLineBuilder .Default() .WithUnitPrice(totalPrice) .Build() } ); }

Coleção de configurações predefinidas

Como mencionado acima, a classe builder é um ótimo lugar para coletar todos os casos comuns e extremos para a classe. Aqui, fornecerei alguns desses casos possíveis:


  • Uma fatura com itens com IVA regular
  • Uma fatura com itens com IVA reduzido
  • Uma fatura com itens com IVA misto
  • Uma fatura para um país da UE
  • Uma fatura para um país da América do Norte
  • Uma fatura para a China


Do meu ponto de vista, é um ótimo lugar para reunir conhecimento sobre os diferentes casos que nosso sistema lida. Ele serve como uma base de conhecimento útil para novos desenvolvedores entenderem o que o sistema precisa gerenciar. Se eu for novo em um campo, posso nem pensar em possíveis casos extremos. Aqui está um exemplo de código de alguns dos casos mencionados acima:

 public static InvoiceBuilder ForEUCountry() { return Default() .WithCountryCode("SV"); } public static InvoiceBuilder ForUSA() { return Default() .WithCountryCode("USA"); } public static InvoiceBuilder ForChina() { return Default() .WithCountryCode("CN"); } public InvoiceBuilder WithRegularVat() { return this .WithLines( InvoiceLineBuilder .Default .WithItemCode("S001") .WithVat(21), InvoiceLineBuilder .Default .WithItemCode("S002") .WithVat(21) ); } public InvoiceBuilder WithReducedVat() { return this .WithLines( InvoiceLineBuilder .Default .WithItemCode("S001") .WithVat(9), InvoiceLineBuilder .Default .WithItemCode("S002") .WithVat(9) ); } public InvoiceBuilder WithMixedVat() { return this .WithLines( InvoiceLineBuilder .Default .WithItemCode("S001") .WithVat(21), InvoiceLineBuilder .Default .WithItemCode("S002") .WithVat(9) ); }

Agora podemos criar uma mistura do acima. Por exemplo, se um caso de teste precisa de uma fatura para um cliente da UE com linhas de fatura que têm IVA misto, posso fazer o seguinte:

 [Test] public void SomeTest() { //arrange var invoice = InvoiceBuilder .ForEU() .WithMixedVat(); //act ... //assert ... }

Este é apenas um exemplo simples, mas espero que você entenda o conceito.


Um construtor é útil quando temos um objeto grande e complexo, mas apenas alguns campos são relevantes para o teste.


Outro caso útil é quando eu quero testar múltiplos cenários com base em valores específicos. Todas as propriedades, exceto uma, permanecem as mesmas, e eu altero apenas uma. Isso torna mais fácil destacar a diferença, o que faz com que o serviço ou objeto se comporte de forma diferente.

Maneiras de criar a classe Builder

Código com suas próprias mãos

Primeiro, você pode criar uma classe builder por conta própria. Isso não requer nenhum investimento inicial de tempo ou dinheiro, e você tem muita liberdade em como criá-la. Copiar, colar e substituir pode ser útil, mas ainda leva um bom tempo, especialmente para classes maiores.

Crie seu próprio gerador de código

Quando comecei com a geração de código, comecei configurando um único teste para ele. Este teste não testou nada de fato; ele apenas aceitou um tipo, recuperou todas as propriedades usando reflexão, criou uma classe de construtor a partir de um modelo codificado e a escreveu na janela de saída do executor de teste. Tudo o que eu tinha que fazer era criar um arquivo de classe e copiar/colar o conteúdo da janela de saída do executor de teste.

ConstrutorGerador

Tudo sobre BuilderGenerator pode ser encontrado aqui . Ele explica o .NET Incremental Source Generator. Isso significa que o código do builder é regenerado ao vivo quando a classe de destino muda. Então, não há aborrecimentos ou trabalho manual em comparação aos métodos acima. Basta criar uma classe builder, adicionar o atributo BuilderFor com o tipo de classe de destino, e todos os métodos With são gerados automaticamente e prontos para uso.

 [BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder() .WithItemCode("S001") .WithUnitCount(1); } }

Não trabalhei muito com ele, mas parece ter uma ampla base de usuários com 82,7 mil downloads no momento em que escrevo. Notei alguns problemas que me fizeram escolher outras opções:


  • A solução falha ao ser construída se a classe builder estiver em um projeto diferente da classe target. Ela pode estar em outro projeto, mas o namespace deve permanecer o mesmo. Caso contrário, você verá os seguintes erros::

  • Ele não suporta parâmetros de construtor e falha com erros se a classe de destino não tiver um construtor sem parâmetros.:

Vamos explorar outras opções que temos.

Gerador Bogus.Faker

Esta é uma biblioteca muito popular com mais de 82,2 milhões de downloads totais (e 186,1 mil para a versão atual) no momento da escrita. Como o autor da biblioteca afirma, é um gerador de dados falso capaz de produzir vários objetos com base em regras predefinidas. Não é exatamente o que o padrão builder é em TDD, mas pode ser adaptado.


Há várias maneiras de usar o Bogus.Faker, mas vou me concentrar em como imitar o padrão do construtor aqui.


A maneira mais simples de criar um objeto com Bogus.Faker é:

 [Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>(); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); }


Ele cria uma instância de InvoiceLine2 com valores padrões, o que significa nulos e zeros. Para definir alguns valores, usarei a seguinte configuração:

 [Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>() .RuleFor(x => x.ItemCode, f => f.Random.AlphaNumeric(5)) .RuleFor(x => x.UnitPrice, f => f.Random.Decimal(10, 1000)) .RuleFor(x => x.UnitCount, f => f.Random.Number(1, 5)) .RuleFor(x => x.Vat, f => f.PickRandom(21, 9, 0)); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }


O código acima cria um objeto de linha de fatura com valores aleatórios. Um exemplo pode ser parecido com este:

 { "ItemCode": "gwg7y", "UnitCount": 3.0, "UnitPrice": 597.035612417891230, "Vat": 0.0, "TotalPrice": 1791.106837253673690 }


É útil, mas cada teste requer sua própria configuração. Em vez disso, podemos criar uma classe builder:

 public class InvoiceLineBuilder: Faker<InvoiceLine2> { public static InvoiceLineBuilder Default() { var faker = new InvoiceLineBuilder(); faker .RuleFor(x => x.ItemCode, f => f.Random.AlphaNumeric(5)) .RuleFor(x => x.UnitPrice, f => f.Random.Decimal(10, 1000)) .RuleFor(x => x.UnitCount, f => f.Random.Number(1, 5)) .RuleFor(x => x.Vat, f => f.PickRandom(21, 9, 0)); return faker; } }


O uso seria algo como isto:

 [Test] public void BogusTest() { var faker = TestDoubles.Bogus.InvoiceLineBuilder .Default() .RuleFor(x => x.ItemCode, f => "S001") .RuleFor(x => x.UnitPrice, f => 100); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }


E a saída:

 { "ItemCode": "S001", "UnitCount": 2.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 218.00 }

Da minha perspectiva, é um pouco mais verboso do que o Builder Pattern regular. Além disso, não sou fã de usar valores aleatórios. Não é um grande problema, mas surgem problemas quando as propriedades de uma classe são inicializadas usando um construtor e ele não tem setters. Então, ele não funciona como um construtor, e cada configuração se torna estática.

 var faker = new InvoiceLineBuilder(); faker .CustomInstantiator(f => new InvoiceLine( f.Random.AlphaNumeric(5), f.Random.Decimal(10, 1000), f.Random.Number(1, 5), f.PickRandom(21, 9, 0) ) );

Construtor N

Esta também é uma biblioteca muito popular com mais de 13,2 milhões de downloads totais (e 7,2 milhões para a versão atual). Embora não tenha sido ativamente desenvolvida recentemente, a última versão foi lançada em 2019. Essencialmente, é muito semelhante ao Bogus.Faker. Deve até ser possível reutilizar o Bogus para fornecer valores aleatórios implementando um IPropertyNamer específico.


Vamos tentar usá-lo sem definir nenhuma propriedade:

 [Test] public void NBuilderTest() { var invoiceLine = Builder<InvoiceLine2> .CreateNew() .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }


Ele produz a seguinte saída:

 { "ItemCode": "ItemCode1", "UnitCount": 1.0, "UnitPrice": 1.0, "Vat": 1.0, "TotalPrice": 1.01 }


O objetivo deste post é mostrar como criar uma classe builder reutilizável. Vamos começar:

 public class InvoiceLineBuilder { public static ISingleObjectBuilder<InvoiceLine2> Default() { return Builder<InvoiceLine2> .CreateNew() .With(x => x.ItemCode, "S001") .With(x => x.UnitCount, 1) .With(x => x.UnitPrice, 100) .With(x => x.Vat, 21); } }


E aqui está o uso:

 [Test] public void NBuilderTest() { var invoiceLine = TestDoubles.NBuilder.InvoiceLineBuilder .Default() .With(x => x.ItemCode, "S002") .With(x => x.Vat, 9) .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }


E a saída:

 { "ItemCode": "S002", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }


Semelhante ao Bogus.Faker, você não pode substituir valores se uma propriedade de classe for definida usando um construtor e não tiver um setter . Se você tentar usar o método With para tal propriedade, ele falhará com a seguinte exceção:

 System.ArgumentException : Property set method not found.

EasyTdd.Geradores.Construtor

EasyTdd.Generators.Builder é um pacote Nuget e funciona em conjunto com o EasyTdd - a extensão do Visual Studio . Este pacote aproveita um gerador de fonte incremental .NET para criar construtores a partir de modelos usados pela extensão EasyTdd. O gerador de construtor manipula setters de propriedade, parâmetros de construtor e uma combinação de ambos. Ele também oferece suporte a parâmetros genéricos.


Esta é minha maneira preferida de criar um builder. Aqui estão os benefícios em comparação com as outras opções:

  • A classe builder é gerada com apenas alguns cliques.


  • Um gerador de fonte incremental é usado para a geração da classe builder. Isso faz com que a classe builder atualize automaticamente em cada alteração na classe source.


  • Suporte a modelos. Você pode adaptar facilmente o modelo às minhas necessidades.


  • Suporte contínuo para classes que podem ser inicializadas usando parâmetros do construtor, propriedades do definidor ou uma mistura de ambos.


  • Suporte a classes genéricas.


Quando o EasyTdd estiver instalado no Visual Studio, abra o menu de ação rápida na classe de destino e selecione "Gerar Construtor Incremental":

Esta ação cria uma classe construtora parcial com o atributo BuilderFor definido:

 [EasyTdd.Generators.BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder( () => default, // Set default itemCode value () => default, // Set default unitCount value () => default, // Set default unitPrice value () => default // Set default vat value ); } }

O código do construtor em si é gerado em segundo plano, e essa classe parcial é destinada a configurações de casos comuns/de ponta. Sinta-se à vontade para definir valores padrão em vez de default .


Mais informações sobre como configurá-lo e como ele funciona podem ser encontradas aqui .


A parte boa é que se eu precisar de valores aleatórios, posso usar Bogus aqui:

 public static InvoiceLineBuilder Random() { var f = new Faker(); return new InvoiceLineBuilder( () => f.Random.AlphaNumeric(5), () => f.Random.Decimal(10, 1000), () => f.Random.Number(1, 5), () => f.PickRandom(21, 9, 0) ); }


Uso:

 [Test] public void EasyTddBuilder() { var invoiceLine = TestDoubles.Builders.InvoiceLineBuilder .Random() .WithUnitPrice(100) .WithUnitCount(1) .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }


E a saída:

 { "ItemCode": "ana0i", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }

Puro EasyTdd

O EasyTdd também oferece geração de código de construtor completo sem a dependência do pacote EasyTdd.Generators Nuget. Isso é útil se você não quiser ou não tiver permissão para depender de bibliotecas de terceiros. A extensão gera o código e tudo fica no seu projeto, sem dependências, sem strings anexadas. Sinta-se à vontade para modificar tudo é seu. Essa abordagem oferece todos os benefícios do caso EasyTdd.Generators, exceto a regeneração automática em alterações de classe de destino.


Neste caso, o builder precisa ser regenerado manualmente (também com alguns cliques). Dois arquivos são gerados para evitar a perda das configurações na regeneração. Um arquivo contém a declaração da classe builder, com todos os métodos necessários, o outro é destinado apenas para configurações e métodos adicionais, que não são destinados à regeneração. A classe pode ser gerada de forma semelhante à acima, abrindo o menu de ação rápida e clicando em "Gerar Builder":

Quando o construtor já foi gerado, a ferramenta oferece a opção de abrir a classe do construtor ou regenerá-la:

Resumo

Nesta postagem do blog, apresentei o padrão builder e seu uso no desenvolvimento orientado a testes. Também mostrei várias maneiras de implementá-lo, começando pela implementação manual, usando bibliotecas de terceiros como Bogus.Faker e NBuilder, geradores de código incremental como BuilderGenerator e EasyTdd.Generators.Builder e, finalmente, tendo todo o código gerado pela extensão EasyTdd Visual Studio. Cada método tem seus pontos fortes e fracos e funciona bem em casos simples.


No entanto, ao lidar com classes imutáveis, o EasyTdd se destaca por lidar com alterações de propriedade igualmente, seja um valor de propriedade inicializado por um setter ou por meio de um parâmetro construtor. O EasyTdd suporta modelos e permite que você personalize a saída para corresponder às suas preferências. O EasyTdd também supera outros métodos de implementação de um construtor devido à sua velocidade de implementação. Ele fornece ferramentas no Visual Studio para gerar arquivos automaticamente com apenas alguns cliques, economizando tempo e esforço.


Foto de Markus Spiske no Unsplash