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/. Внутри файла:
- Feature-level теги в первой строке после комментариев - описывают капабилити и контрибутирующие спеки;
Feature:+As a/I want/So that- продуктовое описание для stakeholder;Background:- общие предусловия;- N сценариев, каждый с собственными тегами (scope, priority, spec-link), Given/When/Then-цепочкой.
Полный пример - закрытая регистрация (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 - «what it could do». Структурированные разговоры (discovery workshops, example mapping) на реальных примерах, чтобы вырастить общее понимание потребностей пользователя, правил системы и границ работы.
- Formulation - «what it should do». Запись примеров в виде структурированной документации, читаемой и человеком, и компьютером, для подтверждения общего понимания до начала имплементации.
- Automation - «what it actually does». Использование тех же примеров как тестов, направляющих разработку: тест падает до того, как поведение реализовано.
Когда в команде появляется агент, эти три практики не отменяются - они перераспределяются.
Discovery остаётся за людьми
Discovery - это набор практик про разговор между бизнесом и инженерами на конкретных примерах. Cucumber-проект и BDD-канон называют три устоявшихся формата:
- Three amigos meeting - встреча трёх ролей: PM (или business analyst), инженер, QA. Каждая роль приносит свой угол: PM знает intent, инженер знает constraints, QA знает граничные случаи. Цель - найти расхождения в понимании до того, как они станут багами. Длительность 30-60 минут на одну user story.
-
Example mapping (Burton, 2015; canonical Cucumber-практика) - структурированная сессия на 25-30 минут вокруг одной user story. Используются карточки четырёх цветов:
- жёлтая - сама user story сверху карты;
- синие - acceptance criteria (бизнес-правила, которые должны выполняться);
- зелёные - конкретные примеры, иллюстрирующие каждое правило;
- красные - открытые вопросы, на которые в этой сессии нет ответа.
- Specification by example (Adzic, 2011) - общее название для практики, в которой acceptance criteria формулируются через конкретные примеры, а не абстрактные требования. Example mapping - одна из конкретных техник под этим зонтиком.
Ожидаемый артефакт discovery-сессии - не Gherkin (это уже formulation, следующий шаг). Артефакт - это структурированная заметка с тремя секциями:
- Rules - бизнес-правила (синие карточки), пронумерованные;
- Examples - конкретные иллюстрации каждого правила (зелёные карточки), сгруппированные под соответствующим rule;
- Open questions - перечень нерешённых вопросов с указанием owner-а, который должен принести ответ.
В большинстве команд этот артефакт фиксируется в Notion / Miro / Confluence-странице или в issue-трекере как комментарии к story. Формальный шаблон не критичен; критичны три секции - правила, примеры, вопросы.
Этот артефакт затем превращается в Gherkin на стадии formulation: каждое бизнес-правило (синяя карточка) часто становится группой сценариев в .feature-файле; каждый конкретный пример (зелёная карточка) - отдельным Scenario: или строкой в Examples: у Scenario Outline.
Агент в discovery не участник:
- он не оценивает regulatory constraint;
- не видел support-тикеты за последний квартал;
- не знает, что часть клиентов не справляется на конкретном шаге;
- не предлагает edge-cases на основе personal experience с клиентами.
Discovery - human checkpoint в любой системе с агентом. Можно использовать агента как scribe (записывать карточки во время сессии, переводить notes в structured format) - но не как amigo, предлагающего содержимое.
Formulation становится human-AI циклом
После discovery остаётся структурированная заметка: rules + examples + open questions. Formulation превращает её в Gherkin - бизнес-правила группируют сценарии, конкретные примеры становятся отдельными Scenario: или строками в Examples:-таблице у Scenario Outline. Это формальная переработка артефакта в исполняемый контракт.
Шаги цикла, когда в команде есть агент:
-
Человек инициирует 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. -
Scaffolder создаёт скелет. На выходе -
.feature-файл с обязательной шапкой (# Capability:,# Contributing specs:), feature-level тегами,Background:, и однимScenario:на каждый acceptance summary изspec.md. Шаги пока шаблонные:Given [initial state],When [action],Then [expected outcome]- это явные TODO, которые нужно заполнить. -
Агент достраивает черновик. Имея на руках discovery-артефакт (rules + examples) и шаблон сценария, агент заполняет конкретные Given/When/Then через бизнес-язык. Здесь агент эффективен по трём причинам: формальный язык с фиксированной грамматикой, правила именования (placeholder в кавычках, lowercase бизнес-термины), переиспользуемые шаги из существующих файлов в той же капабилити (агент сканирует
tests/bdd/steps/auth_steps.pyи предлагает шаги, которые уже забиндены). -
Человек ревьюит контракт. Это 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:.
- Уровень абстракции. Шаги пишутся бизнес-языком (
-
Итерация. Если ревью нашёл проблемы, человек указывает агенту конкретные правки: «у Scenario 3 шаг 2 - на уровне реализации, переформулируй»; «добавь сценарий для concurrent edit (rule 4)»; «помечай
@e2eвместо@smoke, требуется DB и Redis». Агент применяет, человек ревьюит снова. На capability средней сложности 1-3 итерации обычно достаточно. -
Финал:
## 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, нужно знать, какие шаги уже забиндены, а какие появились в новом сценарии и требуют новой функции. Эта информация извлекается двумя способами:
-
Pytest collection.
pytest --collect-onlyдля BDD-сьюта проходит все .feature-файлы и пытается зарезолвить каждый шаг в существующий binding. Когда binding не найден, pytest-bdd бросаетStepDefinitionNotFoundErrorс указанием конкретного шага и файла. Это динамический механизм - он отвечает на вопрос «что не забиндено прямо сейчас» точно, без эвристики. -
Static AST scan. Скрипт обходит
tests/bdd/steps/*.py, собирает все вызовы@given(...)/@when(...)/@then(...)через AST-парсер, извлекает паттерны декораторов (plain string илиparsers.parseargument). На выходе - список «вот все 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 - один файл, между ними нет промежуточного шага «синхронизировать», на котором всё ломается.
Это даёт три полезных свойства:
- Stakeholder читает контракт без перевода. PM, спрашивающий «что продукт делает сегодня», открывает
features/<capability>/<name>.featureи читает прозу. Не нужно ходить в репозиторий за тестами и не нужно звать инженера для перевода. - Спецификация и тест меняются одним PR. Когда поведение меняется, меняется один файл. Сценарий не становится «историческим документом» - он лежит там, где идут изменения.
- 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 на тот же файл, когда меняется поведение. Других мест нет.
Это накладывает ограничения на авторов:
- Шаги пишутся бизнес-языком, а не именами внутренних API. «User receives a confirmation», не «messaging_dispatcher.handle returns ok».
- End-user контент (например, русские строки в bot-сообщениях) сохраняется внутри step arguments дословно, потому что это user-facing контракт, а не данные теста.
- Step bindings - переводчик с бизнес-языка на runtime: они принимают Gherkin-текст и вызывают production-код.
Что это меняет для агента:
- Агент, читая файл, получает контекст, который читает PM. Не «как у нас устроена аутентификация», а «что пользователь делает и что система отвечает».
- Агент, дописывая фичу, обязан изменить тот же файл, который читает PM на следующий ревью. Не два файла, не четыре - один.
- Когда агент гонит pytest, он гонит ровно те сценарии, которые PM считает контрактом. «Зелёные тесты» и «контракт выполняется» становятся синонимами.
Это не отменяет человеческое ревью - 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:
specs/<dir>/spec.mdимеет секцию## Feature files touched, указывающую наfeatures/<capability>/<name>.feature.- Каждый
.feature-файл в шапке несёт# Contributing specs:со списком всех спеков, которые его трогали.
Развязка важна, потому что 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)
Три варианта матчинга, в порядке возрастания мощности:
- 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")
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
Что здесь происходит шаг за шагом:
- 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 в режиме 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-файл с шагом, который ещё не забинден, у него детерминированный путь:
- Скопировать 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/.
Один .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. Проверяет:
## Feature files touchedесть и непустая;- листанный путь существует;
- каждый
### User Story Nвspec.mdимеет матчащий@user-story:017-Nтег в файле; - ни один
.feature-файл не оказался подspecs/.
Если чисто - план 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 запускает:
tools.bdd.render --spec 017для перечисления сценариев спека;pytest tests/bdd/features/test_auth.py -k "<filter>"на этих сценариях;- проверку header back-link (что в
features/auth/apple-signin.featureесть# Contributing specs:с017); tools.bdd.validate_drift --spec 017(drift = 0).
Если всё зелёное - спек закрыт. Если нет - 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 дальше шагов не идёт.
Что это удовлетворяет:
- FR-006 «every scenario is executable» (pytest их прогоняет, даже если итог skip);
- V-USER-STORY (каждая User Story имеет тегированный сценарий);
- остальные drift-checks (нет PII, нет неизвестных тегов, валидные spec-линки).
Без принуждения иметь:
- работающий binding для каждого шага сегодня;
- реальные integration-фикстуры для каждой capability сегодня.
Trade-off: скипнутые сценарии не проверяют поведение. Они фиксируют contract surface - что система обещает делать - и показывают, где должны появиться реальные bindings. По мере того, как domain owners пишут реальный код, сценарии переходят из skipped в green.
Что это даёт агенту в качестве context
Если перечислить, что меняется в работе агента, когда features/-дерево становится первоклассным контрактом:
- Агент читает
features/<capability>/<name>.featureкак PRD. Не Confluence, не Notion, не Jira-эпик - один файл в репо. Тот же, что читает PM. Это убирает intent gap: задача «исправь баг на онбординге» резолвится вfeatures/auth/...без угадывания по grep-у. - Агент пишет сценарии и тесты в одном файле. Дописать сценарий, попросить pytest-bdd показать missing binding, добавить binding в
tests/bdd/steps/auth_steps.py- это один цикл, не три. - Агент видит границы фичи через теги.
@capability:auth @spec:008- это границы ответственности, машиночитаемые. Агент не правит чужую capability «попутно». - «Готово» имеет программное определение. Сценарий зелёный + drift = 0 + scope-тег есть + spec-тег резолвится + PII-проверка прошла. Агент не закрывает задачу односторонне.
- Living docs отражают актуальное состояние. Рендер из
features/в markdown собирается из последнего main; PM, давая фидбэк по новой версии фичи, читает не спецификацию шестинедельной давности, а текущий контракт.
Это не делает агента «безопасным» автоматически. Discovery остаётся за людьми, и плохо сформулированные сценарии дадут плохо сделанные фичи. Но операционная дисциплина становится частью инфраструктуры: путь наименьшего сопротивления и правильный путь совпадают.
Антипаттерны и ограничения
- Gherkin не везде уместен. Low-level invariants - индексы БД, internal API contracts, garbage collection - в
.feature-файле выглядят инородно. Их естественное место - обычные интеграционные или unit-тесты. BDD-слой - additive, не replacement: existing pytest-suites остаются на своём месте,features/добавляется сверху как acceptance-контракт. - Happy-path-only сценарии бесполезны. Без edge cases, failure modes, unhappy paths контракт описывает идеальный мир, в котором баги не случаются. Cucumber-документация по example mapping подчёркивает это: зелёные карточки - это конкретные примеры, включая граничные. Полезное правило: для каждого acceptance criteria - хотя бы один happy и один edge сценарий, и явные
@flaky/@known-regressionтеги для зон, где система ведёт себя неустойчиво. - Авторинг сценариев без human review. Formulation - часть discovery practice, она требует stakeholder-знания, которого у агента нет. Даже отлично написанный Gherkin не означает правильной модели предметной области. Рабочий pipeline: агент скаффолдит первый драфт, человек проходится перед
/speckit-plan, dispute случаются на этом этапе, не на ревью PR-а. - «У нас есть DDD - этого хватит». Bounded context auth-сервиса содержит регистрацию, login, password reset, MFA, SSO, session management - это разные user-facing capability с разным lifecycle, разными pain points, разными owner-ами. DDD структурирует код, но не functional surface. Подробнее это разобрано в статье «Функциональность как first-class artifact».
- Russian Gherkin keywords. Cucumber поддерживает
# language: ruи весь набор русских ключевых слов (Функция,Сценарий,Допустим,Когда,Тогда). Для проекта с русскоязычной командой это выглядит соблазнительно, но лучше выбирать один стиль и применять его последовательно. Английские keywords сочетаются с большинством IDE-плагинов, GitHub-подсветки и tooling-cookbooks без дополнительных настроек; русский end-user текст и так попадает дословно в step arguments как данные. - Перенос всего 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. - 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
Скрипт, который:
- проверяет, что
.feature-файлы лежат только подfeatures/; - парсит каждый, смотрит на whitelist тегов;
- pre-commit hook вызывает скрипт, выводит findings, всегда exit 0 (advisory).
Hard gate - в CI: тот же скрипт, но exit 1 при ошибках.
8. Опционально: интеграция со spec-driven workflow
Если используется spec-kit, GitHub Spec Kit, OpenSpec или собственный аналог - добавить вызов scaffold + добавление ## Feature files touched секции в spec-документ. Без этого агент будет писать .feature-файлы вручную, что работает, но не имеет автоматического gate-а.
.pre-commit-config.yaml, кастомный PR-reviewer-агент. Это всё имеет смысл, когда capabilities дойдёт до 5-10. Через месяц решать: используется ли .feature-дерево как input context для агента (PM ссылается на сценарии, агент в /specify-промтах ставит линки на features/..., тесты падают на каждом дрейфе) - расширять. Если файлы лежат и никто их не открывает - формат не подошёл, проблема в process, а не в инфраструктуре.
Источники
Theory and practice
- North, D. «Introducing BDD» (Better Software, March 2006).
- Adzic, G. Specification by Example (Manning, 2011).
- Christensen, C. M. Competing Against Luck (Harper Business, 2016) - JTBD canon.
- Cucumber Project. BDD documentation - cucumber.io/docs/bdd.
- Cucumber Project. Gherkin Reference - cucumber.io/docs/gherkin/reference.
- Cucumber Project. Example Mapping - cucumber.io/docs/bdd/example-mapping.
Industry artifacts
- pytest-bdd - Python BDD framework, integrates with pytest+asyncio+coverage.
- Spec Kit (GitHub) - публичный фреймворк spec-driven development; источник конвенции
specs/NNN-...и/speckit-...скиллов. - AGENTS.md (Linux Foundation, 2025+) - root-level протокол для AI-агентов.
Смежные статьи на сайте
- Функциональность как first-class artifact - feature catalog как соседний слой.
- Context Engineering - контекстное окно как ресурс.
- Инфраструктура контекста для AI-агентов - трёхъярусная архитектура памяти.
- SDLC для AI-агентов - где BDD лежит в spec-driven процессе.
- Spec Kit - конкретный фреймворк spec-driven development.
- Как устроен Claude Code - skills и rules как L4 слой.