Pull to refresh
Dodo Engineering
О том, как разработчики строят IT в Dodo

Волшебная фея для юнит-тестов: DSL в C#

Reading time 11 min
Views 12K
Как часто бывало так, что написав рабочий юнит-тест, ты смотришь на его код, а он… плохой? И ты такой думаешь: «Это же тест, оставлю так…». Нет, %username%, так оставлять не надо. Тесты — это значимая часть системы, которая обеспечивает поддерживаемость кода, и очень важно, чтобы эта часть также была поддерживаемой. К несчастью, у нас не так много способов обеспечить это (не будем же мы писать тесты на тесты), но парочка всё-таки есть.


В нашей школе разработчиков «Dodo DevSchool» мы выделяем в числе прочих такие критерии хорошего теста:

  • воспроизводимость: запуск тестов на одном и том же коде и входных данных всегда приводит к одному и тому же результату;
  • сфокусированность: должна быть только одна причина для падения теста;
  • понятность: ну тут и так понятно. :)

Как вам такой тест с точки зрения этих критериев?

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

По мне — очень плохо.

Он непонятный: я, например, не могу даже выделить блоки Arrange, Act и Assert.

Невоспроизводимый: используется свойство DateTime.Now. И наконец, он несфокусированный, т.к. имеет 2 причины падения: проверяются вызовы методов двух репозиториев.

Кроме того, хотя именование тестов выходит за рамки этой статьи, я всё же обращу внимание на название: при таком наборе отрицательных свойств сложно сформулировать его так, чтобы при взгляде на имя теста, сторонний человек сразу понял для чего этот тест вообще находится в проекте.
Если не получается лаконично назвать тест, значит с тестом что-то не так.
Раз уж тест непонятный, давайте расскажу что в нём происходит:

  1. Создаются ингредиенты.
  2. Из ингредиентов создаются продукты (пиццы).
  3. Из продуктов создается заказ.
  4. Создается сервис, для которого мокаются репозитории.
  5. Заказ передаётся методу AcceptOrder сервиса.
  6. Проверяется, что были вызваны методы Add и ReserveIngredients у соответствующих репозиториев.

Итак, как нам сделать этот тест лучше? Нужно попытаться оставить в теле теста только то, что по-настоящему важно. И для этого умные люди вроде Мартина Фаулера и Ребекки Парсонс придумали DSL (Domain Specific Language). Здесь я расскажу о паттернах DSL, которые мы в Додо используем для того, чтобы наши юнит-тесты были мягкими и шелковистыми, а разработчики чувствовали себя уверенно каждый день.

План такой: сначала мы сделаем этот тест понятным, потом поработаем над воспроизводимостью и закончим тем, что сделаем его сфокусированным. Погнали…

Вынесение ингредиентов (предопределённые доменные объекты)


Начнём с блока создания заказа. Заказ — это одна из центральных доменных сущностей. Было бы круто, если бы мы могли описывать заказ так, чтобы даже люди, которые не умеют писать код, но разбираются в доменной логике могли понять что за заказ мы создаём. Для этого, в первую очередь, нам нужно отказаться от использования абстрактных «Ingredient1» и «Pizza1» заменив их на реальные ингредиенты, пиццы и прочие доменные объекты.

Первый кандидат на оптимизацию — это ингредиенты. С ними всё просто: для них не нужно никакой кастомизации, только вызов конструктора. Достаточно вынести их в отдельный контейнер и назвать так, чтобы было понятно доменным экспертам:

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

Вместо совершенно невменяемых Ingredient1, Ingredient2 и Ingredient3 мы получили Тесто, Пепперони и Моцареллу.
Используйте предопределение доменных объектов для часто использующихся доменных сущностей.

Builder для продуктов


Следующая доменная сущность это продукты. С ними всё немного сложнее: каждый продукт состоит из нескольких ингредиентов и нам придётся добавить их в продукт перед использованием.

Здесь нам пригодится старый добрый паттерн Builder. Вот как выглядит моя версия билдера для продукта:

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

