- PVSM.RU - https://www.pvsm.ru -
Как часто бывало так, что написав рабочий юнит-тест, ты смотришь на его код, а он… плохой? И ты такой думаешь: «Это же тест, оставлю так…». Нет, %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 причины падения: проверяются вызовы методов двух репозиториев.
Кроме того, хотя именование тестов выходит за рамки этой статьи, я всё же обращу внимание на название: при таком наборе отрицательных свойств сложно сформулировать его так, чтобы при взгляде на имя теста, сторонний человек сразу понял для чего этот тест вообще находится в проекте.
Если не получается лаконично назвать тест, значит с тестом что-то не так.
Раз уж тест непонятный, давайте расскажу что в нём происходит:
Итак, как нам сделать этот тест лучше? Нужно попытаться оставить в теле теста только то, что по-настоящему важно. И для этого умные люди вроде Мартина Фаулера и Ребекки Парсонс придумали DSL (Domain Specific Language) [1]. Здесь я расскажу о паттернах 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. Вот как выглядит моя версия билдера для продукта:
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();
Билдеры помогают создавать объекты, которым нужна настройка. Рассмотрите возможность создания билдера даже если настройка состоит из одной строчки.
Несмотря на то, что создание продукта стало намного приличнее, конструктор 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.
С заказом теперь всё более-менее неплохо. Настало время разобраться с моками репозиториев. Здесь стоит сказать, что сам по себе тест, который мы рассматриваем — это тест на поведение. Тесты на поведение сильно связаны с реализацией методов и, если есть возможность не писать такие тесты, то лучше этого не делать. Однако, иногда они бывают полезны, а временами, без них вообще не обойтись. Следующая техника помогает писать именно тесты на поведение и если вы вдруг понимаете, что хотите воспользоваться ей, то сначала задумайтесь, нельзя ли переписать тесты таким образом, чтобы они проверяли состояние, а не поведение.
Итак, я хочу сделать так, чтобы в моём тестовом методе не было ни одного мока. Для этого я создам обёртку для 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-класс. Вот, например, здесь [2] наш Дима Павлов использует его для сложного рефакторинга легаси-кода.
Testable способен спасти положение в самых сложных случаях. Для тестов на поведение он помогает обернуть уродливые проверки вызовов в красивые методы.
На этом знаменательном моменте мы закончили разбираться с понятностью теста. Осталось сделать его воспроизводимым и сфокусированным.
Паттерн 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, ты получаешь кучу плюшек:
Исходный код примера и тесты доступны здесь [3].
Автор: Юрий Пастушенко
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/317535
Ссылки в тексте:
[1] DSL (Domain Specific Language): https://www.martinfowler.com/books/dsl.html
[2] здесь: https://youtu.be/XejfFBvODxc?t=3746
[3] здесь: https://github.com/pastushenkoy/dsl-example
[4] Источник: https://habr.com/ru/post/451598/?utm_source=habrahabr&utm_medium=rss&utm_campaign=451598
Нажмите здесь для печати.