BDD-ожидания 10.09.2024

Под[в]водная

Скажу честно: несмотря на то, что я написал уже десяток-другой тысяч тестов, я не очень-то люблю писать их. Писал разные: и полезные, и неполезные, и хрупкие, и устойчивые, стабильные и нестабильные. Модульные, низкоуровневые интеграционные, высокоуровневые интеграционные, e2e, даже интерфейсы тестировал. Всякие писал.

Я знаю, что тесты нужны и важны и всё такое… но всегда хочу как можно быстрее написать тест, потратить как можно меньше своего мыслительного ресурса и толкнуть задачу дальше по процессам. При этом я хочу, чтобы тест позволял мне безбоязненно в будущем рефакторить проект, обновлять зависимости, втаскивать новую инфраструктуру и прочее, потому что я люблю такое привозить. И поэтому я продолжаю писать тесты.

Наверное поэтому моя нелюбовь к тестам как к процессу заставляет меня искать нестандартные (а иногда и стандартные) решения, чтобы сократить время на написание тестов и выжать из них максимум полезности в перспективе, а после делиться опытом со всеми заинтересованными.

Вводная

Кент Бек (автор экстремального программирования, TDD и фреймворка JUnit) в конце 90-ых и начале нулевых внёс значительный вклад в стандартизацию подходов тестирования. Его фреймворку начали подражать как на концептуальном уровне, так и на уровне предоставляемого фреймворками API. Именно благодаря JUnit мы теперь имеем PhpUnit, NUnit, PyUnit и всевозможные их вариации на разных языках.

Здесь я хочу поделиться своим опытом замены привычных ассертов на BDD-ожидания. Но для начала стоит кратко подсветить цель тестирования.

Тесты должны рассказывать конкретные сценарии использования через тестирование поведения. Здесь есть одна очень важная мысль: тест не тестирует код, а тестирует конкретное поведение объекта. Как мы знаем, объект без поведения не может являться объектом, это просто контейнер данных, а тестировать контейнеры данных бессмысленно, там нечего тестировать.

Это основное знание нужно вкладывать не только в каждый тест, но и в инфраструктуру, которую мы вводим для тестов. Мы хотим тестировать поведение и делать это быстро. Тесты не должны превращаться в ещё одно место, где мы «просто пишем код». Чем быстрее мы напишем тест, чем быстрее мы сможем его подправить под измененяющийся бизнес, тем быстрее мы доведём новую функциональность до прода.

Как сказал Владимир Хориков в своей книге «Принципы unit-тестирования», тесты - это обязательство, а не актив (liability, not an asset). То есть тесты - это точно такой же код, как код в основной кодовой базе проекта: его пишет разработчик, тест также требует поддержки и точно так же подвержен человеческим ошибкам.

И мы приходим плавно к тому, как именно мы проверяем ожидаемый результат.

BDD-ожидания

В тестах мы проверяем какой-то конкретный ожидаемый результат поведения и, например, результатом поведения выполнения заказа является состояние заказа: статус заказа должен поменяться на «выполнен», а коллекция доменных событий должна пополниться событием «заказ выполнен», если такое предусматривается.

Кент Бек, упомянутый выше, придумал, что утверждения (asserts) должны описываться в коде в обратном порядке, так как тест пишется разработчиком, и именно разработчик знает, какое ожидаемое значение, собственно, ожидается в тесте. То есть мы проверяем ожидаемое значение, поэтому наши ожидания появляются первыми, чем фактическое значение, которое мы проверяем - какая-то такая логика, если я верно её понял.

Со временем тестирование претерпело существенные изменения, к тестам начали относиться чуть иначе: я многократно встречал термин, что тесты - это своего рода документация (docs as tests), так как тесты рассказывают конкретные сценарии поведения, диктуемые бизнесом, хотя и в весьма своеобразном виде. А хороший, то есть понятный, тест - это такой тест, который могут прочесть и те, кто далёк от кода. Очень хороший тезис, от него мы и будем отталкиваться в дальнейшем.

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

  1. берём невыполненный ещё заказ (который можно выполнить);
  2. выполняем заказ;
  3. ожидаем, что заказ будет в состоянии «выполнен».

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

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

Даже если мы осознаём, что у нас нет нужных ресурсов и польза от ввода BDD не выглядит существенной, это ведь не значит, что нам нельзя даже смотреть в сторону BDD, верно? Давайте посмотрим на идею BDD-ожиданий, которую мы можем оттуда взять.

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

Давайте взглянем на вот такой ассерт условно реального теста:

assertSame(OrderStatus::Completed, $order->status);

Поставьте себя на место человека, который только пришёл в тест, а ещё лучше - никогда тесты не писал. Как вы прочитаете эту строку?

Я вот так: «утверждаю то же самое статус выполнен, статус заказа». Да, вот так читаю, дословно и слева-направо, потому что я поставил себя на место человека, который не знаком с особенностями фреймворка для тестирования, особенностями синтаксиса утверждений и в целом «особенностями».

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

assertSame(expected: OrderStatus::Completed, actual: $order->status);

Лучше не стало, всё равно получается, что кто-то где-то шатает чья-то дом труба.

А теперь давайте расширим пример и добавим ещё один ассерт к первому:

