BDD для AI-агентов

Почему практика 2006 года про разговоры между PM, QA и инженером оказалась подходящим форматом для работы с агентом

9 мая 2026

BDD создавался Дэном Нортом в 2006 году как способ сократить разрыв между бизнесом и инженерами через единый артефакт: текст, который человек читает, а тестовый раннер исполняет. Сам Cucumber-проект формулирует это прямо: «documentation and automated tests are produced by a BDD team, you can think of them as nice side-effects. The real goal is valuable, working software». Когда между PM и кодом появляется AI-агент, эта двойная природа Gherkin-файла начинает работать ещё в одном направлении: тот же файл, что читает PM на ревью, и тот же, что исполняет тестовый раннер, лежит в контексте агента, когда он генерирует код. Статья разбирает три практики BDD (discovery / formulation / automation) применительно к работе с агентом, формат Gherkin-файла, направления drift-валидации и end-to-end pipeline.

Тезис: BDD - формат, в котором PM, инженер и агент читают одно и то же

BDD появился задолго до AI-агентов. Дэн Норт в статье 2006 года «Introducing BDD» переписал TDD в терминах поведения, потому что слово «тест» сбивало команды с толку: люди думали, что пишут тесты, на самом деле они описывали ожидаемое поведение системы. Cucumber и Gherkin закрепили формат: текст, который читает stakeholder, и тот же текст, который исполняет тестовый раннер.

Сам формат намеренно прост. В Gherkin около десяти ключевых слов, и они образуют декларативную грамматику с фиксированным порядком:

Ключевое слово Назначение
Feature:Заголовок документа, одна на файл
Background:Общие предусловия, выполняются перед каждым сценарием в файле
Rule:Группировка сценариев под общим бизнес-правилом (Gherkin 6+)
Scenario: или Example:Один тест-кейс
Scenario Outline: + Examples:Параметризованный сценарий с таблицей данных
GivenИсходное состояние («система в таком-то состоянии»)
WhenСобытие или действие («пользователь делает X»)
ThenОжидаемое наблюдаемое последствие
And, ButПродолжение цепочки шагов того же типа
*Звёздочка как замена любому шаговому ключевому слову (для bullet-стиля)
@-тегиМетаданные сценария (приоритет, scope, owner, контрибутирующий спек)
# ...Комментарии
"""...""" или ```...```Doc strings - multi-line аргумент шага (str)
| a | b |Data tables - табличный аргумент шага

Простота - фича, а не недостаток. PM, который никогда не писал код, читает Gherkin без обучения. Инженер, который никогда не работал с этой capability, понимает контракт за минуту. Агент с любым front-end LLM-движком парсит файл без специальной настройки. Учить специалистов «читать .feature-файлы» не нужно.

При этом грамматика составная. Background + Scenario Outline + Examples + теги + doc strings + data tables в одном файле дают достаточную выразительность для любого слоя acceptance-тестирования: pure-unit-стиль (один Given, один When, один Then), интеграционный стиль (multi-step Given для setup, табличные Examples для матрицы кейсов), end-to-end стиль (Background для общего контекста + цепочка When/Then через нескольких акторов или этапов - спорная практика, см. ниже).

Канонический минимальный сценарий выглядит так:

Feature: Magic-link sign-in

  Scenario: User signs in with a fresh magic link
    Given a registered user "alice@example.com"
    When the user requests a magic link
     And the user opens the link within 15 minutes
    Then the user is signed in

В большом проекте структура раскладывается на несколько уровней. Один .feature-файл соответствует одному «slice» функциональности (например, magic-link sign-in или csv-export). Один каталог - одной capability (auth, agents, tools, ...). Все капабилити лежат в общем top-level дереве features/. Внутри файла:

Полный пример - закрытая регистрация (invite-only mode) с шапкой, feature-level тегами и тремя сценариями:

# Capability: auth
# Contributing specs:
#   - 008-close-registration            (introduces the kill-switch)
#   - reference/auth-flows              (cross-cutting auth invariants)

@capability:auth @spec:008 @spec:reference-auth-flows
Feature: Closed registration (waitlist mode)
  As a product team operating in invite-only mode
  I want public signup blocked with a clear waitlist response
  So that we can flip the registration kill-switch in under 5 minutes

  Background:
    Given a clean tenant for this scenario

  @spec:008 @user-story:008-1 @smoke @p1
  Scenario: Landing CTA shows waitlist label when registration is closed
    Given the registration kill-switch is on
    When an anonymous visitor opens the landing page
    Then the primary CTA reads "Join the waitlist"

  @spec:008 @user-story:008-3 @integration @p1
  Scenario: New email signup returns 503 with registration_closed error
    Given the registration kill-switch is on
    When a new email signup is attempted with email "bob@example.com"
    Then the response is 503 with body field error="registration_closed"
     And no magic-link token is created
     And no email is sent

  @spec:008 @user-story:008-2 @smoke @p1
  Scenario: Existing user's magic link still works when registration is closed
    Given the registration kill-switch is on
     And a registered user "alice@example.com"
    When the user requests a magic link
    Then the user receives an email with a one-time link
     And the user can sign in with that link

Те же ключевые слова работают на любом уровне - от одного inline-теста до 40-сценарийной capability с feature-level Background. Это и есть «композиция»: формат не растёт сложнее по мере роста проекта; добавляются только количество файлов, тегов и капабилити.

