Через три года после старта проекта в команду приходит новый человек, и ему вручают каталог feature-файлов вместо документации. В одном проекте он читает их как спецификацию и к обеду понимает правила начисления скидок. В другом листает страницу за страницей нажатий кнопок и заполнений полей, за которыми бизнес-логику разглядеть так и не удаётся. Оба набора безупречны формально: парсер доволен, шаги распознаны, прогоны зелёные. Разница не в синтаксисе, а в том, о чём и на каком языке сценарии написаны. Шаблон Given-When-Then освоить несложно, читаемость даётся иначе.
Шаблон Given-When-Then придумали для разговора с бизнесом
Идея выросла из страданий тест-драйва. В начале двухтысячных Дэн Норт учил программистов TDD и раз за разом слышал одни и те же вопросы: с чего начать, что проверять в первую очередь, как называть тесты, чтобы провал говорил сам за себя. Поводом к переосмыслению стала скромная утилита agiledox его коллеги Криса Стивенсона, печатавшая имена тестовых методов JUnit обычными предложениями. Стоило составить методы живыми фразами, и полученный текст годился в документацию для аналитиков и тестировщиков. Отсюда выросла мысль, что разработчикам нужно описание поведения, а не проверка, и 20 сентября 2006 года Норт опубликовал манифест BDD.
Для критериев приёмки команда выработала шаблон: дан начальный контекст, когда происходит событие, тогда проверяем результат. Первые фреймворки воплотили это буквально: в JBehave каждый фрагмент сценария превращался в Java-класс с методами setup и occurIn. В 2008 году из Story Runner в RSpec вырос Cucumber Аслака Хеллесёя, а его язык Gherkin стал де-факто стандартом: ключевые слова локализованы на десятки языков, включая русский, так что сценарий читается словами "Допустим", "Когда", "Тогда" без единого английского вкрапления. Замысел был прикладным: слова сценария должен понимать аналитик, который никогда не открывал код.
Классическая иллюстрация из той самой статьи, снятие наличных в банкомате, на сегодняшнем языке выглядит так:
Сценарий: На счёте есть деньги
Допустим на счёте есть деньги
И карта действительна
И в банкомате есть наличные
Когда клиент запрашивает деньги
Тогда счёт уменьшается на запрошенную сумму
И деньги выдаются
И карта возвращается
Норт специально оговаривал роль союза И: он связывает несколько начальных условий и несколько исходов, не превращая сценарий в механический перечень. Фрагменты он задумывал мелкими ровно настолько, чтобы каждый ложился в код напрямую. Сама запись за двадцать лет не устарела ни на йоту, устарели лишь способы её исполнения: классы Given и Event в JBehave уступили место шагам с выражениями и таблицами.
Синтаксис Gherkin проверяет форму, но не содержание
Разборщик Gherkin строг только к структуре. Первым словом файла идёт Функция, сценарии перечисляет слово Сценарий, общую подготовку выносит Контекст, матрицу вариантов описывает пара Структура сценария и Примеры, шаги начинаются с Допустим, Когда и Тогда. Шестая версия языка добавила Rule для группировки правил, есть таблицы значений через вертикальные черты, многострочные тексты в тройных кавычках и теги. Всё, что не служебное слово и не шаг, парсер пропускает: пояснения под названием функции пишутся свободно и попадают в отчёты. Пара ограничений стоит знать заранее: один файл вмещает ровно одну Функцию, комментарии начинаются с решётки и обязаны занимать целую строку, блочных комментариев язык не поддерживает, а за образец выравнивания приняты два пробела.
Так выглядит сценарий, после которого читателю почти ничего не нужно объяснять:
Функция: Начисление бонусов постоянным клиентам
Клиенту с более чем тремя заказами в квартал
полагается скидка пять процентов на следующий заказ
Структура сценария: Скидка зависит от числа заказов
Допустим клиент совершил <заказы> заказов за квартал
Когда он оформляет следующий заказ
Тогда итоговая скидка составляет <скидка>
Примеры:
| заказы | скидка |
| 2 | 0 % |
| 4 | 5 % |
Формальная проверка прошла, шаги связаны с кодом, всё зелёное. Однако тот же разборщик с тем же успехом примет текст, где каждое слово стоит по шаблону, а смысла нет. Синтаксис отвечает за форму фразы, за содержание отвечает только автор. Вот здесь и проходит граница между набором, который читают, и набором, который терпят.
Через годы читаются сценарии с одним уровнем абстракции
Типовой продукт слепого следования шаблону выглядит так:
Сценарий: Оформление заказа
Допустим пользователь открыл страницу входа
И заполнил поле "логин" значением "Мария"
И заполнил поле "пароль" значением "пароль123"
И нажал кнопку "Войти"
И подождал 2 секунды
Когда она открывает каталог и выбирает третий товар
И нажимает "В корзину"
И нажимает "Оформить"
Тогда на экране есть текст "Заказ принят"
Спустя два года кнопку переименовали, вход переехал на отдельный домен, фиксированное ожидание заменили умным опросом. Читая такой файл, человек держит в голове интерфейс целиком, а на главный вопрос "какое бизнес-правило здесь проверяется" ответа нет. Есть маршрут по экранам, замороженный на момент написания.
Тот же случай в декларативном изложении:
Сценарий: Гость оформляет заказ после входа в учётную запись
Допустим Мария уже вошла в учётную запись
Когда она оформляет заказ на товар из каталога
Тогда заказ принимается и сохраняется в её истории
Правило простое: в сценарии живёт одно бизнес-правило, и живёт оно на одном уровне. Селекторы, ожидания и подробности интерфейса прячутся в определениях шагов, а строка сценария сообщает о намерении. Такой файл перечитывают через три года без участников тех событий, потому что опирается он на язык предметной области, который меняется куда медленнее вёрстки. Название сценария в этом смысле работает как заголовок правила: "Скидка не действует при отменённом заказе" сообщает больше, чем "Проверка скидки 3". Неслучайно в командах с живыми BDD-наборами сценарии правят вместе с задачей: поменялось правило, поменялась одна строка.
Скрытое состояние между шагами разъедает ценность сценариев
Вторая беда формальных наборов прячется в коде шагов. Определения связаны невидимой нитью: шаг входа сохраняет пользователя в общий контекст, шаг заказа достаёт его оттуда. В cucumber-js таким общим хранилищем служит объект World, в Cucumber-JVM состояние собирает контейнер внедрения зависимостей, в pytest-bdd связующим звеном работают fixtures из pytest. Пока связей немного, всё работает. Когда их становятся десятки, переставить два шага местами уже нельзя, хотя по тексту сценария этого никто не скажет.
public class OrderSteps {
private final ShopContext context;
public OrderSteps(ShopContext context) {
this.context = context;
}
@Given("Мария уже вошла в учётную запись")
public void login() {
context.setCurrentUser(users.create("Мария"));
}
@When("она оформляет заказ на товар из каталога")
public void placeOrder() {
User user = context.getCurrentUser();
shop.placeOrder(user, catalog.randomItem());
}
}
Вызов context.getCurrentUser() внутри второго шага выглядит невинно, только пока первый шаг гарантированно исполнялся раньше. Уберут его из сценария, поменяют местами с подготовкой, запустят параллельно, и вся конструкция рассыплется, причём ошибка окажется далеко от строки, которая её вызвала. Гигиена здесь конкретна: общее состояние держат минимальным, чаще всего это один главный объект с понятным именем, а всё остальное шаги получают через параметры. Подготовку, от которой зависит шаг, честнее писать отдельной строкой прямо в сценарии, чтобы читатель видел источник. Подготовку и очистку общего хозяйства выносят в обработчики Before и After: каждый сценарий получает чистый старт, параллельный прогон перестаёт зависеть от порядка исполнения, а изоляция состояния, бесплатная на старте проекта, сберегает недели отладки потом.
Формальное следование шаблону узнаваемо по типовым признакам
Наборы, где шаблон соблюдён, а документации нет, оставляют одинаковые следы:
- шаги-невидимки вида "Допустим всё готово к работе" и "Тогда система ведёт себя корректно", за которыми не стоит ни одной проверки;
- цепочки из десяти И, где реально работает одно звено, а остальные пересказывают интерфейс;
- несколько событий Когда подряд, каждое со своим исходом, хотя шаблон рассчитан на одно событие;
- определения шагов на регэкспах, которые побаиваются трогать даже их авторы;
- названия сценариев вида "проверка входа 2", по которым через год не найдут ничего;
- Контекст на полстраницы, навязывающий каждому сценарию всю подготовку разом;
- фиксированные паузы в шагах, которые замедляют прогон и маскируют состояние гонки.
Каждый признак по отдельности терпим, вместе они превращают каталог в однотипный шум, который никто не перечитывает. Диагноз ставится за пять минут: попросите нового тестировщика пересказать любое правило по файлу, не открывая код. Пересказ не складывается, значит сценарии писались для парсера, а не для человека. И это не мелочь: набор, который не читают, перестают обновлять, ветшает и в конце концов выбрасывается вместе с потраченными на него месяцами.
Гигиена сценариев сохраняет их ценность на годы вперёд
Практические приёмы стоят недорого, а эффект накапливается. Регэкспы в определениях шагов сменили Cucumber-выражения с типами параметров: запись "Допустим клиент совершил {int} заказов" читается и в файле сценария, и в коде, а собственные типы параметров придают шагам словарь предметной области:
@ParameterType(".*(рубля|рублей)")
public Money money(String raw) {
return Money.parse(raw);
}
@Then("итоговая скидка составляет {money}")
public void discount(Money expected) {
assertThat(cart.discount(), is(expected));
}
Один такой тип экономит десятки близнецов-шагов, различающихся только способом передачи суммы. Правило словаря простое: одна фраза означает одно действие, и близнецы вроде "нажал кнопку" и "кликнул по кнопке" множить нельзя, через год они ломают и поиск, и понимание. Сухой прогон с флагом --dry-run не запускает сценарии, а лишь сверяет шаги с определениями и печатает несвязанные, так что набор не копит мёртвые строки. Отчёты в формате HTML показывают сценарии руководству без единого инструмента разработки, и это тот случай, когда документирующая ценность проверяется на людях, а не на тест-раннере.
Помогает и честный статус файлов в ревью: Gherkin-файл правится в одной ветке с кодом и читается обзорщиком как спецификация, а не как приложение к ней. Экосистема тем временем живёт: Behat держит PHP, pytest-bdd врастает в Python-проекты, связки Gherkin с Playwright прогоняют сценарии в настоящем браузере, а после остановки развития SpecFlow его место в .NET занял открытый Reqnroll. Инструменты меняются, и это ещё один аргумент держать ценность в текстах сценариев, а не в обвязке: тексты переживут и смену раннера, и смену половины команды.
Сценарий, который стоит перечитать через три года
Всё сходится к трём опорам. Одна бизнес-правило на один сценарий, один уровень абстракции во всех строках, минимум скрытого состояния в определениях. На этих трёх опорах Given-When-Then превращается в документацию, которая обновляется сама, потому что зелёная проверка вынуждает тексты не отставать от системы. Без них шаблон остаётся пустой формой: парсер доволен, прогоны зелёные, а подсказать новичку, как работает скидка, никто не может.
Формальный синтаксис здесь потолок, а пол. Команде, которая собирается завести BDD-набор, полезно сразу договориться о двух вещах: строку сценария пишет язык предметной области, а интерфейсные подробности живут в коде шагов, и каждый файл проходит ревью как спецификация. Проверка на прочность проста и неприятна: откройте случайный сценарий двухлетней давности и спросите себя, понятно ли без контекста, какое правило он охраняет. Если да, набор пережил смену половины команды не случайно. Если нет, самое время переписывать, пока старые тексты ещё можно спасти.