21 июля 2026
Это справочник к разбору BDD для AI-агентов. Основная статья отвечает на вопрос «зачем BDD агенту»; здесь собрана механика «той стороны» Gherkin - как шаги сценария превращаются в исполняемые тесты pytest-bdd (декораторы и parsers, target_fixture, World fixture, теги-маркеры, async, doc strings и data tables), как устроена трассируемость scenario → step → production-код, и как то же самое выглядит на Ruby (Cucumber, Turnip). Держать это отдельно удобно: детали реализации не нужно искать в основной статье, а сюда можно возвращаться как к справочнику.
Mapping: как Gherkin-шаги превращаются в исполняемые тесты
Gherkin-файл сам по себе - текст. Чтобы pytest-bdd мог его исполнить, каждый шаг сценария должен быть связан с Python-функцией. Этот процесс называется step matching, или step binding. Понять механику стоит, потому что именно она определяет, как агент пишет код «на той стороне» Gherkin.
Декораторы и parsers
Step binding - это Python-функция, помеченная одним из трёх декораторов: @given, @when, @then. Декоратор принимает паттерн, которому должен соответствовать текст шага:
from pytest_bdd import given, when, then, parsers
# 1) Plain string - точное совпадение
@given("a clean tenant for this scenario")
def given_clean_tenant(db_session):
return create_tenant(db_session, name="test-tenant")
# 2) parsers.parse - cucumber-expression style с placeholders
@given(parsers.parse('a registered user "{email}"'),
target_fixture="user")
def given_registered_user(email: str, db_session):
return create_user(db_session, email=email)
# 3) parsers.re - полный regex с named groups
@when(parsers.re(r'the user opens the link within (?P\d+) minutes'))
def when_opens_link(minutes: str, world):
world.elapsed_minutes = int(minutes)
Три варианта матчинга, в порядке возрастания мощности:
- Plain string: точное совпадение.
@given("a tenant exists")сматчится только со строкой «Given a tenant exists». Простой, читаемый, без захвата параметров. parsers.parse(...)(наиболее распространённый): cucumber-expression style. Placeholders в фигурных скобках захватывают значения и передаются как named arguments. Базовый тип -str; встроенные форматы вроде:d(int) и:f(float) работают из коробки; для кастомных типов используетсяextra_types:from datetime import date @given(parsers.parse("an event scheduled for {when:iso_date}", extra_types={"iso_date": date.fromisoformat})) def given_event_date(when: date, world): world.event_date = whenparsers.re(...): полный regex с named groups. Используется, когдаparsers.parseне хватает (optional сегменты, alternations, сложные паттерны).
Когда pytest-bdd встречает шаг «Given a tenant 'acme-corp'», он перебирает все зарегистрированные binding-ы и выбирает тот, чей паттерн сматчился. Если матчей нет - поднимает StepDefinitionNotFoundError с указанием конкретного шага и файла. Если несколько - выбирает наиболее специфичный (предпочтение plain string > parsers.parse > parsers.re).
Ключевые слова шага (Given / When / Then) при матчинге не учитываются: декоратор определяется по тексту шага, а не по его ключевому слову. Это сделано намеренно - чтобы переиспользовать утилитные шаги на разных позициях в сценарии. Конкретный пример: один и тот же шаг the tenant has 3 users может встречаться и как setup (Given), и как assertion (Then) в разных сценариях:
Scenario: Listing users requires admin role
Given the tenant has 3 users
And the current user is "alice@example.com" with role "viewer"
When the user lists tenant members
Then the response is 403 forbidden
Scenario: Bulk import creates the right number of users
Given an empty tenant
When the admin imports a CSV with 3 rows
Then the tenant has 3 users # тот же binding, что и Given в первом сценарии
Обе строки the tenant has 3 users матчатся в одну Python-функцию:
@given(parsers.parse("the tenant has {count:d} users"))
@then(parsers.parse("the tenant has {count:d} users"))
def step_tenant_user_count(count: int, db_session, current_tenant):
actual = db_session.query(User).filter_by(tenant=current_tenant).count()
if step_is_given():
# setup: создать count пользователей
for i in range(count - actual):
create_user(db_session, tenant=current_tenant)
else:
# assertion: проверить, что их count
assert actual == count
Декоратор фиксирует семантический тип (Given - setup, When - action, Then - assertion), но не блокирует физический матчинг. Хороший паттерн - писать utility-шаги в form-е «<X> is in state <Y>» и навешивать на функцию все три декоратора, если она применима в любом контексте.
Передача данных между шагами: target_fixture
Один сценарий - несколько шагов, и им нужно делиться данными. Pytest-bdd решает это через target_fixture. Рассмотрим конкретный сценарий и его bindings.
Gherkin:
Scenario: User signs in with a fresh magic link
Given a registered user "alice@example.com"
When the user requests a magic link
Then the user receives an email with a one-time link
Bindings:
@given(parsers.parse('a registered user "{email}"'),
target_fixture="current_user")
def given_user(email: str, db_session):
return create_user(db_session, email=email)
@when("the user requests a magic link",
target_fixture="magic_link_request")
def when_request_link(current_user, identity_app):
# identity_app is a synchronous test client in this example
return identity_app.post(
"/auth/magic-link/request",
json={"email": current_user.email}
)
@then("the user receives an email with a one-time link")
def then_email_received(magic_link_request, sent_emails):
assert magic_link_request.status_code == 202
assert len(sent_emails) == 1
assert "/auth/verify?token=" in sent_emails[0].body
Что здесь происходит шаг за шагом:
- Given «a registered user "alice@example.com"». Pytest-bdd сматчил binding
given_user. Функция принимаетemail(из placeholder) иdb_session(стандартная service-фикстура изconftest.py). Возвращает объектUser. Декораторtarget_fixture="current_user"регистрирует это значение как фикстуру с именемcurrent_user. - When «the user requests a magic link». Сматчился binding
when_request_link. Он принимает аргументыcurrent_userиidentity_app. Pytest-механизм dependency injection видит, чтоcurrent_user- это фикстура (та самая, которую вернул предыдущий Given), иidentity_app- service-фикстура. Подставляет оба. Функция делает HTTP-запрос, возвращает Response.target_fixture="magic_link_request"регистрирует Response под этим именем. - Then «the user receives an email...». Сматчился binding
then_email_received. Принимаетmagic_link_request(фикстура из When) иsent_emails(отдельная service-фикстура для перехвата emails). Делает assertions.
Вся цепочка current_user → magic_link_request → sent_emails строится через стандартный pytest-механизм фикстур. Никакого глобального состояния, никакой мутации между шагами, никаких self.something. Каждый шаг - чистая функция: берёт фикстуры, возвращает фикстуру (или ничего), при этом следующий шаг видит результат через имя.
World fixture: per-scenario state bag
target_fixture покрывает большинство случаев, но три ситуации делают его неудобным:
- Накопление. Шаг публикует events в цикле и хочет их собрать в список.
target_fixtureможет вернуть только одно значение и только в момент завершения функции - накапливать в нём не получится без дополнительной обвязки. - Мутация существующего объекта. Шаг должен изменить уже существующую сущность (добавить поле в response, переключить флаг), а не вернуть новую.
target_fixtureзаточен под «вернул - зарегистрировал»; если предыдущий шаг что-то вернул и следующий должен это модифицировать, через target_fixture это не делается. - One-off данные. Шагу нужно передать соседнему шагу значение, для которого имя фикстуры заводить избыточно (один раз использовали - забыли).
Для всего этого используется World fixture. Это function-scoped mutable объект, который step-функции принимают параметром и изменяют по ходу сценария.
Определение в tests/bdd/steps/world.py:
from dataclasses import dataclass, field
from typing import Any
import pytest
@dataclass(slots=True)
class World:
tenant: Any = None
user: Any = None
last_response: Any = None
captured_events: list[Any] = field(default_factory=list)
extra: dict[str, Any] = field(default_factory=dict)
@pytest.fixture
def world() -> World:
return World()
Поля типизированы как Any намеренно: World не должен зависеть от production-типов (Tenant ORM-модель, FastAPI Response, recorded LLM cassettes). Step-модули кладут туда runtime-объекты любого типа; это test-инфраструктура, не доменная модель.
Использование в шагах - сценарий с накоплением событий:
Scenario: Downstream consumers receive every published event
Given a tenant with 3 active subscribers
When the system publishes 3 user-created events
Then downstream consumers receive all 3 events
@given("a tenant with 3 active subscribers")
def given_subscribers(world: World, db_session):
world.tenant = create_tenant(db_session)
world.subscribers = [create_subscriber(db_session, world.tenant) for _ in range(3)]
@when("the system publishes 3 user-created events")
def when_publish_events(world: World, event_bus):
for _ in range(3):
evt = event_bus.publish(UserCreatedEvent(tenant=world.tenant))
world.captured_events.append(evt) # накопление в существующий list
@then("downstream consumers receive all 3 events")
def then_consumers_receive(world: World, event_consumers):
expected = len(world.captured_events)
for consumer in event_consumers:
assert len(consumer.received) == expected
Что важно понимать про scope. Pytest fixture world объявлена без параметра scope, что означает дефолтный function scope: фикстура создаётся заново на каждый тест-функцию (в pytest-bdd это - один сценарий). Каждый сценарий получает свежий World(); сценарии не могут просочить состояние друг другу. Это критично для детерминизма: если бы World был scope="session", порядок выполнения сценариев влиял бы на результаты, и flaky-тесты появились бы из ниоткуда.
Поле extra - escape hatch для one-off данных. Если в сценарии нужно временное значение, для которого заводить именованное поле в dataclass-е избыточно, его кладут в world.extra["my_temp_value"] = .... Если такое значение начинает встречаться в нескольких сценариях - повышают в основной dataclass с типом и именем.
Ловушка: World легко переиспользовать там, где правильнее использовать target_fixture. Эвристика - если шаг ВОЗВРАЩАЕТ значение и следующий шаг его ЧИТАЕТ, это target_fixture; если шаг МОДИФИЦИРУЕТ что-то и следующий шаг работает с обновлённым состоянием, это world. Смешивать оба механизма в одном binding-наборе нормально - они дополняют друг друга, не конкурируют.
Теги становятся pytest-маркерами
Gherkin-теги при сборе сценариев pytest-bdd автоматически конвертирует в pytest.mark-маркеры. Bare-теги мапятся напрямую: @smoke становится pytest.mark.smoke, @integration - pytest.mark.integration. Это даёт стандартный pytest-механизм фильтрации:
# только smoke-сценарии (default PR fast path)
pytest -m smoke
# smoke + integration (когда подняты DB и Redis)
pytest -m "smoke or integration"
# исключить slow
pytest -m "not slow"
Keyed-теги (@spec:008, @user-story:008-3) сложнее: двоеточие в имени маркера ломает pytest-парсер. Стандартный приём - добавить в conftest.py конвертер, который заменяет : на _ и регистрирует маркер вида pytest.mark.spec_008:
def pytest_collection_modifyitems(config, items):
for item in items:
for marker in list(item.iter_markers()):
if ":" in marker.name:
key, value = marker.name.split(":", 1)
normalized = f"{key}_{value.replace('-', '_')}"
item.add_marker(getattr(pytest.mark, normalized))
После этого @spec:008 доступен через pytest -m spec_008, а @user-story:008-3 - через pytest -m user_story_008_3. Конкретное имя зависит от стиля нормализации, который выбрала команда.
Тег @flaky или @known-regression можно маппить в обработчик, который вызывает pytest.skip с pointer-сообщением о причине. Это даёт явную трассируемость отключённых сценариев без удаления их из .feature-файла.
Async-шаги
pytest-asyncio поддерживает асинхронные тесты и фикстуры, но из этого не следует автоматическое ожидание async step в pytest-bdd. Нужна явно проверенная интеграция для закрепленных версий. Ниже пример с синхронным тестовым клиентом.
@when('the user clicks "Sign in with Apple"', target_fixture="auth_response")
def when_apple_signin_clicked(apple_id, identity_app):
# identity_app is a synchronous test client in this example
return identity_app.post(
"/auth/apple/init", json={"email": apple_id.email}
)
Поддержку асинхронных шагов нужно проверить отдельным исполняемым сценарием на используемой связке плагинов. Само наличие asyncio_mode = auto не подтверждает ее.
Doc strings и data tables как аргументы шага
В текущем pytest-bdd doc string доступен через аргумент docstring, а таблица - через datatable. Это именованные аргументы, а не часть текста шага. Поведение и поддержку нужно проверять для установленной версии.
Doc string - triple-quoted блок, который попадает в step-функцию как str:
Scenario: Agent receives an organizer with multi-line instructions
Given a registered user "alice@example.com"
When the firm sends an organizer with the following intro:
"""
Привет! Это годовой organizer на 2026.
Пожалуйста, загрузите W-2 и 1099 формы до 15 февраля.
Если возникнут вопросы - отвечайте в этом же чате.
"""
Then the user receives a notification with that exact intro
@when("the firm sends an organizer with the following intro:")
def when_send_organizer(docstring, current_user, organizer_service):
organizer_service.send(user=current_user, intro_text=docstring)
Data table. Аргумент datatable содержит список строк, каждая из которых является списком ячеек. Если первая строка задает заголовки, словари для остальных строк можно построить через zip.
Scenario: Bulk-create users with various roles
Given the following users exist:
| email | role | tenant |
| alice@example.com | admin | acme-corp |
| bob@example.com | member | acme-corp |
| carol@example.com | viewer | beta-inc |
When the admin lists users for tenant "acme-corp"
Then exactly 2 users are returned
@given("the following users exist:")
def given_users(datatable, db_session):
headers = datatable[0]
for cells in datatable[1:]:
row = dict(zip(headers, cells))
create_user(db_session, **row)
Эти механизмы позволяют держать сложные test data inline в .feature-файле без вспомогательных fixture-файлов, что делает сценарий self-contained для PM (он видит данные прямо в шаге) и для агента (он видит контракт целиком, не через ссылки).
Что это даёт агенту
Когда агент видит .feature-файл с шагом, который ещё не забинден, у него детерминированный путь:
- Скопировать step text в
parsers.parse(...), заменив значения в кавычках на placeholders"{name}"; - Выбрать декоратор по semantic-типу шага (
@given/@when/@then); - Найти подходящие service-фикстуры из
tests/bdd/conftest.py(db_session,identity_app,messaging_dispatcher, ...); - Написать функцию, которая использует эти фикстуры и реализует шаг;
- При необходимости связать шаги через
target_fixtureили мутациюworld.
Этот путь алгоритмический и хорошо ложится на агентскую работу. Скилл /speckit-tasks может эмитить такой «рецепт» в [BDD] tasks: какие шаги уже забиндены и должны быть переиспользованы (с указанием конкретного tenant_steps.py / auth_steps.py файла), какие новые - с предложенной сигнатурой декоратора.
BDD как мостик к коду: traceability scenario → step → реализация
Принцип «один артефакт, два потребителя» работает только если от сценария можно дойти до кода. Если PM прочитал .feature и заинтересовался «а как это технически устроено» - ему нужен путь scenario → step binding → production-код. Если агенту дали задачу «исправь баг в шаге X» - ему нужен тот же путь. Несколько устоявшихся механизмов решают эту задачу.
IDE-навигация: Cmd-click по шагу
JetBrains-плагин Cucumber (для Java / JS / Ruby) и расширение cucumber/vscode резолвят step text при курсоре до функции step-definition: Cmd-click (на macOS) или Ctrl-click (Linux / Windows) переходит из .feature-файла прямо в Python-функцию с декоратором @given / @when / @then. Это базовый механизм, ради которого имеет смысл держать pytest-bdd, а не самодельный аналог - встроенная навигация в IDE окупает overhead BDD-стека сама по себе.
Caveat: cucumber-expression паттерны (parsers.parse) иногда не резолвятся плагинами надёжно из-за placeholder-синтаксиса; regex-паттерны (parsers.re) работают чаще. Для шагов, по которым важна стабильная навигация, безопаснее regex.
Snippets для отсутствующих шагов
Когда в .feature появляется новый шаг без binding, pytest-bdd при сборе сценариев бросает StepDefinitionNotFoundError с указанием конкретного шага и файла:
StepDefinitionNotFoundError: Step definition is not found:
When "the user uploads a CSV file with 1000 rows"
Line 23 in scenario "Bulk import" in features/admin/csv-import.feature
Это копи-пейст-готовая подсказка: имя шага и где он. Cucumber (Java / Ruby) идёт дальше - флаг --snippets печатает готовый шаблон step-definition с placeholder-параметрами:
When('the user uploads a CSV file with {int} rows') do |int|
pending # Write code here that turns the phrase above into concrete actions
end
Это закрывает цикл «новый сценарий → недостающий код» без интеллектуального угадывания: добавили Scenario:, прогнали тест, получили подсказку - где и что писать. См. Cucumber Step Definitions reference.
Теги как ссылки на спек и тикет
Теги @spec:008 и @user-story:008-3 - не только метаданные drift-валидатора. Они работают как явные ссылки в обе стороны:
@spec:008резолвится вspecs/008-close-registration/spec.mdчерез convention-based mapping; drift-валидатор это enforce-ит (правило V-SPEC-TAG).@user-story:008-3указывает на### User Story 3heading в этомspec.md.@JIRA-1234или@TICKET-1234(если интегрированы с трекером) - прямая ссылка на тикет. Cucumber for Jira идёт дальше: тег@tc:JIRA-1234биндит сценарий к Jira test case и синхронизирует статусы.
Из .feature-файла читатель (или агент) видит, какой спек ввёл этот сценарий, какая user-story из спека, какой тикет в трекере. Это даёт навигацию назад - к origin-у требования. См. Cucumber blog: how does BDD affect traceability.
Аннотации на production-коде
Менее распространённый, но мощный паттерн - аннотации на production-коде, ссылающиеся обратно на .feature:
from features_traceability import covers
@covers("auth/closed-registration.feature::New email signup returns 503")
def handle_signup_attempt(email: str) -> Response:
if not registration_open():
return Response(503, error="registration_closed")
...
Это вариация общего паттерна Cyrille Martraire «annotations as living documentation» из книги «Living Documentation» (Pearson, 2019): production-код несёт machine-readable ссылку на acceptance scenario, который он реализует. Для агента это даёт обратную навигацию: «найди реализацию сценария X» → grep по @covers("X") → конкретная функция.
Декоратор @covers - не canonical Python-аннотация; его пишут под проект (3-5 строк). Книга Martraire даёт общую идею «аннотации на коде как точка живой документации», а конкретный набор аннотаций каждая команда выбирает сама.
Allure-отчёты: scenario → tracker → код
Allure (test-reporter) даёт ещё один уровень trace: декораторы @TmsLink("TMS-456") и @Issue("BUG-1") на step-binding или сценарии превращаются в clickable links в HTML-отчёте. Если step-definition аннотирован @Issue("BUG-1"), отчёт показывает прямой переход в issue-tracker. Это инверсия предыдущей цепочки: не от .feature к коду, а от запущенного сценария - в источник требования. См. Allure TmsLink reference.
Что это даёт агенту
Когда задача приходит в форме «исправь баг в waitlist-флоу», цепочка для агента:
- Найти .feature-файл по capability.
features/auth/→closed-registration.feature. Имя capability в дереве - первая координата задачи. - Найти step-bindings для шагов сценария. Grep по step text в
tests/bdd/steps/auth_steps.py- либо ручной, либо черезpytest --collect-only -k "<scenario name>"(оно покажет, какие функции pytest-bdd вызывает). - Из binding-функции прочитать, какие production-классы / -функции она вызывает (
handle_signup_attempt,MagicLinkService.create, ...). Это - точки входа в production-код. - (Опционально) Если в проекте есть annotations
@covers(...)на production-коде, агент grep-ает по имени сценария и находит relevant production-код напрямую, минуя binding.
Это даёт агенту 3-4 алгоритмических шага вместо угадывания по grep по неструктурированной кодовой базе. Особенно полезно в монорепо с десятками сервисов: имя capability в features/ - первая координата, остальное навигируется через bindings и аннотации.
Каноничного paper-а или vendor-документации «BDD как агентский context-индекс» пока нет - это emerging practice, разбираемая в community-постах (Andy Knight, «BDD Gherkin Guidelines for AI»). Но базовые механизмы - IDE-навигация, snippets, тег-traceability, Martraire-аннотации - работают одинаково для человека и для агента, потому что они machine-readable по построению.
Ruby-эквиваленты: Cucumber и Turnip
Статья центрирована вокруг pytest-bdd, потому что Python - частая среда для AI-агентов. В Ruby / Rails-мире механика та же, но инструменты другие. Сам RSpec нативно .feature-файлы не читает; для Gherkin есть два устоявшихся gem-а.
Cucumber + cucumber-rails
Каноничный Gherkin-runner. Aslak Hellesøy сделал Cucumber в Ruby в 2008 году (порты на JS, Java, Python пришли позже). Отдельная task bin/rails cucumber, парсинг .feature-файлов, step definitions в features/step_definitions/. Шаг - обычный Ruby-метод:
Given('a registered user {string}') do |email|
@current_user = create(:user, email: email)
end
When('the user requests a magic link') do
post '/auth/magic-link/request', params: { email: @current_user.email }
end
Then('the user receives an email with a one-time link') do
email = ActionMailer::Base.deliveries.last
expect(email.body).to include('/auth/verify?token=')
end
Cucumber и RSpec живут рядом в одном проекте, не конкурируют. У Cucumber свой fixture-mechanism (Before / After hooks), но в Rails-проектах часто используют FactoryBot из RSpec-инфраструктуры внутри step definitions.
Turnip
Gem, который заставляет RSpec парсить .feature-файлы и исполнять их как RSpec-тесты. Если в проекте уже есть зрелая RSpec-инфраструктура (FactoryBot, Capybara, custom helpers, shared contexts), Turnip переиспользует её напрямую. Step definitions - это step "..." блоки в RSpec-helper-модулях:
module AuthSteps
step 'a registered user :email' do |email|
@current_user = create(:user, email: email)
end
step 'the user requests a magic link' do
post '/auth/magic-link/request', params: { email: @current_user.email }
end
step 'the user receives an email with a one-time link' do
email = ActionMailer::Base.deliveries.last
expect(email.body).to include('/auth/verify?token=')
end
end
RSpec.configure { |c| c.include AuthSteps, type: :feature }
Преимущество - единая инфраструктура. bin/rspec запускает и .feature-файлы, и обычные *_spec.rb-файлы. Coverage-отчёт собирается один; CI-конфигурация одна. Cucumber требует отдельную task и отдельный coverage merge.
На каком уровне работает
Step definition в обоих gem-ах - обычная Ruby-функция; вызывать изнутри можно что угодно:
- e2e / system specs - Capybara:
visit,click_link,fill_in. Самое естественное применение Gherkin. - request / controller specs -
get/postчерезActionDispatch::IntegrationTest. HTTP-контракт без браузера. - service specs - прямой вызов service-объектов:
MyService.new(args).call. Acceptance-критерии без HTTP-слоя. - model specs - ActiveRecord:
User.create, validations. Технически работает, но на этом уровне Gherkin-overhead обычно превышает пользу; проще plain-RSpec вspec/models/. - pytest-bdd: документация
Один .feature-файл комбинирует уровни в разных шагах: Given a user "alice" идёт в model-слой через FactoryBot, When alice opens the dashboard - в Capybara, Then she sees 3 active orders - Capybara или ActionDispatch. Выбор делается в step definition, не в .feature-файле. Это та же additive-стратегия: BDD-слой добавляется сверху unit / model-тестов как acceptance-контракт, не вместо них.
Отдельно стоит упомянуть gem rspec-given - он добавляет Given / When / Then DSL внутри обычных RSpec-блоков, но .feature-файлы не парсит. Это полезная стилевая конвенция для plain-RSpec, но не Gherkin: stakeholder его не читает, и принцип «один артефакт, два потребителя» не выполняется.
Какой выбрать
Эвристика: если в проекте уже есть зрелая RSpec-инфраструктура (FactoryBot, Capybara, shared contexts, custom matchers) - Turnip, чтобы не дублировать механизмы. Если стек RSpec-у не предусматривался изначально или его ещё нет - Cucumber: у него больше комьюнити-материалов и официальных доков по Gherkin-практикам.
В обоих случаях канонические правила из основной статьи про BDD (capability-первая организация, теги, drift-валидация, mapping-механика) применимы один-в-один - меняется только синтаксис step definition и конкретный runner.