paint-brush
Представляем Builder: ваш помощник в разработке через тестирование (TDD)к@easytdd
482 чтения
482 чтения

Представляем Builder: ваш помощник в разработке через тестирование (TDD)

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

Слишком долго; Читать

Builder: Ваш друг в разработке через тестирование (TDD) Builder позволяет разработчикам создавать объекты тестовых данных шаг за шагом, используя гибкий интерфейс, который улучшает читаемость и снижает многословие. Класс builder — отличное место для определения и сбора всех общих и пограничных объектов. Во многих случаях только часть свойств имеет отношение к определенным тестам.
featured image - Представляем Builder: ваш помощник в разработке через тестирование (TDD)
Kazys Račkauskas HackerNoon profile picture

Шаблон строителя

Сегодня я расскажу о шаблоне Builder в разработке через тестирование. Если вы уже работаете с тестами, вы, вероятно, заметили, как много времени может занять создание всех входных данных. Часто один и тот же набор данных или данные с небольшими различиями используются во многих тестах в тестовом наборе системы. Builder здесь помогает. Он служит двум целям:


  • Конструктор позволяет разработчикам шаг за шагом создавать объекты тестовых данных, используя удобный интерфейс, который повышает читабельность и сокращает многословие.


  • Класс builder — это отличное место для определения и сбора всех общих и пограничных объектов. Например, для Passenger это может быть Man, Woman, Boy, Girl, Infant и т. д. Для Route это может быть One-way, Round trip, Direct, Indirect и т. д.


В качестве примера я возьму класс Invoice , очень упрощенная версия которого может выглядеть примерно так:

 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); }

Чтобы создать объект Invoice , мне нужно предоставить много значений конструкторам Invoice и InvoiceLine . Во многих случаях только часть свойств имеет отношение к определенным тестам. Здесь на помощь приходят конструкторы.


Конструктор для InvoiceLine может выглядеть примерно так:

 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; } }


Конструктор Invoice может выглядеть примерно так:

 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; } }


В случае, если тесту необходим объект Invoice только для свойства общей цены, то Invoice можно создать следующим образом:

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


Так как общая цена рассчитывается путем суммирования строк счета-фактуры, а количество единиц по умолчанию для строки счета-фактуры равно 1, то достаточно установить цену за единицу для строки счета-фактуры. Если аналогичная функциональность необходима в нескольких тестах, мы могли бы пойти дальше и добавить следующий метод в InvoiceBuilder :

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

Коллекция предопределенных настроек

Как упоминалось выше, класс builder — это отличное место для сбора всех общих и пограничных случаев для класса. Здесь я приведу несколько из этих возможных случаев:


  • Счет-фактура с товарами, имеющими обычный НДС
  • Счет-фактура с товарами, имеющими сниженный НДС
  • Счет-фактура с товарами, имеющими смешанный НДС
  • Счет-фактура в страну ЕС
  • Счет-фактура для страны Северной Америки
  • Счет-фактура в Китай


С моей точки зрения, это отличное место для сбора знаний о различных случаях, которые обрабатывает наша система. Это служит полезной базой знаний для новых разработчиков, чтобы понять, чем должна управлять система. Если я новичок в этой области, я могу даже не думать о возможных крайних случаях. Вот пример кода из некоторых случаев, упомянутых выше:

 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) ); }

Теперь мы можем создать смесь из вышеперечисленного. Например, если тестовый случай требует счет для клиента из ЕС со строками счета, которые содержат смешанный НДС, я могу сделать следующее:

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

Это всего лишь простой пример, но я надеюсь, вы поняли концепцию.


Конструктор полезен, когда у нас есть большой и сложный объект, но для теста важны только несколько полей.


Другой полезный случай — когда я хочу протестировать несколько сценариев на основе определенных значений. Все свойства, кроме одного, остаются прежними, и я меняю только одно. Это облегчает выделение разницы, которая заставляет службу или объект вести себя по-разному.