Один нюанс, на котором BDD-сообщество расходится. Канонический Gherkin предписывает один When на сценарий: «Describe an initial context (Given steps), describe an event (When steps), describe an expected outcome (Then steps)». Жёсткая интерпретация - «one scenario, one behavior» - отстаивается, например, Andrew Knight (Automation Panda); по его терминологии это «cardinal rule of BDD», а длинные When/Then цепочки в imperative UI-сценариях («open browser - click - check - click - check») - явный антипаттерн.

Liz Keogh, одна из учеников Дэна Норта и активных BDD-практиков, явно опровергает жёсткое правило: бывают сценарии, в которых смысл - именно в последовательности нескольких событий или взаимодействии нескольких акторов. Её каноничный пример из «On multiple Givens, Whens, and Thens»:

Given Clare is editing trade 12345
When Stephen edits and saves that trade
 And Clare tries to save that trade
Then she should be told that Stephen has already edited it.

Здесь два разных When отражают двух акторов; смысл сценария - в их временном чередовании, а не в одном изолированном событии. Сводя такой сценарий к одному When, теряется контракт - именно конкуренция и порядок действий проверяются.

Безопасный baseline: один When на сценарий, как рекомендует Cucumber-документация и большинство руководств по «better Gherkin». Цепочка When/Then используется как осознанное исключение в трёх контекстах - end-to-end-сценарии (по терминологии Knight это допустимое исключение), race-condition-проверки (Liz Keogh), API-flow с несколькими цепными вызовами. Если сценарий распадается на «click → check → click → check → click → check» - это сигнал, что его стоит разбить на несколько отдельных Scenario:.

Те же восемь-десять строк сценария работают как PRD для PM, контракт для QA и исполняемый тест для CI. Когда между PM и кодом появляется AI-агент, файл начинает работать ещё в одном качестве - как контекст, который определяет границы сгенерированного кода.

Причина - в том, как агент работает с контекстом. Агент не помнит вчерашнего разговора между PM и инженером. Он не догадывается, что фраза «онбординг» в задаче означает «регистрация + welcome-email + первичная настройка профиля». Он работает с тем, что записано в репозитории. Если запись «как должно быть» (PRD в Confluence) и запись «как проверяется» (тесты в репо) - два разных файла на двух разных платформах, агент будет держать в контексте одну из них и игнорировать другую. Дрейф между ними агент не увидит, потому что он не делает Confluence cross-check на каждый запрос.

Gherkin-файл, лежащий в репозитории, читаемый PM и исполняемый pytest-bdd, делает эту проблему ненаблюдаемой по построению: один источник, одна проверка, один артефакт в Pull Request.

Три практики BDD по cucumber.io: discovery, formulation, automation

Cucumber-документация разбирает BDD на три практики:

Когда в команде появляется агент, эти три практики не отменяются - они перераспределяются.

Discovery остаётся за людьми

Discovery - это набор практик про разговор между бизнесом и инженерами на конкретных примерах. Cucumber-проект и BDD-канон называют три устоявшихся формата:

  1. Three amigos meeting - встреча трёх ролей: PM (или business analyst), инженер, QA. Каждая роль приносит свой угол: PM знает intent, инженер знает constraints, QA знает граничные случаи. Цель - найти расхождения в понимании до того, как они станут багами. Длительность 30-60 минут на одну user story.
  2. Example mapping (Burton, 2015; canonical Cucumber-практика) - структурированная сессия на 25-30 минут вокруг одной user story. Используются карточки четырёх цветов:
    • жёлтая - сама user story сверху карты;
    • синие - acceptance criteria (бизнес-правила, которые должны выполняться);
    • зелёные - конкретные примеры, иллюстрирующие каждое правило;
    • красные - открытые вопросы, на которые в этой сессии нет ответа.
    Сессия заканчивается, когда группа удовлетворена scope-ом story, либо когда красных карточек становится больше зелёных - в этом случае story считается недостаточно проработанной для имплементации и возвращается в backlog для дополнительного research.
  3. Specification by example (Adzic, 2011) - общее название для практики, в которой acceptance criteria формулируются через конкретные примеры, а не абстрактные требования. Example mapping - одна из конкретных техник под этим зонтиком.

Ожидаемый артефакт discovery-сессии - не Gherkin (это уже formulation, следующий шаг). Артефакт - это структурированная заметка с тремя секциями:

В большинстве команд этот артефакт фиксируется в Notion / Miro / Confluence-странице или в issue-трекере как комментарии к story. Формальный шаблон не критичен; критичны три секции - правила, примеры, вопросы.

Этот артефакт затем превращается в Gherkin на стадии formulation: каждое бизнес-правило (синяя карточка) часто становится группой сценариев в .feature-файле; каждый конкретный пример (зелёная карточка) - отдельным Scenario: или строкой в Examples: у Scenario Outline.

Агент в discovery не участник:

Discovery - human checkpoint в любой системе с агентом. Можно использовать агента как scribe (записывать карточки во время сессии, переводить notes в structured format) - но не как amigo, предлагающего содержимое.

Formulation становится human-AI циклом

После discovery остаётся структурированная заметка: rules + examples + open questions. Formulation превращает её в Gherkin - бизнес-правила группируют сценарии, конкретные примеры становятся отдельными Scenario: или строками в Examples:-таблице у Scenario Outline. Это формальная переработка артефакта в исполняемый контракт.