Он состоит из параметризованного конструктора, кастомизирующего метода Containing и терминального метода Please. Если не любите любезничать с кодом, то можно заменить Please на Now. Билдер скрывает сложные конструкторы и вызовы методов, настраивающих объект. Код становится чище и понятнее. По-хорошему билдер должен упрощать создание объекта настолько, чтобы код был понятен доменному эксперту. Особенно стоит использовать билдер для объектов, которые требуют настройки перед началом работы.

Билдер продукта позволит создавать конструкции вроде:

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

Билдеры помогают создавать объекты, которым нужна настройка. Рассмотрите возможность создания билдера даже если настройка состоит из одной строчки.

Объект ObjectMother


Несмотря на то, что создание продукта стало намного приличнее, конструктор new ProductBuilder всё еще выглядит довольно уродливо. Починим это с помощью паттерна ObjectMother (Father).

Паттерн простой как 5 копеек: создаём статический класс и собираем в него все билдеры.

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

Теперь можно писать так:

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

ObjectMother придуман для декларативного создания объектов. Кроме того он помогает вводить в домен новых разработчиков, т.к. при написании слова Create IDE сама подскажет что можно создать в этом домене.

В нашем коде ObjectMother иногда называют не Create, а Given. Оба варианта мне нравятся. Если у вас есть какие-то еще идеи — поделитесь в комментариях.
Для декларативного создания объектов используйте ObjectMother. Код станет чище, а новым разработчикам будет проще вникать в домен.

Вынесение продуктов


Стало сильно лучше, но продуктам ещё есть куда расти. Продуктов у нас ограниченное количество и их можно подобно ингредиентам собрать в отдельном классе и не инициализировать для каждого теста:

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

Здесь я назвал контейнер не Products, а Pizza. Такое название помогает читать тест. Например, оно помогает снять вопросы типа «А Pepperoni — это пицца или колбаска?».
Старайтесь использовать реальные доменные объекты, а не заменители вроде Product1.

Билдер для заказа (пример с обратной стороны)


Теперь применим описанные паттерны для создания билдера заказа, но теперь пойдём не от билдера, а от того, что бы нам хотелось получить. Вот так я хочу создавать заказ:

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

Как мы можем этого добиться? Нам, очевидно, понадобится билдеры для заказа и строки заказа. С билдером для заказа всё кристально ясно. Вот он:

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

А вот с OrderLine ситуация поинтереснее: во-первых, здесь не вызывается терминальный метод Please, а во-вторых, доступ к билдеру предоставляет не статический Create и не конструктор самого билдера. Первую проблему мы решим с помощью implicit operator и наш билдер будет выглядеть так:

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

Со второй нам поможет разобраться Extension-метод для класса Product:

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

Вообще Extension-методы — это большие друзья DSL. Они могут из совершенно адской логики сделать декларативное понятное описание.
Используйте extension-методы. Просто используйте их. :)
Сделав все эти действия мы получили вот такой код теста:

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

Здесь мы применили подход, который мы называем «Волшебная фея». Это когда ты сначала пишешь неработающий код так как тебе хотелось бы его видеть, а потом пытаешься завернуть то, что написал, в DSL. Так действовать очень полезно — иногда ты и сам не представляешь на что способен C#.
Представьте, что прилетела волшебная фея и разрешила вам писать код так, как хочется, а потом попробуйте обернуть написанное в DSL.

Создание сервиса (паттерн Testable)


С заказом теперь всё более-менее неплохо. Настало время разобраться с моками репозиториев. Здесь стоит сказать, что сам по себе тест, который мы рассматриваем — это тест на поведение. Тесты на поведение сильно связаны с реализацией методов и, если есть возможность не писать такие тесты, то лучше этого не делать. Однако, иногда они бывают полезны, а временами, без них вообще не обойтись. Следующая техника помогает писать именно тесты на поведение и если вы вдруг понимаете, что хотите воспользоваться ей, то сначала задумайтесь, нельзя ли переписать тесты таким образом, чтобы они проверяли состояние, а не поведение.

Итак, я хочу сделать так, чтобы в моём тестовом методе не было ни одного мока. Для этого я создам обёртку для PizzeriaService, в которой инкапсулирую всю логику, которая проверяет вызовы методов:

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

  }
}

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

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

     return new PizzeriaServiceTestable(orderRepositoryMock, ingredientsRepositoryMock);
  }
}