Способы создания класса Builder

Код своими руками

Во-первых, вы можете создать класс-строитель самостоятельно. Это не требует никаких начальных вложений времени или денег, и у вас есть большая свобода в том, как вы его создаете. Копирование, вставка и замена могут быть полезны, но все равно это занимает довольно много времени, особенно для больших классов.

Создайте свой собственный генератор кода

Когда я начал генерацию кода, я начал с настройки одного теста для него. Этот тест на самом деле ничего не тестировал; он просто принимал тип, извлекал все свойства с помощью рефлексии, создавал класс-конструктор из жестко закодированного шаблона и записывал его в окно вывода тестового раннера. Все, что мне нужно было сделать, это создать файл класса и скопировать/вставить содержимое из окна вывода тестового раннера.

СтроительГенератор

Все о BuilderGenerator можно найти здесь . Он объясняет .NET Incremental Source Generator. Это означает, что код строителя регенерируется в реальном времени при изменении целевого класса. Таким образом, нет никаких хлопот или ручной работы по сравнению с методами выше. Просто создайте класс строителя, добавьте атрибут BuilderFor с типом целевого класса, и все методы With будут сгенерированы автоматически и готовы к использованию.

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

Я не работал с ним много, но, похоже, у него большая база пользователей с 82,7 тыс. загрузок на момент написания статьи. Я заметил несколько проблем, которые заставили меня выбрать другие варианты:


  • Решение не может быть построено, если класс строителя находится в другом проекте, чем целевой класс. Он может быть в другом проекте, но пространство имен должно оставаться прежним. В противном случае вы увидите следующие ошибки::

  • Он не поддерживает параметры конструктора и завершается с ошибками, если целевой класс не имеет конструктора без параметров.:

Давайте рассмотрим, какие еще варианты у нас есть.

Генератор фальшивок.

Это очень популярная библиотека с более чем 82,2 млн загрузок (и 186,1 тыс. для текущей версии) на момент написания статьи. Как утверждает автор библиотеки, это фейковый генератор данных, способный производить многочисленные объекты на основе предопределенных правил. Это не совсем то, что шаблон строителя в TDD, но его можно адаптировать.


Существует несколько способов использования Bogus.Faker, но здесь я сосредоточусь на том, как имитировать шаблон строителя.


Самый простой способ создания объекта — с помощью Bogus.Faker:

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


Он создает экземпляр InvoiceLine2 со значениями по умолчанию, что означает nulls и нули. Чтобы задать некоторые значения, я буду использовать следующую настройку:

 [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); }


Код выше создает объект строки счета со случайными значениями. Пример может выглядеть так:

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


Это полезно, но каждый тест требует собственной настройки. Вместо этого мы можем создать класс-строитель:

 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; } }


Использование будет выглядеть примерно так:

 [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); }


И вывод:

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

С моей точки зрения, он немного более многословен, чем обычный шаблон Builder. Кроме того, я не фанат использования случайных значений. Это не большая проблема, но проблемы возникают, когда свойства класса инициализируются с помощью конструктора, а у него нет сеттеров. Тогда он не работает как строитель, и каждая настройка становится статической.

 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) ) );

NBuilder

Это также очень популярная библиотека с более чем 13,2 миллионами загрузок (и 7,2 миллиона для текущей версии). Хотя она не была активно разработана в последнее время, последняя версия была выпущена в 2019 году. По сути, она очень похожа на Bogus.Faker. Должно быть даже возможно повторно использовать Bogus для предоставления случайных значений, реализуя определенный IPropertyNamer.


Давайте попробуем использовать его без установки каких-либо свойств:

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


Он выдает следующий вывод::

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


Цель этого поста — показать, как создать повторно используемый класс строителя. Давайте начнем:

 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); } }


А вот как это используется:

 [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); }


И вывод:

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


