Gherkin и pytest-bdd: как .feature превращается в исполняемые тесты

Reference-компаньон к статье про BDD для агентов: механика step binding, traceability scenario → step → код и Ruby-эквиваленты

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)

Три варианта матчинга, в порядке возрастания мощности:

Когда 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

Что здесь происходит шаг за шагом:

  1. 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.
  2. 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 под этим именем.
  3. 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 покрывает большинство случаев, но три ситуации делают его неудобным:

  1. Накопление. Шаг публикует events в цикле и хочет их собрать в список. target_fixture может вернуть только одно значение и только в момент завершения функции - накапливать в нём не получится без дополнительной обвязки.
  2. Мутация существующего объекта. Шаг должен изменить уже существующую сущность (добавить поле в response, переключить флаг), а не вернуть новую. target_fixture заточен под «вернул - зарегистрировал»; если предыдущий шаг что-то вернул и следующий должен это модифицировать, через target_fixture это не делается.
  3. 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-файл с шагом, который ещё не забинден, у него детерминированный путь:

  1. Скопировать step text в parsers.parse(...), заменив значения в кавычках на placeholders "{name}";
  2. Выбрать декоратор по semantic-типу шага (@given / @when / @then);
  3. Найти подходящие service-фикстуры из tests/bdd/conftest.py (db_session, identity_app, messaging_dispatcher, ...);
  4. Написать функцию, которая использует эти фикстуры и реализует шаг;
  5. При необходимости связать шаги через 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-валидатора. Они работают как явные ссылки в обе стороны:

Из .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-флоу», цепочка для агента:

  1. Найти .feature-файл по capability. features/auth/ → closed-registration.feature. Имя capability в дереве - первая координата задачи.
  2. Найти step-bindings для шагов сценария. Grep по step text в tests/bdd/steps/auth_steps.py - либо ручной, либо через pytest --collect-only -k "<scenario name>" (оно покажет, какие функции pytest-bdd вызывает).
  3. Из binding-функции прочитать, какие production-классы / -функции она вызывает (handle_signup_attempt, MagicLinkService.create, ...). Это - точки входа в production-код.
  4. (Опционально) Если в проекте есть 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-функция; вызывать изнутри можно что угодно:

Один .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.

Связанные материалы: BDD для AI-агентов · Spec Kit · SDLC для AI-агентов.