assertSame(OrderStatus::Completed, $order->status);
assertIsInstanceOf(OrderCompletedDomainEvent::class, $order->events[0]);

А это вы как читаете? На что обращаете внимание?

Здесь интересно: мы не просто добавили второй ассерт к первому. Мы к первому ассерту добавили такой ассерт, который раскрывает внутренности нашего объекта заказа. Помимо того, что в первом ассерте мы явным образом получаем статус заказа, во втором же ассерте залезаем в особенности реализации заказа - далеко не совсем бизнесовую часть заказа в виде коллекции доменных событий, забираем нулевой элемент и проверяем его.

Кроме того, визуально эти два ассерта выглядят как код, а не как конструкции, которые может прочесть сын маминой подруги (хотя, он может прочесть всё). Ну согласитесь, это ведь код, который мы написали для тестирования кода. Здесь и работа с коллекциями, и обращение к свойствам объектов, и хитрая проверка на принадлежность классу.

Но и это ещё не всё: эти два ассерта привязываются к разным контекстам. Первый ассерт находится в контексте статуса заказа и проверяет статус заказа, при этом об этом знании контекста мы узнаём в самом конце при чтении строки. Второй ассерт привязывается к контексту конкретного события, при этом расширяет контекст до знания о коллекции с событиями.

В итоге мы получили два несвязанных ассерта, объединённых одним тестовым сценарием. Мы должны хранить в голове, что эти проверки на самом деле не про статус и не про событие, они про конкретное состояние конкретного объекта заказа, то есть контекст мы формируем в своей голове неявным образом, что увеличивает когнитивную нагрузку при чтении. А если наш сценарий затронул несколько объектов? Как понять, где заканчивается контекст одного объекта и начинается контекст другого?

Теперь давайте взглянем на вот такие примеры:

assertThat($order)
->toHaveStatus(OrderStatus::Completed)
->toHaveEvent(OrderCompletedDomainEvent::class);
assertThat($order)
->toBeCompleted()
->toHaveCompletedEvent();

Такие утверждения, хотя их уже будет более верно называть BDD-ожиданиями, воспринимаются гораздо проще из-за того, что они контекстуально зависимые. Другими словами, они визуально «прицепляются» к конкретному объекту через fluent interface и инкапсулируют знание о свойствах этого объекта и знаниях о том, что же такое «выполнен» у заказа — это ведь может быть не простая проверка на статус, а наличие записи об изменении, если у нас под капотом Event Sourcing.

Сами ассерты из примеров выше никуда не делись, но поверх них появилась абстракция, прячущая особенности реализации и предоставляющая более лаконичное API.

К сожалению, PHP не имеет синтаксического сахара для расширения классов или прототипов, но, если язык позволяет (например JS или C#), то такие ассерты можно записать в чуть более красивом виде, семантически это будет то же самое:

order
.ToBeCompleted()
.ToHaveCompletedEvent();

BDD-ожидания и читаются более естественно: «ожидаю, что заказ должен будет выполнен, должен будет иметь событие выполнения». Так как это тестовый сценарий, где у нас есть некоторые ожидания, сами ожидания более явно подчёркивают будущее время и обязательства. Носителям языка, для которых английский был всегда везде и всюду, такие конструкции будут ещё более понятны.

А ещё мы получаем единую точку изменений. При модификации внутреннего устройства объекта без изменения поведения мы подправим BDD-ожидание, что автоматически исправит все тесты, которые были завязаны на конкретных свойствах объекта. Приятная мелочь, которая иногда может помочь сократить стоимость поддержки тестов в будущем, потому что, напоминаю, тесты - это обязательство.

Ок, а как быть в тестах без объектов? BDD-ожидания раскрываются хорошо на тестировании именно объектов, но это не значит, что таким же образом нельзя записывать проверки и для конкретных значений:

assertThat($bool)
->toBeTrue();

assertThat($int)
->toBe(10);

assertThatArray($array)
->toHaveCount(2);

assertThat($float) // передаём привет IEEE 754
->toBeGreaterThan(1.1)
->toBeLessThan(1.2);

Пользы от применения BDD-ожиданий для проверки не-объектов на самом деле не так уж и много. Я бы рекомендовал применять их для обеспечения консистентности тестов, чтобы не использовать и не регламентировать два подхода, а остановиться на каком-то одном.

Хорошо, а как быть с минусами?

Да всё очевидно. Недостатком такого подхода можно назвать появление дополнительного кода, помогающего реализовать абстракцию. Там, где мы могли написать только тест и привычные стандартные ассерты, мы теперь должны добавить BDD-ожидания в виде отдельного кода и только после этого написать тест.

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

Итоги

BDD-ожидания можно применить к чему угодно. Помимо тестирования доменных объектов очень хорошо на эту концепцию накладываются ответы на HTTP-запросы, где мы можем «прицепиться» к контексту ответа и проверить наличие нужных заголовков, нужных данных в теле ответа, статус ответа, не раскрывая подробностей работы с ответами:

assertThatResponse($response)
->toBeOk()
->hasVndApiJsonContentType()
->hasJsonBody();

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

ЗЫ. Для PHP мы нашли и используем вот такую библиотеку с реализацией базовых BDD-ожиданий, а для других языков, я уверен, есть аналогичные готовые.