Подобно Bogus.Faker, вы не можете переопределить значения, если свойство класса установлено с помощью конструктора и не имеет сеттера . Если вы попытаетесь использовать метод With для такого свойства, он завершится ошибкой со следующим исключением:

 System.ArgumentException : Property set method not found.

EasyTdd.Генераторы.Строитель

EasyTdd.Generators.Builder — это пакет Nuget , работающий в тандеме с EasyTdd — расширением Visual Studio . Этот пакет использует инкрементальный генератор исходного кода .NET для создания построителей из шаблонов, используемых расширением EasyTdd. Генератор построителя обрабатывает сеттеры свойств, параметры конструктора и их комбинацию. Он также поддерживает общие параметры.


Это мой любимый способ создания конструктора. Вот преимущества по сравнению с другими вариантами:

  • Класс конструктора создается всего за несколько щелчков мыши.


  • Для генерации класса строителя используется инкрементальный генератор исходного кода. Это приводит к автоматическому обновлению класса строителя при каждом изменении исходного класса.


  • Поддержка шаблонов. Вы можете легко адаптировать шаблон под мои нужды.


  • Полная поддержка классов, которые можно инициализировать с использованием как параметров конструктора, так и свойств сеттера или их комбинации.


  • Поддержка общих классов.


После установки EasyTdd в Visual Studio откройте меню быстрых действий для целевого класса и выберите «Создать инкрементный построитель»:

Это действие создает частичный класс конструктора с набором атрибутов BuilderFor:

 [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 ); } }

Сам код строителя генерируется в фоновом режиме, и этот частичный класс предназначен для общих/пограничных случаев. Не стесняйтесь устанавливать значения по умолчанию вместо default .


Подробнее о настройке и принципе работы можно узнать здесь .


Хорошая часть в том, что если мне нужны случайные значения, я могу использовать Bogus здесь:

 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) ); }


Использование:

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


И вывод:

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

Чистый EasyTdd

EasyTdd также предлагает полную генерацию кода конструктора без зависимости от пакета Nuget EasyTdd.Generators. Это полезно, если вы не хотите или вам не разрешено зависеть от сторонних библиотек. Расширение генерирует код, и все это находится в вашем проекте, без зависимостей, без прикрепленных строк. Не стесняйтесь изменять его, все ваше. Этот подход предлагает все преимущества случая EasyTdd.Generators, за исключением автоматической регенерации при изменении целевого класса.


В этом случае строитель необходимо перегенерировать вручную (также несколькими щелчками). Генерируются два файла, чтобы избежать потери настроек при перегенерации. Один файл содержит объявление класса строителя со всеми необходимыми методами, другой предназначен только для настроек и дополнительных методов, которые не предназначены для перегенерации. Класс можно сгенерировать аналогично вышеописанному, открыв меню быстрых действий и нажав «Сгенерировать строитель»:

Когда конструктор уже сгенерирован, инструмент предлагает открыть класс конструктора или сгенерировать его заново:

Краткое содержание

В этой записи блога я представил шаблон строителя и его использование в разработке через тестирование. Я также показал несколько способов его реализации, начиная с ручной реализации, с использованием сторонних библиотек, таких как Bogus.Faker и NBuilder, инкрементальных генераторов кода, таких как BuilderGenerator и EasyTdd.Generators.Builder, и, наконец, сгенерировав весь код расширением Visual Studio EasyTdd. Каждый метод имеет свои сильные и слабые стороны и хорошо работает в простых случаях.


Однако при работе с неизменяемыми классами EasyTdd выделяется тем, что одинаково обрабатывает изменения свойств, независимо от того, инициализируется ли значение свойства сеттером или через параметр конструктора. EasyTdd поддерживает шаблоны и позволяет настраивать вывод в соответствии с вашими предпочтениями. EasyTdd также превосходит другие методы реализации конструктора благодаря скорости реализации. Он предоставляет инструменты в Visual Studio для автоматической генерации файлов всего за несколько щелчков, экономя время и усилия.


Фото Маркуса Списке на Unsplash