На текущий момент наш тестовый метод выглядит так:

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

Проверки вызовов методов — не единственное для чего может использоваться Testable-класс. Вот, например, здесь наш Дима Павлов использует его для сложного рефакторинга легаси-кода.
Testable способен спасти положение в самых сложных случаях. Для тестов на поведение он помогает обернуть уродливые проверки вызовов в красивые методы.
На этом знаменательном моменте мы закончили разбираться с понятностью теста. Осталось сделать его воспроизводимым и сфокусированным.

Воспроизводимость (Literal Extension)


Паттерн Literal Extension не имеет прямого отношения к воспроизводимости, но поможет нам именно с ней. Наша проблема на текущий момент в том, что мы используем дату DateTime.Now в качестве даты заказа. Если вдруг начиная с какой-то даты, логика приёма заказа изменится, то в нашей бизнес-логике мы должны будем хотя бы какое-то время поддерживать 2 логики принятия заказа, разделяя их проверкой вроде if (order.Date > edgeDate). В этом случае у нашего теста есть шанс упасть при переходе системной даты через граничную. Да, мы быстро это пофиксим, и даже сделаем из одного теста два: один будет проверять логику до граничной даты, а другой после. Тем не менее таких ситуаций лучше избегать и сразу делать все входные данные постоянными.

«Причём же здесь DSL?» — спросите вы. Дело в том, что даты в тестах удобно вводить через Extension-методы, например 3.May(2019). Такая форма записи будет понятна не только разработчикам, но и бизнесу. Для этого нужно всего лишь создать такой статический класс

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

Естественно, даты — не единственное, для чего можно использовать этот паттерн. Например, если бы мы вводили количество ингредиентов в составе продуктов, то могли бы написать что-то вроде 42.Grams("flour").
Количественные объекты и даты удобно создавать через уже знакомые extension-методы.

Сфокусированность


Почему важно делать тесты сфокусированными? Дело в том, что сфокусированные тесты легче поддерживать, а поддерживать их еще как придется. Например, их приходится менять при изменении кода и удалять при выпиливании старых фич. Если тесты не сфокусированные, то при изменении логики нужно будет разобраться в больших тестах, и выпилить из них куски проверяемой функциональности. Если же тесты сфокусированные и их названия понятны, то нужно просто удалить устаревшие тесты и написать новые. Если в тестах есть хороший DSL, то это вообще не проблема.

Итак, после того, как мы закончили писать DSL, у нас появилась возможность сделать этот тест сфокусированным, разделив его на 2 теста:

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

Оба теста получились короткими, понятными, воспроизводимыми и сфокусированными.

Обратите внимание, что теперь названия тестов отражают цель, ради которой они были написаны и теперь любой разработчик, зашедший в мой проект, поймет зачем был написан каждый из тестов и что в этом тесте происходит.
Сфокусированность тестов делает их поддерживаемыми. Хороший тест обязан быть сфокусированным.
И вот, я уже слышу как вы кричите мне «Юра, ты что охренел? Мы написали миллион кода только для того, чтобы сделать пару тестов красивенькими?». Да, именно так. Пока у нас всего пара тестов, имеет смысл вложиться в DSL и сделать эти тесты понятными. Один раз написав DSL, ты получаешь кучу плюшек:

  • Новые тесты становится писать легко. Не нужно 2 часа настраивать себя на юнит-тестирование, просто берешь и пишешь.
  • Тесты становятся понятными и читаемыми. Любой разработчик взглянув на тест понимает, зачем он был написан и что проверяет.
  • Снижается порог вхождения в тесты (а может быть и в домен) для новых разработчиков. Например, через ObjectMother можно легко разобраться какие объекты можно создать в домене.
  • Ну и наконец, с тестами просто приятно работать, и, как следствие, код становится более поддерживаемым.

Исходный код примера и тесты доступны здесь.
Tags:
Hubs:
+20
Comments 41
Comments Comments 41

Articles

Information

Website
dodo.dev
Registered
Founded
Employees
201–500 employees
Location
Россия