Behavior-driven development with AI agents
PLBehavior-driven development (BDD) with AI agents uses Gherkin scenarios, written in Given/When/Then and approved by a human, as the executable contract an agent implements against. An agent drafts the scenarios and the Cucumber step glue, a person approves both, and the implementing session cannot edit either. The Cucumber run in CI decides when the behavior is done.
This page is for developers and tech leads whose product owners or testers already read scenarios, or who maintain a Cucumber suite. Your cart feature passes every unit test and still ships the wrong thing: the stock rule lived in a ticket the agent never saw, and when a scenario did fail, the agent changed Then the cart holds 2 into Then the cart holds at least 1. BDD fixes the first problem only if you also fix the second.
What you get from BDD with agents
Section titled “What you get from BDD with agents”- A decision rule for when Gherkin earns its extra layer and when plain acceptance tests are enough.
- A five-step loop: example mapping, locked feature file, step glue that is red for the right reason, implementation, and step-library cleanup.
- Four copy-paste prompts for Claude Code, Codex and Cursor, with the session lock for each tool.
- A CI job that runs the suite, rejects contract edits and unused steps, and publishes the HTML report as the evidence a reviewer reads instead of the diff.
- The ways BDD with agents fails, including a Cucumber.js exit-code trap, and how to recover from each.
When is Gherkin worth it over plain acceptance tests?
Section titled “When is Gherkin worth it over plain acceptance tests?”Gherkin adds a layer: the .feature file, plus step definitions that bind each sentence to code. That layer pays for itself when people who do not read code approve the behavior. Otherwise it is one more place for an agent to hide a weak assertion. The general method, one criterion to one failing test, is on executable acceptance criteria; this page covers the Gherkin variant.
| Situation | Use | Why |
|---|---|---|
| A product owner, tester or domain expert approves scenarios | Gherkin + Cucumber | They sign off on sentences, not test code |
| One rule has many example rows (prices, limits, locales) | Gherkin Scenario Outline | The Examples table makes special-casing visible and cheap to extend |
| You already maintain a Cucumber, Behat or SpecFlow suite | Gherkin | The agent extends the existing step library instead of starting a second style |
| Only developers read the tests | Plain Playwright or API tests | The glue layer costs more than it returns |
| The behavior has no user-visible boundary | Unit or property tests | A scenario would couple to internals |
For JavaScript projects, pick one of two runners. Cucumber.js (@cucumber/cucumber 13.2.1 on npm, checked 2026-09-26) runs features directly and drives Playwright as a library. playwright-bdd (9.2.1, peer @playwright/test 1.44 or later) turns features into Playwright Test files with npx bddgen, so you keep Playwright’s fixtures, traces and sharding. The example below uses Cucumber.js; the loop and the prompts are the same with either.
How does the BDD loop work with an agent?
Section titled “How does the BDD loop work with an agent?”The loop has two pull requests. The first carries the contract: the feature file and red step glue. A human approves it. The second carries the implementation, from a session that cannot touch the first. The contract pull request is red by design, so it never merges on its own: open the implementation pull request with the contract branch as its base, merge it into that branch once CI is green, and then merge the contract pull request, now green, into main. Main never holds a failing scenario, and the path guard still compares the implementation against the approved contract.
- Map examples with the agent. The agent turns the story into rules, concrete examples and open questions. You and the product owner answer the questions and approve the scenarios.
- Commit the feature file in a contract pull request. From here on, the scenarios (
features/**/*.feature) belong to the product owner and the glue (features/steps/,features/support/) to the tech lead. - Generate step glue that fails for the right reason. A separate session writes step definitions and runs the suite. Every scenario must fail on a missing behavior, not on a missing selector or a thrown
TypeError. - Implement until green in a fresh session that loads the lock, in its own worktree, on a branch stacked on the contract branch.
- Consolidate the step library once the feature ships, so the next story reuses steps instead of adding near-duplicates.
Step 1: map the examples before you write Gherkin
Section titled “Step 1: map the examples before you write Gherkin”Example mapping splits a story into rules, one or more examples per rule, and questions nobody can answer yet. Ask the agent to do the first pass. It reads the ticket and the code faster than a meeting can, and its questions expose the gaps.
The draft is a proposal. The product owner answers the questions, deletes @question tags, and approves the file. Here is the approved result for a stock-limited cart:
Feature: Stock-limited cart Shoppers cannot put more units in the cart than the warehouse holds.
Rule: The cart never holds more units than are in stock
Scenario Outline: Adding up to the stock limit Given "<product>" has <stock> units in stock When the shopper adds <qty> "<product>" to the cart Then the cart holds <qty> "<product>"
Examples: | product | stock | qty | | Super Widget | 2 | 2 | | Gear Kit | 5 | 1 |
Scenario: Blocked from exceeding the stock limit Given "Super Widget" has 2 units in stock And the shopper already has 2 "Super Widget" in the cart When the shopper tries to add 1 more "Super Widget" Then the shopper sees "Only 2 in stock" And the cart holds 2 "Super Widget"The second example row matters. With one example, an agent can pass by checking for "Super Widget" and 2. With two, it has to implement the rule. Note the wording of the set-up line in the second scenario. Cucumber ignores Given/When/Then when it matches a step to a definition, so a set-up step and an assertion need different sentences: the shopper already has 2 … in the cart seeds state, and the cart holds 2 … only ever asserts. If both used the same sentence, either the precondition would assert against an empty cart and fail, or the Then would become a set-up step that checks nothing. If your product owner writes in another language, Gherkin supports that too: # language: pl on the first line switches the keywords (npx cucumber-js --i18n-keywords pl lists them).
Step 2: lock the feature file
Section titled “Step 2: lock the feature file”Commit the feature file in a pull request that only a code owner can approve, label it bdd-contract, and add the CI guard shown in How do you prove the behavior without reading the diff?. The product owner approves the contract in this pull request, but it stays open: its suite is red until the implementation lands. The implementation pull request targets the contract branch, not main, and both merge once the suite is green. The five layers of protection, including holdout examples kept out of the repository, are on protecting the oracle.
Step 3: generate step glue that is red for the right reason
Section titled “Step 3: generate step glue that is red for the right reason”Configure Cucumber.js once. This cucumber.json loads TypeScript step files and writes an HTML report:
{ "default": { "paths": ["features/**/*.feature"], "import": ["features/steps/**/*.ts", "features/support/**/*.ts"], "format": ["progress", "html:reports/cucumber.html"] }}{ "scripts": { "test:bdd": "NODE_OPTIONS='--import tsx' cucumber-js" }}Load tsx through NODE_OPTIONS, not Cucumber’s loader option: with tsx 4.23 on Node 22, "loader": ["tsx"] fails with “tsx must be loaded with —import instead of —loader” (checked 2026-09-26 with @cucumber/cucumber 13.2.1). The NODE_OPTIONS form is POSIX shell syntax; on Windows use cross-env.
Run npm run test:bdd -- --dry-run to see the snippets Cucumber suggests for undefined steps, then hand the job to a session that may write under features/steps/ and features/support/ only. In Claude Code, lock the scenarios but not the glue:
claude --disallowedTools "Edit(/features/**/*.feature)"Codex 0.157.1 and Cursor have no verified per-path write deny, so at this stage the code-owner review of the contract pull request is the only check that the scenarios stayed untouched.
Start the app first (npm start in another terminal): test:bdd drives the app on port 3000, and without it every scenario fails with a refused connection, which is the wrong reason.
Read the red run, not the glue code line by line. Each failure should name the missing behavior, for example an expected text of Only 2 in stock that never appears. Commit the glue with the feature file in the same contract pull request.
Step 4: implement until the suite is green
Section titled “Step 4: implement until the suite is green”The implementing session gets the contract and nothing else to change. Give it a branch stacked on the contract branch (here cart-stock-contract) in its own worktree, so the feature file and the step glue are already in the checkout. A tool’s built-in worktree flag (claude --worktree, codex exec --worktree) starts from the tool’s default base, not from the contract branch, so create the worktree yourself:
git worktree add ../cart-stock-impl -b cart-stock-impl cart-stock-contractcd ../cart-stock-implls features/cart/stock-limit.feature # the contract must be hereKeep the app running on port 3000 from this worktree (npm start), as in step 3. How you lock the session differs per tool.
Pass the lock as a command-line flag on implementation sessions only, so the step-glue session in step 3 is not blocked by it:
# Terminal, inside ../cart-stock-impl: interactive implementationclaude --disallowedTools "Edit(/features/**)"
# Terminal or CI: headless run with a spending capclaude -p "Implement features/cart/stock-limit.feature until npm run test:bdd passes. Do not edit features/." \ --disallowedTools "Edit(/features/**)" \ --permission-mode acceptEdits \ --allowedTools "Bash(npm run test:bdd)" "Bash(npm run test:bdd *)" \ --max-budget-usd 5Edit rules cover Claude Code’s own file tools and the file commands it recognizes in Bash, not a Node script that writes files, so the CI guard stays the binding check. Before the first real run, ask the interactive session to add a line to features/cart/stock-limit.feature: the edit must be denied. Flags checked against Claude Code 2.1.283 on 2026-09-26; the rule syntax is explained on permissions and sandboxes.
Add the contract rule to AGENTS.md, which Codex reads before it starts work in a trusted project:
## BDD contract- Never edit files under features/. The product owner approves them.- If a scenario looks wrong, stop and explain why in your final message.# Terminal (Codex CLI 0.157.1), inside ../cart-stock-implcodex exec --approve-for-me -o bdd-report.md \ "Implement features/cart/stock-limit.feature until npm run test:bdd passes. Do not edit features/."--approve-for-me routes approval requests through automatic review inside the workspace-write sandbox. This page does not show a per-path write deny for Codex, because none was verified for 0.157.1; the CI guard enforces the contract. The workspace-write sandbox has no network access unless sandbox_workspace_write.network_access is set to true (key present in 0.157.1), so the Playwright run against localhost:3000 asks for an escalation that auto-review decides; if auto-review denies it, every scenario fails with a connection error, which is the wrong reason, and /approve retries one denial.
Start in Plan Mode, which “creates detailed implementation plans before writing any code”, and check that the plan runs npm run test:bdd and leaves features/ alone. Put the two contract rules from the Codex tab in a project rule so every agent session sees them.
For a long red-to-green loop, hand the task to a Cloud Agent and review the pull request it opens; the CI guard runs there as well. Enforce the contract in CI rather than with a local Cursor hook (Cursor docs last checked 2026-08-28).
To let the step-glue session explore the running page before it writes locators, connect the Playwright MCP server (@playwright/mcp). Setup for all three tools is on browser automation MCP servers.
Step 5: keep the step library small
Section titled “Step 5: keep the step library small”Agents write a new step for every phrasing: “adds 2 to the cart”, “puts 2 in the cart”, “adds two items”. Cucumber’s usage formatter lists every step definition with the scenarios that call it and marks the orphans UNUSED.
How do you prove the behavior without reading the diff?
Section titled “How do you prove the behavior without reading the diff?”The reviewer reads three things: the approved feature file, the Cucumber HTML report, and the guard result. This GitHub Actions job produces all three on every pull request:
name: bddon: pull_requestpermissions: contents: readjobs: bdd: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci - run: npx playwright install --with-deps chromium - name: Implementation PRs must not change the contract if: ${{ !contains(github.event.pull_request.labels.*.name, 'bdd-contract') }} env: BASE_REF: ${{ github.base_ref }} run: | changed=$(git diff --name-only "origin/$BASE_REF"...HEAD -- features) if [ -n "$changed" ]; then echo "::error::This PR changes the BDD contract:"; echo "$changed"; exit 1 fi - name: No unused step definitions run: | if npm run test:bdd -- --dry-run --format usage | grep -q UNUSED; then echo "::error::Unused step definitions. Consolidate them."; exit 1 fi - run: npm run build - run: npx start-server-and-test start http://localhost:3000 test:bdd - uses: actions/upload-artifact@v7 if: always() with: name: cucumber-report path: reports/The job assumes a start script that serves the built app on port 3000; add start-server-and-test as a devDependency so npx does not fetch it on each run.
On the contract pull request the last npm run test:bdd step fails by design; that red run is the evidence the glue fails for the right reason. The pull request is never merged while red: the stacked implementation pull request is merged into the contract branch first, the job reruns green, and only then does the contract reach main. Protect main by making the bdd job a required status check, so a red contract cannot be merged by mistake. Protect the contract branch too, so that code reaches it only through the guarded implementation pull request; if stale approvals are dismissed, the product owner re-approves the now-green contract pull request before it merges.
Split ownership in .github/CODEOWNERS with /features/**/*.feature @your-org/product-owners and /features/steps/ @your-org/tech-leads plus /features/support/ @your-org/tech-leads plus /.github/ @your-org/tech-leads (one pattern per line), and turn on Require review from Code Owners. A pull request labeled bdd-contract then needs the product owner for scenario wording and a tech lead for glue, so nobody who does not read code approves a weakened assertion. The job holds no secrets and uses a read-only token, so it is safe on pull requests from branches. It is not tamper-proof on its own, though: on pull_request, GitHub runs bdd.yml from the pull request’s own branch, so an implementation pull request can delete the path guard and pass, and anyone who can add the bdd-contract label can make the guard skip. That is why /.github/ is in CODEOWNERS too: with required Code Owner review, neither a changed workflow nor a change under features/ can merge without its owner.
| What is checked | How | Who signs off |
|---|---|---|
| The scenarios describe the right behavior | Contract pull request with the feature file | Product owner |
| The glue fails for the right reason | Red run pasted into the contract pull request | Developer or tech lead |
| The glue is strong enough | Audit prompt on executable acceptance criteria, mutation score on oracle strength | Tech lead, per suite |
| The implementation meets the contract | npm run test:bdd green in CI, HTML report attached | CI |
| The contract did not move to fit the code | Path guard and CODEOWNERS | CI, plus the product owner for scenarios and the tech lead for glue |
Security, payments and migrations still get a human code review. The scenarios move the default for everything else, which is the shift described on reading evidence instead of code.
What breaks with BDD and agents?
Section titled “What breaks with BDD and agents?”The agent edits or retags a scenario. It adds @wip or @skip to a failing scenario, or rewords a Then. Signal: the path guard fails. Recovery: reject the pull request, restart in a fresh session with the lock loaded, and repeat the “do not retag” line in the prompt. If it happens twice on one scenario, the scenario is probably wrong: take it back to the product owner.
The glue gets weaker while the feature file stays the same. The agent changes toHaveText('Only 2 in stock') to toBeVisible() in cart.steps.ts. Signal: the path guard, because the glue lives under features/. Recovery: keep step definitions inside the locked path; a glue change goes through a bdd-contract pull request, which CODEOWNERS routes to a tech lead rather than the product owner.
--dry-run reports undefined steps and still exits 0. With @cucumber/cucumber 13.2.1 (checked 2026-09-26), cucumber-js --dry-run exits 0 even when steps are undefined, with or without --strict. A real run exits 1 on undefined or pending steps. Recovery: gate on the full run, and use dry runs only for snippets and the usage report.
Scenarios read like click scripts. “When I click #add-btn” breaks on every redesign and tempts the agent to edit the feature file. Recovery: rewrite steps in the shopper’s terms (“adds 2 to the cart”) and keep selectors in the glue; the example-mapping prompt asks for declarative steps for this reason.
The implementation special-cases the examples. The code checks for "Super Widget". Signal: a new Examples row fails, or a reviewer spots literals from the table in application code. Recovery: add rows through a contract pull request, and keep one set of rows as a holdout that only CI runs, as described on protecting the oracle.
Steps race the UI and flake in CI. Signal: people rerun the job until green. Recovery: replace page.waitForTimeout with web-first assertions, seed state through the test API instead of the UI, and fix a flake before adding scenarios. If flakes persist, move the suite to playwright-bdd for Playwright’s traces, fixtures and per-test retries (Cucumber.js has --retry, but no trace viewer).
Where to go next with BDD
Section titled “Where to go next with BDD”- Start from the general method, one criterion to one failing test, on executable acceptance criteria.
- Keep the feature-level spec that the scenarios come from authoritative: spec-driven development.
- Drive Playwright with an agent in more depth: end-to-end testing.
- Pair behavior-level scenarios with a unit-level red/green loop: test-driven development with AI.
- Name the domain terms your steps use consistently: domain-driven design with AI.