Skip to content

Behavior-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.

  • 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.

SituationUseWhy
A product owner, tester or domain expert approves scenariosGherkin + CucumberThey sign off on sentences, not test code
One rule has many example rows (prices, limits, locales)Gherkin Scenario OutlineThe Examples table makes special-casing visible and cheap to extend
You already maintain a Cucumber, Behat or SpecFlow suiteGherkinThe agent extends the existing step library instead of starting a second style
Only developers read the testsPlain Playwright or API testsThe glue layer costs more than it returns
The behavior has no user-visible boundaryUnit or property testsA 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.

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.

  1. 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.
  2. 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.
  3. 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.
  4. Implement until green in a fresh session that loads the lock, in its own worktree, on a branch stacked on the contract branch.
  5. 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:

features/cart/stock-limit.feature
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).

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:

Terminal window
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:

Terminal window
git worktree add ../cart-stock-impl -b cart-stock-impl cart-stock-contract
cd ../cart-stock-impl
ls features/cart/stock-limit.feature # the contract must be here

Keep 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 window
# Terminal, inside ../cart-stock-impl: interactive implementation
claude --disallowedTools "Edit(/features/**)"
# Terminal or CI: headless run with a spending cap
claude -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 5

Edit 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.

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.

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:

.github/workflows/bdd.yml
name: bdd
on: pull_request
permissions:
contents: read
jobs:
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 checkedHowWho signs off
The scenarios describe the right behaviorContract pull request with the feature fileProduct owner
The glue fails for the right reasonRed run pasted into the contract pull requestDeveloper or tech lead
The glue is strong enoughAudit prompt on executable acceptance criteria, mutation score on oracle strengthTech lead, per suite
The implementation meets the contractnpm run test:bdd green in CI, HTML report attachedCI
The contract did not move to fit the codePath guard and CODEOWNERSCI, 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.

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).