Шаги цикла, когда в команде есть агент:

  1. Человек инициирует scaffolding. В spec-driven workflow это команда вроде /speckit-specify "users can sign in with their Apple ID". Скилл пишет spec.md на основе discovery-артефакта, сканирует его на capability cues («sign in» → auth), выбирает целевой путь features/auth/apple-signin.feature, вызывает scaffolder.
  2. Scaffolder создаёт скелет. На выходе - .feature-файл с обязательной шапкой (# Capability:, # Contributing specs:), feature-level тегами, Background:, и одним Scenario: на каждый acceptance summary из spec.md. Шаги пока шаблонные: Given [initial state], When [action], Then [expected outcome] - это явные TODO, которые нужно заполнить.
  3. Агент достраивает черновик. Имея на руках discovery-артефакт (rules + examples) и шаблон сценария, агент заполняет конкретные Given/When/Then через бизнес-язык. Здесь агент эффективен по трём причинам: формальный язык с фиксированной грамматикой, правила именования (placeholder в кавычках, lowercase бизнес-термины), переиспользуемые шаги из существующих файлов в той же капабилити (агент сканирует tests/bdd/steps/auth_steps.py и предлагает шаги, которые уже забиндены).
  4. Человек ревьюит контракт. Это core-checkpoint фазы. Что проверяется:
    • Уровень абстракции. Шаги пишутся бизнес-языком (When the user requests a magic link), а не именами внутренних API (When messaging_dispatcher.handle returns ok). Если агент сполз в имплементационные детали - возвращается на доработку.
    • Покрытие правил. Каждое синее правило из discovery-артефакта должно быть представлено хотя бы одним сценарием. Если агент сгенерировал 5 сценариев на 7 правил - проверить, какие два пропущены и почему. Часто пропускают правила, которые сформулированы как negative requirement («система не должна X»).
    • Edge cases. Happy path - дефолт LLM-генерации. Агент часто пропускает unhappy paths (что если токен expired? что если backend вернул 503? что если concurrent request?). Зелёные карточки из discovery именно для этого - человек сверяется со списком и просит дописать недостающие.
    • Теги. Каждый сценарий несёт scope-тег (@smoke/@integration/@e2e), priority-тег (@p1/@p2/@p3), @spec:<id> и @user-story:<id>-<n>. Если scope или priority не очевидны из контекста - агент часто проставляет дефолт @smoke @p1; человек корректирует, ориентируясь на нагрузочный профиль (требует ли сценарий DB / Redis - @integration; полный pipeline - @e2e).
    • End-user текст verbatim. Если в продукте есть user-facing strings (bot-сообщения, email subjects, UI labels), они должны попадать в шаги дословно, в кавычках, как данные. Агент иногда переводит / перефразирует / транслитерирует их - возвращается на дословное цитирование.
    • Структура When/Then. Если агент сгенерировал длинную When → Then → When → Then цепочку - проверить, действительно ли смысл сценария в этой последовательности (как у Liz Keogh с двумя акторами), или это императивный UI-скрипт, замаскированный под BDD-сценарий, и его надо разбить на несколько отдельных Scenario:.
  5. Итерация. Если ревью нашёл проблемы, человек указывает агенту конкретные правки: «у Scenario 3 шаг 2 - на уровне реализации, переформулируй»; «добавь сценарий для concurrent edit (rule 4)»; «помечай @e2e вместо @smoke, требуется DB и Redis». Агент применяет, человек ревьюит снова. На capability средней сложности 1-3 итерации обычно достаточно.
  6. Финал: ## Feature files touched. После approve скилл дописывает в spec.md секцию со ссылкой на features/auth/apple-signin.feature и закрывает фазу formulation. Спек переходит на /speckit-plan с прогоном validate_drift --spec NNN.

Что отличает этот цикл от прямой генерации Gherkin агентом без human-checkpoint - именно review-стадия. Discovery-артефакт - источник правды, против которого ревьюер сверяет агентский draft. Без него ревьюер сверяет «звучит ли разумно», и LLM, оптимизированный под звучать разумно, проходит проверку с любым контентом.

Automation - территория агента

Step bindings - это Python-функции с декораторами @given / @when / @then. Они работают как обычный pytest-код: используют существующие service-фикстуры (db_session, identity_app, recorded_llm), вызывают production-код, проверяют результаты.

Чтобы заполнять bindings, нужно знать, какие шаги уже забиндены, а какие появились в новом сценарии и требуют новой функции. Эта информация извлекается двумя способами:

  1. Pytest collection. pytest --collect-only для BDD-сьюта проходит все .feature-файлы и пытается зарезолвить каждый шаг в существующий binding. Когда binding не найден, pytest-bdd бросает StepDefinitionNotFoundError с указанием конкретного шага и файла. Это динамический механизм - он отвечает на вопрос «что не забиндено прямо сейчас» точно, без эвристики.
  2. Static AST scan. Скрипт обходит tests/bdd/steps/*.py, собирает все вызовы @given(...) / @when(...) / @then(...) через AST-парсер, извлекает паттерны декораторов (plain string или parsers.parse argument). На выходе - список «вот все pattern-ы, к которым уже привязан код, и в каком файле / функции». Это статический механизм - он отвечает на вопрос «что у нас в принципе есть» без запуска тестов.

Комбинация этих двух механизмов даёт полную картину: список бинденных шагов (AST-скан) минус шаги в новом сценарии (collection failure) = шаги, которые надо написать.

На основе этой картины spec-driven скилл (например, /speckit-tasks) формирует задачи. Для каждого нового сценария он эмитит одну [BDD] wiring-task, в которой каждый шаг помечен либо «reuse» (если найден в AST-скане - с указанием конкретного файла и функции), либо «NEW» (если не найден):

- [ ] T010 [US1] [BDD] Wire scenario "User signs in with valid Apple ID"
  in features/auth/apple-signin.feature (@spec:017 @user-story:017-1).
  Steps to bind:
  - Given an Apple ID with email "{email}" → NEW: add to tests/bdd/steps/auth_steps.py
  - And a clean tenant → reuse tests/bdd/steps/tenant_steps.py:given_clean_tenant
  - When the user clicks "Sign in with Apple" → NEW: add to tests/bdd/steps/auth_steps.py
  - Then a JWT is issued with the user's email claim → NEW: add to tests/bdd/steps/auth_steps.py
  Acceptance: pytest tests/bdd/features/test_auth.py -k "User signs in with valid Apple ID" is green.

Этот формат - готовый промт для агента. Reuse-шаги дают сигнатуру существующего binding-а, который агент должен вызывать (или подсмотреть в нём паттерн). NEW-шаги дают конкретный файл, в который надо добавить новую функцию. Агент пишет код, прогоняет pytest -k "...", видит зелёный сценарий - закрывает task. Discovery остался за человеком, formulation прошёл human review, automation идёт по детерминированному pipeline.

Drift как корневая проблема, которую BDD решает заранее

Один из дизайн-выборов BDD: вынести спецификацию и проверку в один файл вместо нескольких артефактов в разных системах. Это инженерное решение - не drama про «дрейфующие источники правды», а просто способ построить процесс так, чтобы дрейф между PRD, тестами и кодом не возникал по построению. Если acceptance criteria и acceptance test - один файл, между ними нет промежуточного шага «синхронизировать», на котором всё ломается.

Это даёт три полезных свойства:

  1. Stakeholder читает контракт без перевода. PM, спрашивающий «что продукт делает сегодня», открывает features/<capability>/<name>.feature и читает прозу. Не нужно ходить в репозиторий за тестами и не нужно звать инженера для перевода.
  2. Спецификация и тест меняются одним PR. Когда поведение меняется, меняется один файл. Сценарий не становится «историческим документом» - он лежит там, где идут изменения.
  3. Acceptance scenarios не дублируются. Не нужно писать отдельный набор Python-тестов, которые перекодируют acceptance scenarios из spec.md в test-language - сценарий уже исполняемый.

В работе с агентом это особенно полезно. Агент работает в цикле «прочитал контекст - сгенерировал patch - запустил тесты». Если контекст и тесты - один файл, цикл замкнут: «зелёные тесты» и «контракт выполняется» означают одно и то же. Если контекст лежит в Confluence, а тесты в репозитории, цикл размыкается - агент будет генерировать patch, который проходит тесты, но не обязательно соответствует контракту в Confluence, и не заметит этого.

Insight: один артефакт, два потребителя

Принцип, на который сходится Cucumber-документация и большинство практических реализаций: артефакт, который читает stakeholder, чтобы понять текущее поведение, и артефакт, который исполняет тестовый раннер, чтобы это поведение проверить, должны быть физически одним файлом. Если они разойдутся, drift вернётся.

«Физически одним» - не метафора. Один файл features/auth/closed-registration.feature лежит в репозитории. PM открывает его на GitHub и читает прозу. CI запускает pytest, который через pytest-bdd парсит тот же файл и исполняет шаги. PR-ревьюер видит diff на тот же файл, когда меняется поведение. Других мест нет.

Это накладывает ограничения на авторов:

Что это меняет для агента:

Это не отменяет человеческое ревью - discovery остаётся за людьми, как обсуждалось выше. Но всё, что между discovery и production-кодом, сводится в один файл.

Опорная архитектура BDD-стека

На уровне дерева репозитория Python-проекта типичный BDD-стек выглядит так:

features/                     # current-state контракты, top-level
├── auth/
│   ├── magic-link.feature
│   ├── webauthn.feature
│   ├── jwt-issuance.feature
│   └── closed-registration.feature
├── agents/
│   ├── agent-loop.feature
│   └── agent-tuning.feature
├── tools/
│   ├── doc-spec-matching.feature
│   ├── csv-export.feature
│   └── ...
├── platform/
│   ├── state-engine.feature
│   └── ...
└── ...

tests/bdd/                    # step bindings + execution glue
├── conftest.py               # shared fixtures, tag → marker, V-TAG enforcement
├── steps/
│   ├── world.py              # World dataclass + per-scenario fixture
│   ├── tenant_steps.py
│   ├── agent_steps.py
│   ├── fsm_steps.py
│   ├── tool_steps.py
│   ├── channel_steps.py
│   ├── llm_steps.py
│   └── bdd_meta_steps.py
└── features/
    ├── conftest.py
    ├── test_auth.py          # 5 строк: scenarios("auth")
    ├── test_agents.py
    └── ...

tools/bdd/                    # production-grade тулинг
├── parser.py                 # Gherkin AST → typed dataclasses
├── render.py                 # walks features/ → manifest.json + markdown
├── scaffold.py               # creates / amends .feature files
└── validate_drift.py         # three-direction drift validator

specs/                        # decision history (immutable once shipped)
├── 001-initial-feature/
│   ├── spec.md               # contains "Feature files touched" section
│   ├── plan.md
│   └── tasks.md
└── ...

Ключевое архитектурное решение: features/ физически не лежит под specs/. Это два разных дерева с разной семантикой:

Дерево Роль Изменчивость Аудитория
specs/ Decision history - почему изменение сделано, когда, что решено Append-mostly; spec-и не меняют после shipping Инженеры, читающие историю проекта
features/ Actual current state - что система делает сегодня, по capability Меняется в том же PR, что и поведение PM, QA, инженеры

Жёсткое правило (V-LOCATION): .feature-файл не имеет права лежать где-либо под specs/. Связь двусторонняя plain-text:

Развязка важна, потому что capability живёт дольше любого отдельного спека. Спек 008 «закрытая регистрация» добавил сценарии в features/auth/closed-registration.feature. Спек 014 через год добавит в тот же файл сценарии про rate-limiting. Capability auth - константа. Спеки - timeline.

Для агента это означает: когда задача приходит в контексте «исправь баг в waitlist», агент идёт в features/auth/closed-registration.feature, а не пытается восстановить актуальное поведение по chronological order спеков 008-014.

Что писать в .feature: канонический Gherkin + операционные расширения

Cucumber-документация определяет минимальный синтаксис: Feature (заголовок документа), Scenario (один тест-кейс), Given (исходное состояние), When (действие или событие), Then (наблюдаемое последствие), And / But (продолжение цепочки), Background (общий контекст для всех сценариев в файле), Scenario Outline + Examples (один сценарий, прогнанный по таблице данных), теги (@-префикс для метаданных).

Минимальный пример:

Feature: Magic-link sign-in

  Background:
    Given a clean tenant for this scenario

  @smoke @p1
  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
    When the user opens the link within 15 minutes
    Then the user is signed in
     And a JWT is issued with the user's email claim

Поверх канонического Gherkin полезно зафиксировать несколько операционных правил, которые превращают .feature-файл из теста в контракт.

Капабилити-первая организация, не сервис-первая

Капабилити - то, как продукт-менеджер называет область продукта, а не то, как инженеры разбили код на сервисы. auth/ (капабилити) грубо соответствует services/identity/ (сервис), но это не одно и то же: invariants под features/auth/ описывают user-facing поведение, не внутреннюю архитектуру. Если завтра identity-сервис разрежут на два, features/auth/ не двинется.

Заголовок файла - часть контракта

Полезный паттерн - обязательная шапка файла:

# Capability: auth
# Contributing specs:
#   - 008-close-registration            (introduces the kill-switch)
#   - reference/auth-flows              (cross-cutting auth invariants)
# Living docs: docs/bdd/per-file/auth-closed-registration.md

@capability:auth @spec:008 @spec:reference-auth-flows
Feature: Closed registration (waitlist mode)
  ...

# Capability: и # Contributing specs: - не комментарии, а данные для drift-валидатора. Их формат проверяется парсером.

Whitelist тегов

Каждый сценарий обязан нести минимум: scope-тег (@smoke / @integration / @e2e), priority-тег (@p1 / @p2 / @p3), хотя бы один @spec:<id>, и @user-story:<NNN>-<N>, если контрибутирующий спек имеет user stories. Whitelist:

Тег Форма Назначение
@smoke bare Быстрый PR-чек (default)
@integration bare Нужны DB / Redis
@e2e bare Полный pipeline
@slow bare Исключается из PR fast path
@flaky, @known-regression bare Требуют # tracking: коммента
@p1, @p2, @p3 bare Приоритет
@capability:<slug> keyed Capability-маркер на уровне Feature
@spec:<id> keyed Контрибутирующий спек, валидируется
@user-story:<NNN>-<N> keyed Линк на User Story из spec.md
@owner:<team> keyed Информационный, фильтр в living-doc

Неизвестный тег ломает pytest-collection. Это ловит опечатки (@intgration) до того, как тест запустится.

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")
async def when_request_link(current_user, identity_app):
    return await 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 в режиме asyncio_mode = "auto" (стандартная конфигурация для async-проектов на FastAPI / Starlette / aiohttp) автоматически awaits любую async def step-функцию:

@when('the user clicks "Sign in with Apple"', target_fixture="auth_response")
async def when_apple_signin_clicked(apple_id, identity_app):
    return await identity_app.post(
        "/auth/apple/init", json={"email": apple_id.email}
    )

Никакой специальной разметки - pytest-bdd видит coroutine и await-ит её как обычный async test. Sync и async шаги могут смешиваться в одном сценарии без ограничений.

Doc strings и data tables как аргументы шага

Когда шагу нужен большой блок текста или таблица, используются doc strings и data tables - стандартный Gherkin-механизм, который pytest-bdd прокидывает как последний аргумент step-функции.

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(parsers.parse("the firm sends an organizer with the following intro:\n{intro}"))
def when_send_organizer(intro: str, current_user, organizer_service):
    organizer_service.send(user=current_user, intro_text=intro)

Data table - pipe-delimited таблица, которая приходит в step-функцию как объект DataTable со встроенными методами для конвертации в list[dict] (as_dicts()) или плоский list[list[str]]. Точное имя класса и его import-path зависят от версии pytest-bdd; в production-коде стоит свериться с актуальной документацией перед использованием.

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(parsers.parse("the following users exist:\n{table}"))
def given_users(table, db_session):
    rows = table.as_dicts()  # [{"email": "...", "role": "...", "tenant": "..."}, ...]
    for row in rows:
        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-практикам.

В обоих случаях канонические правила из статьи (capability-первая организация, теги, drift-валидация, mapping-механика) применимы один-в-один - меняется только синтаксис step definition и конкретный runner.

Three-direction drift validator как операционный gate

Главный кусок инфраструктуры, который превращает features/ в живой артефакт - валидатор дрейфа. Он смотрит в три направления и срабатывает в трёх местах: pre-commit hook, CI, автономный PR-reviewer-агент.

Direction A: spec → features

Когда spec.md меняется (добавили User Story, переписали Acceptance Scenarios), но листинг feature-файлов не меняется - спек описывает поведение, которое контрактом не зафиксировано.

Правило Что ловит
V-SPEC-LIST В spec.md нет секции ## Feature files touched
V-SPEC-LIST-EXISTS Путь, перечисленный в этой секции, не существует
V-USER-STORY ### User Story N в спеке без матчащего @user-story:<NNN>-<N> тега ни в одном feature-файле

Direction B: features → spec

Когда .feature-файл указывает на несуществующий спек или вовсе не имеет контрибутирующих спеков.

Правило Что ловит
V-LINK # Contributing specs: ссылается на id, которого нет в specs/
V-ORPHAN .feature без контрибутирующих спеков (warning)

Direction C: code → features

Когда production-код меняет externally-observable поведение, но ни один feature-файл не тронут.

Правило Что ловит
V-CODE-DRIFT PR трогает behaviour-affecting пути под services/*/src/, libs/*/src/, apps/*/src/ без изменения .feature-файлов и без лейбла bdd-skip:no-behaviour-change

Direction C обычно делегируют автономному PR-reviewer-агенту, который читает diff: программная классификация «behaviour-affecting vs refactor» нетривиальна, а агент с доступом к diff справляется. Reviewer пишет в комментарий PR-а структурированный отчёт:

## BDD Spec Coverage

- ✅ No .feature files under specs/ (location rule honoured)
- ✅ specs/017-apple-signin/spec.md lists "Feature files touched"
- ✅ features/auth/apple-signin.feature has @spec:017 in its header
- ✅ Scenario "User signs in with valid Apple ID" tagged @smoke @p1 @spec:017 @user-story:017-1
- ⚠️ Direction C: production code change without features/ update
  - Modified: services/orchestration/src/.../doc_matcher.py
  - No .feature change in the PR
  - Resolve: add a scenario, or apply bdd-skip:no-behaviour-change
- ✅ Coverage gate at 100%

Verdict: REQUEST CHANGES

Кросс-катящие правила

Эти правила срабатывают независимо от направления drift-а - они проверяют целостность самого формата и метаданных, а не связи между деревьями.

V-LOCATION - .feature-файл где-либо под specs/. Это hard-rule из дизайна стека: spec-и - decision history, features - current state, и они не должны смешиваться. Если кто-то по ошибке создал specs/008-close-registration/features/closed-registration.feature, валидатор это ловит и блокирует merge. Без этого правила при росте проекта неизбежно появятся два дерева feature-файлов, между которыми поедет drift.

V-PII - реальный email, телефон или bot-token в шагах сценария. .feature-файлы лежат в публичном (или хотя бы общедоступном для всей компании) репозитории; PII в них - regulatory-проблема (GDPR / 152-ФЗ / CCPA). Реализация - regex-сканирование шагов на паттерны вроде \d{10,15} для телефонов, [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,} для email-ов кроме whitelisted доменов (@example.com, @test.local), \d{6,}:[A-Za-z0-9_-]{30,} для Telegram bot-token-ов. Cost фалшивого срабатывания - переименовать значение в плейсхолдер; cost пропуска - regulatory finding.

V-TAG - тег не в проектном whitelist. Whitelist живёт в одном месте (например, tests/bdd/conftest.py + tools/bdd/validate_drift.py, оба должны быть синхронизированы) и фиксирует допустимые scope, priority, и keyed-теги. Это ловит опечатки (@intgration вместо @integration) и неавторизованные нововведения (один разработчик решил завести @critical - валидатор просит сначала добавить в whitelist).

V-SPEC-TAG - @spec:<id> не резолвится в реальный spec dir. Тег @spec:008 предполагает, что в specs/ есть директория 008-*. Если её нет (опечатка, спек был удалён, или ссылка на ещё-не-созданный спек) - валидатор ругается. Это симметрично к V-LINK в направлении B (Direction B ловит ссылки в шапке файла, V-SPEC-TAG - в feature-level и scenario-level тегах).

V-LANG - # language: <X> с X != "en". Если команда выбрала английские Gherkin keywords как стандарт (см. антипаттерны), это правило enforce-ит выбор валидатором. Конкретный X может быть ru, de, fr - V-LANG срабатывает на любой не-en. Команды, выбравшие русский Gherkin, инвертируют правило.

PARSE-ERROR - .feature не парсится Gherkin-парсером. Это catch-all для случаев, когда файл синтаксически сломан (отсутствует Feature:, кривое отступление в Scenario Outline, незакрытая doc string). Срабатывает раньше всех остальных правил - если файл не парсится, проверять его содержимое бессмысленно.

Где это срабатывает

Pre-commit hook лучше делать advisory: инженер видит дрейф до push, но не оказывается заблокирован настолько, чтобы хотеть выключить hooks вообще. CI блокирует - exit code 1 на любой error-level finding. PR-reviewer-агент пишет вердикт в PR-комментарий.

Для агента это означает три независимых места, где он не пропустит обновление контракта: pre-commit покажет ему diff drift до push; CI скажет ему то же самое после push; PR-reviewer сделает финальную проверку с человеком в петле.

End-to-end жизненный цикл с агентом

Сшивание выглядит так на примере новой фичи «sign in with Apple ID» в spec-driven workflow.

Шаг 1: /speckit-specify

/speckit-specify "users can sign in with their Apple ID". Скилл пишет specs/017-apple-signin/spec.md с user stories, scope, requirements. Затем сканирует описание на capability cues (cue «sign in» → auth), предлагает целевой путь features/auth/apple-signin.feature, вызывает scaffolder. Scaffolder создаёт файл с шапкой, feature-level тегами, Background и одним Scenario: на каждый acceptance summary, отмеченным @spec:017 @user-story:017-N @smoke @p1. После этого скилл дописывает в spec.md секцию ## Feature files touched со ссылкой на новый файл.

Шаг 2: /speckit-plan

Constitution Check запускает validate_drift --spec 017. Проверяет:

Если чисто - план READY. Если нет - DRAFT, и инженер (или агент) возвращается в /speckit-specify.

Шаг 3: /speckit-tasks

Генерирует список задач. Для BDD - одна creation-task на новый файл и одна [BDD] wiring-task на каждый acceptance scenario, со статическим AST-сканом существующих tests/bdd/steps/ для подсказок реюза:

- [ ] T010 [US1] [BDD] Wire scenario "User signs in with valid Apple ID"
  in features/auth/apple-signin.feature (@spec:017 @user-story:017-1).
  Steps to bind:
  - Given an Apple ID with email "{email}" → NEW: add to tests/bdd/steps/auth_steps.py
  - When the user clicks "Sign in with Apple" → NEW: add to tests/bdd/steps/auth_steps.py
  - Then a JWT is issued with the user's email claim → NEW: add to tests/bdd/steps/auth_steps.py
  Acceptance: pytest tests/bdd/features/test_auth.py -k "User signs in with valid Apple ID" is green.

Шаг 4: имплементация

Production-код под services/identity/src/.../auth/apple_oauth.py. Step bindings под tests/bdd/steps/auth_steps.py:

from pytest_bdd import given, when, then, parsers

@given(parsers.parse('an Apple ID with email "{email}"'),
       target_fixture="apple_id")
def given_apple_id(email: str) -> AppleID:
    return AppleID(email=email)

@when('the user clicks "Sign in with Apple"', target_fixture="auth_response")
async def when_apple_signin_clicked(apple_id, identity_app):
    return await identity_app.post(
        "/auth/apple/init", json={"email": apple_id.email}
    )

@then("a JWT is issued with the user's email claim")
def then_jwt_with_email(auth_response, apple_id):
    claims = decode_jwt(auth_response.json()["access_token"])
    assert claims["email"] == apple_id.email

Эту работу часто делает агент - код прозрачный, паттерны повторяются, fixtures (identity_app, db_session) уже описаны в tests/bdd/conftest.py.

Шаг 5: /speckit-implement

Completion gate запускает:

Если всё зелёное - спек закрыт. Если нет - gate репортит green / red / pending breakdown и точку отказа.

Шаг 6: PR

CI прогоняет полный pytest + validate_drift. Автономный PR-reviewer-агент работает в isolated context (worktree), читает testing-rules, конституцию, контракты BDD-слоя, выдаёт «BDD Spec Coverage» секцию с severity matrix. Reviewer не пишет код - только репортит. Решение принимает человек.

Каждый шаг - программный gate с конкретным выходом: либо пайплайн зелёный и контракт в целости, либо красный с конкретной точкой отказа.

Скип-паттерн как backfill-стратегия

Распространённая прагматичная ситуация в момент внедрения BDD-слоя. Чтобы выполнить функциональное требование «every scenario is executable», step bindings нужны для всех сценариев в момент шиппинга. Реальные bindings требуют integration-фикстур (db_session, identity_app, recorded_llm), которые на этом этапе ещё не подключены к conftest. Поставлять пустые fixtures ради скорости - значит получать красные тесты, которые никто не починит.

Решение - универсальный single-Given skip-pattern:

@spec:008 @user-story:008-3 @integration @p1
Scenario: New email signup attempt returns 503 with a registration_closed error
  Given the new-email-blocked assertions are deferred to Phase 5 auth-domain follow-up

Один Given, который матчится универсальным binding-ом в bdd_meta_steps.py:

@given(parsers.parse("the {workflow} assertions are deferred to {target}"))
def given_deferred(workflow: str, target: str) -> None:
    pytest.skip(
        f"workflow-level scenario for '{workflow}' is verified manually "
        f"per quickstart.md; programmatic binding lands during {target}."
    )

При прогоне сценарий рапортуется как skipped с сообщением, в котором есть phase pointer. Pytest-bdd дальше шагов не идёт.

Что это удовлетворяет:

Без принуждения иметь:

Trade-off: скипнутые сценарии не проверяют поведение. Они фиксируют contract surface - что система обещает делать - и показывают, где должны появиться реальные bindings. По мере того, как domain owners пишут реальный код, сценарии переходят из skipped в green.

В контексте AI-агентов это особенно полезно: агент часто формулирует контракт быстрее, чем команда успевает завести integration-фикстуры. Skip-pattern закрывает разрыв: контракт зафиксирован, тесты зелёные (как skip), реальные bindings ставятся отдельным PR-ом - желательно тем же агентом, по указателю на phase pointer в сообщении skip-а.

Что это даёт агенту в качестве context

Если перечислить, что меняется в работе агента, когда features/-дерево становится первоклассным контрактом:

Это не делает агента «безопасным» автоматически. Discovery остаётся за людьми, и плохо сформулированные сценарии дадут плохо сделанные фичи. Но операционная дисциплина становится частью инфраструктуры: путь наименьшего сопротивления и правильный путь совпадают.

Антипаттерны и ограничения

  1. Gherkin не везде уместен. Low-level invariants - индексы БД, internal API contracts, garbage collection - в .feature-файле выглядят инородно. Их естественное место - обычные интеграционные или unit-тесты. BDD-слой - additive, не replacement: existing pytest-suites остаются на своём месте, features/ добавляется сверху как acceptance-контракт.
  2. Happy-path-only сценарии бесполезны. Без edge cases, failure modes, unhappy paths контракт описывает идеальный мир, в котором баги не случаются. Cucumber-документация по example mapping подчёркивает это: зелёные карточки - это конкретные примеры, включая граничные. Полезное правило: для каждого acceptance criteria - хотя бы один happy и один edge сценарий, и явные @flaky / @known-regression теги для зон, где система ведёт себя неустойчиво.
  3. Авторинг сценариев без human review. Formulation - часть discovery practice, она требует stakeholder-знания, которого у агента нет. Даже отлично написанный Gherkin не означает правильной модели предметной области. Рабочий pipeline: агент скаффолдит первый драфт, человек проходится перед /speckit-plan, dispute случаются на этом этапе, не на ревью PR-а.
  4. «У нас есть DDD - этого хватит». Bounded context auth-сервиса содержит регистрацию, login, password reset, MFA, SSO, session management - это разные user-facing capability с разным lifecycle, разными pain points, разными owner-ами. DDD структурирует код, но не functional surface. Подробнее это разобрано в статье «Функциональность как first-class artifact».
  5. Russian Gherkin keywords. Cucumber поддерживает # language: ru и весь набор русских ключевых слов (Функция, Сценарий, Допустим, Когда, Тогда). Для проекта с русскоязычной командой это выглядит соблазнительно, но лучше выбирать один стиль и применять его последовательно. Английские keywords сочетаются с большинством IDE-плагинов, GitHub-подсветки и tooling-cookbooks без дополнительных настроек; русский end-user текст и так попадает дословно в step arguments как данные.
  6. Перенос всего PRD в Gherkin. Gherkin - формат для acceptance criteria, не для market analysis, business cases, ARC-design rationale. Spec-документ остаётся (это decision history), .feature - его исполняемая часть. Если PM пишет «фича позволит увеличить retention на 15% по гипотезе X» - это не Given/When/Then, это motivation в spec.md.
  7. Drift validator без owner. Правила уровня error без человека, который реагирует на тревоги, превращаются в noise. V-PII или V-LOCATION блокирует merge - кто-то должен быть готов это разрулить. V-ORPHAN - warning, который кто-то периодически разгребает. Без этой ответственности валидатор отключают через две недели.

Минимальная рабочая система

Чтобы попробовать BDD-as-default в проекте с AI-агентами, не обязательно строить весь стек сразу. Минимальная конфигурация.

1. Один capability, не все сразу

Выбрать одну critical capability с понятным actor и outcome - типа onboarding, billing-checkout, primary search. Не размазываться на все области.

2. Top-level features/<capability>/

Не под спеками, не под тестами - отдельное дерево. Один файл на slice (например, features/auth/magic-link.feature).

3. pytest-bdd как execution layer

Установить pytest-bdd, в pyproject.toml положить:

[tool.pytest.ini_options]
asyncio_mode = "auto"
bdd_features_base_dir = "features"

4. Один conftest для tag whitelist

В tests/bdd/conftest.py - pytest_collection_modifyitems, который ловит unknown теги:

_BARE_TAG_WHITELIST = frozenset({
    "smoke", "integration", "e2e", "p1", "p2", "p3",
})
_KEYED_TAG_WHITELIST = frozenset({"capability", "spec", "user-story"})

def pytest_collection_modifyitems(config, items):
    unknown = []
    for item in items:
        for marker in list(item.iter_markers()):
            name = marker.name
            if ":" in name:
                if name.split(":", 1)[0] in _KEYED_TAG_WHITELIST:
                    continue
            elif name in _BARE_TAG_WHITELIST:
                continue
            elif name in {"asyncio", "parametrize", "skip", "skipif", "xfail"}:
                continue
            unknown.append((item.nodeid, name))
    if unknown:
        formatted = "\n".join(f"  - {nid}: @{tag}" for nid, tag in unknown)
        raise pytest.UsageError(f"Unknown tags:\n{formatted}")

5. Per-capability glue в 5 строк

# tests/bdd/features/test_auth.py
from pytest_bdd import scenarios
from tests.bdd.steps.world import world  # noqa: F401
from tests.bdd.steps.auth_steps import *  # noqa: F403

scenarios("auth")

6. World-фикстура для per-scenario state

# tests/bdd/steps/world.py
from dataclasses import dataclass, field
from typing import Any
import pytest

@dataclass(slots=True)
class World:
    last_response: Any = None
    extra: dict[str, Any] = field(default_factory=dict)

@pytest.fixture
def world() -> World:
    return World()

7. Простейший drift-чек как pre-commit advisory

Скрипт, который:

Hard gate - в CI: тот же скрипт, но exit 1 при ошибках.

8. Опционально: интеграция со spec-driven workflow

Если используется spec-kit, GitHub Spec Kit, OpenSpec или собственный аналог - добавить вызов scaffold + добавление ## Feature files touched секции в spec-документ. Без этого агент будет писать .feature-файлы вручную, что работает, но не имеет автоматического gate-а.

Что не нужно на старте: HTML living-doc renderer, full three-direction validator, кастомный .pre-commit-config.yaml, кастомный PR-reviewer-агент. Это всё имеет смысл, когда capabilities дойдёт до 5-10. Через месяц решать: используется ли .feature-дерево как input context для агента (PM ссылается на сценарии, агент в /specify-промтах ставит линки на features/..., тесты падают на каждом дрейфе) - расширять. Если файлы лежат и никто их не открывает - формат не подошёл, проблема в process, а не в инфраструктуре.

Источники

Theory and practice

Industry artifacts

Смежные статьи на сайте