BDD как единый источник требований и тесты

Мне нравится идея BDD из-за одной довольно простой вещи: требования можно сразу записать в виде понятных сценариев, а потом эти же сценарии запустить как тесты. Не нужно отдельно держать описание задачи для бизнеса, пересказ этого описания для разработчика и ещё один пересказ для тестировщика. У всех может быть один язык и один сценарий, который описывает ожидаемое поведение системы.
Причём написать такой сценарий можно почти на любом этапе работы над задачей. Его можно собрать на общей сессии с бизнесом, аналитиком, тестировщиком и разработчиком. Можно отдать написание аналитику, а разработчику оставить реализацию шагов. Можно сделать наоборот: разработчик сам пишет сценарии, а аналитик и тестировщик отдельно их проверяют. Даже если BDD появились уже после готового кода, они всё равно могут стать исполняемой документацией существующего поведения.
По ощущениям, про BDD знают сильно меньше людей, чем могли бы. А те, кто знает, нередко воспринимают их как какой-то отдельный вид автотестов для тестировщиков. На практике вокруг BDD можно выстроить почти весь процесс работы над требованием: от первого обсуждения до проверки кода в CI.
BDD и Gherkin - не совсем одно и то же
BDD расшифровывается как Behavior-driven development - разработка через описание поведения. Идея шире конкретного синтаксиса или тестового фреймворка. Участники разработки сначала договариваются, как система должна вести себя в определённой ситуации, а затем фиксируют эту договорённость в примерах.
Gherkin - язык, на котором такие примеры удобно записывать. В нём есть знакомая структура:
Givenописывает начальное состояние;When- действие или событие;Then- наблюдаемый результат.
Полное описание синтаксиса есть в официальной документации Gherkin. Сам по себе Gherkin ничего не знает о Python, Go, базе данных, HTTP и конкретной архитектуре сервиса. Он описывает поведение словами предметной области. Связь с кодом появляется позже, в реализациях шагов.
Дальше я иногда буду называть BDD и сами сценарии почти одним словом. Технически это упрощение: BDD является подходом, а Gherkin - одним из способов записать сценарии. Но в рамках статьи нас интересует именно практическая связка из понятного сценария и исполняемых шагов.
Начнём с простой очистки корзины
Допустим, у пользователя есть корзина с товарами и он хочет полностью её очистить. Первую версию требования можно записать так:
Feature: Очистка корзины
Scenario: Пользователь очищает корзину
Given в корзине пользователя лежат товары
When пользователь очищает корзину
Then корзина пользователя пуста
Этот текст уже можно показать почти любому участнику разработки. Бизнес видит ожидаемое поведение. Аналитик может проверить, правильно ли понято требование. Тестировщик сразу видит основной сценарий. Разработчик понимает, какое начальное состояние нужно подготовить, что вызвать и какой результат проверить.
При этом в сценарии нет технических деталей. Там не написано, какой HTTP-метод нужно вызвать, в какой таблице лежит корзина и какой use-case отвечает за очистку. Всё это детали реализации. Сегодня шаг может вызывать API, завтра - бизнес-логику напрямую. Само ожидаемое поведение от этого не меняется.
В этом и находится одна из главных причин использовать Gherkin. Он заставляет описывать результат на языке предметной области. Если сценарий выглядит как последовательность из POST /api/v1/cart/clear, SQL-запроса и проверки конкретной таблицы, то читать его смогут в основном разработчики. Формально это всё ещё Gherkin, но единый язык уже куда-то потерялся.
Как вокруг сценария появляется процесс
Для начала возьмём один конкретный вариант процесса. Он не является единственно правильным, просто на нём проще показать полный путь требования.
- Бизнес формулирует необходимость ручной очистки корзины.
- Аналитик вместе с тестировщиком и разработчиком уточняет поведение и записывает сценарий в Gherkin.
- Сценарий добавляется к проекту, но временно помечается тегом
@skip, потому что нужного поведения в коде ещё нет. - Разработчик реализует функциональность и связывает текстовые шаги с кодом.
- Тег снимается, сценарий начинает выполняться в общем наборе тестов.
- Аналитик и тестировщик проверяют, что формулировки всё ещё соответствуют исходному требованию, а разработчик - что за каждым шагом действительно стоит нужное действие или проверка.
Черновой сценарий может выглядеть так:
Feature: Очистка корзины
@skip
Scenario: Пользователь очищает корзину
Given в корзине пользователя лежат товары
When пользователь очищает корзину
Then корзина пользователя пуста
Тут есть важная техническая мелочь: @skip не является магической командой Gherkin. Это обычный тег и командная договорённость. Тестовый runner нужно отдельно настроить так, чтобы он исключал подобные сценарии. Например, в Behave это можно сделать через behave.ini:
[behave]
default_tags = not @skip
Вместо @skip можно использовать @not_implemented, @wip или любое другое соглашение. Важно только, чтобы новый сценарий не ломал основной пайплайн до начала разработки и при этом не терялся где-то в описании задачи.
Такой процесс даёт разработчику довольно детерминированную работу. Есть начальное состояние, действие и ожидаемый результат. Не нужно превращать абстрактный текст задачи в набор проверяемых условий уже в процессе написания кода - этот набор лежит перед глазами.
Процесс может быть почти любым
Смысла превращать предыдущую последовательность в обязательный ритуал я не вижу. Ценность BDD как раз в том, что роли можно распределять по-разному.
Сценарии можно писать на общей сессии. Бизнес объясняет правило, аналитик помогает превратить его в точную формулировку, тестировщик ищет corner cases, а разработчик сразу говорит, где требование двусмысленно или слишком дорого реализуется. Такая встреча может быть обычным брейнштормом вокруг одного .feature-файла.
Можно отдать сценарии аналитику. Он добавляет новые BDD под @skip, после чего разработчик получает уже формализованные примеры поведения. По мере реализации разработчик подключает шаги и убирает тег.
Можно отдать всё разработчику. Он пишет сценарии и их реализацию, а аналитик и тестировщик отдельно ревьюят именно BDD. Такое ревью всё равно полезно: код они могут не читать, а сценарий на предметном языке - вполне.
Можно сначала реализовать задачу, а потом описать существующее поведение. В таком варианте BDD уже не управляют разработкой с самого начала, но остаются тестами и исполняемой документацией. Иногда это единственный реалистичный путь для старого сервиса, где поведение давно существует, а нормального описания нет.
Ни один из вариантов сам по себе не делает сценарии лучше. Можно провести общую встречу и коллективно пропустить очевидный corner case. Можно поручить всё одному сильному разработчику и получить отличное описание. Конкретный процесс зависит от команды. Важнее, чтобы итоговый сценарий был однозначным, проверяемым и действительно описывал согласованное поведение.
Сценарии постепенно разрастаются
Первый вариант очистки корзины слишком абстрактный. Что значит «лежат товары» - один товар, десять, сотня? Что должно происходить с уже пустой корзиной? Нужна ли пользователю активная сессия? Все эти вопросы необязательно решать в первой же строчке. Сценарий может уточняться вместе с требованием.
Например, авторизацию можно вынести в Background, а количество товаров - в Examples:
Feature: Очистка корзины
Background:
Given пользователь авторизован
Scenario Outline: Пользователь очищает корзину
Given в корзине пользователя <items_count> товаров
When пользователь очищает корзину
Then в корзине пользователя остаётся 0 товаров
Examples:
| items_count |
| 0 |
| 1 |
| 3 |
Background выполняется перед каждым сценарием внутри feature. Пока сценарий один, выносить туда единственный шаг не особенно полезно. Здесь я показываю его как следующий этап роста файла: когда рядом появятся другие операции с корзиной, общую авторизацию не придётся повторять в каждом сценарии. Создавать Background ради самого факта его существования не нужно.
Scenario Outline превращает один шаблон в несколько конкретных примеров. В данном случае сценарий будет выполнен для пустой корзины, корзины с одним товаром и корзины с тремя товарами. Так в требованиях появляется важная вводная: операция должна приводить корзину к одному состоянию независимо от исходного количества товаров.
Если позже выяснится, что пустую корзину очищать нельзя или для неё нужен другой ответ, это уже изменение поведения. Его нужно отразить в BDD, а не спрятать внутри step definition. Иначе текст будет обещать одно, а код шага молча реализовывать другое.
Со временем могут появиться новые Given, отдельные сценарии, таблицы примеров и бизнес-правила. Это нормальная эволюция. Главное, чтобы сценарий не превращался в простыню из двадцати технических шагов. Если для понимания одного поведения нужно держать в голове половину устройства сервиса, то BDD перестают выполнять свою основную задачу.
Что находится под Gherkin в Python
Теперь откроем фасад и посмотрим, как текст превращается в обычный тест. Для Python возьмём Behave. Минимальная структура выглядит примерно так:
features/
├── clear_cart.feature
├── environment.py
└── steps/
└── cart_steps.py
В clear_cart.feature лежит сценарий. В environment.py можно подготовить тестовую инфраструктуру. Например:
# features/environment.py
from tests.bdd.cart_driver import build_cart_test_driver
def before_scenario(context, scenario):
context.cart = build_cart_test_driver()
Реализацию build_cart_test_driver() я специально опускаю. В одном проекте driver может ходить в живое HTTP API. В другом - вызывать use-case напрямую. В третьем - поднимать приложение с тестовой базой. Для Behave это просто объект, через который шаги подготавливают данные, выполняют действие и читают результат.
Сами шаги могут выглядеть так:
# features/steps/cart_steps.py
from behave import given, then, when
@given("пользователь авторизован")
def authorize_user(context):
context.user_id = context.cart.create_user()
@given("в корзине пользователя {items_count:d} товаров")
def fill_cart(context, items_count):
context.cart.set_items(
user_id=context.user_id,
items_count=items_count,
)
@when("пользователь очищает корзину")
def clear_cart(context):
context.cart.clear(user_id=context.user_id)
@then("в корзине пользователя остаётся {expected_count:d} товаров")
def assert_items_count(context, expected_count):
actual_count = context.cart.get_items_count(user_id=context.user_id)
assert actual_count == expected_count, (
f"ожидали {expected_count} товаров, получили {actual_count}"
)
Никакой особой магии здесь нет.
В первом Given мы создаём пользователя и сохраняем его идентификатор в context. Во втором Given берём значение из Examples и подготавливаем корзину. В When передаём сохранённый идентификатор в реальный вызов приложения. В Then читаем фактическое состояние и выполняем обычный assert.
context нужен, чтобы передавать состояние между шагами одного сценария. В нём можно держать пользователя, ответ API, созданные сущности и другие данные текущего теста. Behave сам управляет временем жизни контекста, а setup и cleanup можно подключать через hooks и fixtures. Подробно это описано в руководстве Behave.
По сути, это те же тесты, которые разработчик и так пишет. Они просто разделены на именованные шаги:
подготовить данные -> выполнить код -> проверить результат
Given -> When -> Then
Под context.cart.clear() может скрываться что угодно:
# Вариант с API
response = api_client.delete(f"/users/{user_id}/cart")
# Вариант с бизнес-логикой
clear_cart_use_case.execute(user_id=user_id)
Выбирать конкретный уровень нужно исходя из того, что команда хочет проверять. Один и тот же текстовый сценарий не обязан навсегда привязываться к HTTP, базе или определённому классу. Behave даже позволяет запускать разные наборы реализаций шагов для разных test stages, но для основной идеи статьи это уже лишняя деталь.
Тот же сценарий на Go
Теперь возьмём Go и Godog. Переписывать .feature-файл не нужно. Меняется только код, который связывает его шаги с приложением.
Как и в Python-примере, я опускаю импорты, интерфейс CartTestDriver и его конкретную реализацию. Это та же точка подключения к приложению: за ней может находиться API-клиент, use-case или другая тестовая инфраструктура.
Состояние сценария можно держать в небольшой структуре:
type cartScenario struct {
driver CartTestDriver
userID string
}
func (s *cartScenario) authorizeUser() error {
s.driver = buildCartTestDriver()
userID, err := s.driver.CreateUser()
if err != nil {
return err
}
s.userID = userID
return nil
}
func (s *cartScenario) fillCart(itemsCount int) error {
return s.driver.SetItems(s.userID, itemsCount)
}
func (s *cartScenario) clearCart() error {
return s.driver.Clear(s.userID)
}
func (s *cartScenario) assertItemsCount(expectedCount int) error {
actualCount, err := s.driver.GetItemsCount(s.userID)
if err != nil {
return err
}
if actualCount != expectedCount {
return fmt.Errorf(
"ожидали %d товаров, получили %d",
expectedCount,
actualCount,
)
}
return nil
}
Дальше методы регистрируются как реализации шагов:
func InitializeScenario(ctx *godog.ScenarioContext) {
scenario := &cartScenario{}
ctx.Given(
`^пользователь авторизован$`,
scenario.authorizeUser,
)
ctx.Given(
`^в корзине пользователя (\d+) товаров$`,
scenario.fillCart,
)
ctx.When(
`^пользователь очищает корзину$`,
scenario.clearCart,
)
ctx.Then(
`^в корзине пользователя остаётся (\d+) товаров$`,
scenario.assertItemsCount,
)
}
А сам набор feature-файлов можно подключить к обычному go test:
func TestFeatures(t *testing.T) {
suite := godog.TestSuite{
ScenarioInitializer: InitializeScenario,
Options: &godog.Options{
Format: "pretty",
Paths: []string{"features"},
TestingT: t,
},
}
if suite.Run() != 0 {
t.Fatal("BDD scenarios failed")
}
}
Go-код выглядит иначе, проверки возвращают error вместо Python-assert'ов, инфраструктура собирается другим способом. Но сценарий остался тем же. Бизнесу, аналитику и тестировщику вообще не нужно знать, на каком языке реализованы шаги.
Я не предлагаю писать одну и ту же проверку одновременно на Python и Go. Второй пример нужен только для иллюстрации: язык требований не зависит от языка приложения. Один сервис может использовать Behave, другой Godog, третий Cucumber для Java или JavaScript. Принцип остаётся прежним.
Почему BDD могут быть источником истины
Источник истины здесь - сами сценарии, а не конкретное место их хранения. Они могут лежать рядом с кодом, в отдельном репозитории или в системе управления тестами. Они могут запускаться локально, в CI или через отдельный сервис. Организация хранения влияет на удобство процесса, но не меняет смысл BDD.
Сценарий одновременно выполняет несколько ролей:
- фиксирует бизнес-требование на конкретном примере;
- объясняет ожидаемое поведение аналитику, тестировщику и разработчику;
- связывается с исполняемым кодом;
- проверяет, что реализация продолжает соответствовать договорённости;
- остаётся документацией, которую сложнее забыть обновить, потому что она запускается.
Если поведение сервиса расходится с BDD, в большинстве случаев проблема находится в реализации. Хорошо написанный сценарий должен точно и без двойных трактовок описывать ожидаемый результат. Но объявлять его непогрешимым всё равно не стоит. Требование могло измениться, участники могли пропустить условие, а сам сценарий - оказаться неверным.
При таком расхождении сначала нужно понять, что именно сломалось. Возможно, достаточно исправить код. Возможно, придётся обновить сценарий после нового решения бизнеса. А если подобное повторяется, дальше уже включаются процессы вокруг: ревью BDD, проверки тестовой инфраструктуры и обратная связь по инцидентам.
BDD тоже можно написать так, что они ничего не проверяют
Сам факт наличия .feature-файла не даёт никаких гарантий. Между красивым сценарием и реальным поведением сервиса всё ещё находится код, и в нём достаточно точек отказа.
Разработчик может оставить заглушку:
@then("корзина пользователя пуста")
def assert_cart_is_empty(context):
pass
Behave увидит реализованный шаг, выполнит функцию без ошибки и посчитает её успешной. В Godog можно получить тот же результат, если step definition всегда возвращает nil. Сценарий будет зелёным, хотя никакой проверки в нём нет.
Можно вызвать не тот слой приложения, неверно собрать тестовую инфраструктуру или проверить промежуточное состояние вместо наблюдаемого результата. Можно написать корректные step definitions и при этом пропустить важный сценарий. Аналитик и тестировщик тоже могут не заметить corner case или сформулировать условие так, что два разработчика поймут его по-разному.
Поэтому BDD нуждаются в обычной инженерной аккуратности:
- сценарии нужно ревьюить как требования;
- реализации шагов нужно ревьюить как тестовый код;
- в
Thenдолжны быть реальные проверки результата; - заглушенные и исключённые сценарии не должны годами жить под
@skip; - после инцидентов нужно проверять не только код, но и сценарии вместе с инфраструктурой их запуска.
Не нужно пытаться описать через Gherkin вообще каждый тест. Unit-тест небольшой функции, проверку SQL-запроса или нагрузочный профиль чаще проще оставить обычным кодом. BDD особенно хорошо работают там, где есть наблюдаемое бизнес-поведение, о котором должны одинаково договориться несколько участников разработки.
В итоге
BDD мне нравятся не из-за синтаксиса Given / When / Then самого по себе. Нравится возможность один раз описать ожидаемое поведение и дальше использовать это описание на всём пути задачи.
Сначала сценарий может быть предметом обсуждения. Потом - точным требованием для разработчика. После реализации он становится тестом и продолжает жить как документация. При этом команда не обязана копировать чужой процесс целиком. Сценарии можно писать вместе, по отдельности, до кода или после него. Можно запускать их через Python, Go или другой язык.
Под Gherkin всё равно находятся обычная подготовка данных, вызов приложения и проверка результата. Именно поэтому BDD не требуют какой-то отдельной магии. Они дают этим действиям форму, которую понимают не только авторы тестового кода.
Если сценарии написаны точно, а шаги действительно проверяют систему, разработка становится понятнее и детерминированнее. У команды есть не несколько пересказов одного требования, а один исполняемый пример ожидаемого поведения. Для меня это уже достаточно хорошая причина хотя бы попробовать выстроить вокруг BDD часть процесса.