Published Sep 16, 2025 ⦁ Updated Nov 10, 2026 ⦁ 11 min read
BDD Scenarios: 10 Best Practices and a Feature File Template

BDD Scenarios: 10 Best Practices and a Feature File Template

Good BDD scenarios describe one behavior each, in business language, using Given-When-Then with about 3-5 steps. Keep one Feature per .feature file, group the files in folders by business domain, put each business rule in a Rule block, and use a small fixed set of tags to choose what runs. These BDD best practices come from the Cucumber Gherkin reference and Cucumber's guides on writing better Gherkin and anti-patterns. Below is a copy-paste BDD template, then ten rules, each with a bad feature file and a fixed one.

Every feature file on this page was parsed on 2026-11-10, and the template and the refactored example were run for real with Cucumber.js 13.3.0, Behave 1.3.3 and pytest-bdd 9.0.1. The BDD examples all use one small domain, a subscription shop with billing and accounts, so you can compare them. The syntax is plain Gherkin, so it also applies to Cucumber-JVM and Reqnroll, the .NET successor to SpecFlow.

3-5
steps per example recommended by the Gherkin reference
4 lines
the Background length after which the reference says to move detail into higher-level steps
1
Feature allowed per .feature file
Gherkin 6
the version that added the Rule keyword
70+
spoken languages Gherkin keywords are translated into
8.6M
npm downloads of @cucumber/cucumber, Oct 10 to Nov 8, 2026

A BDD feature file template

Save this as features/billing/subscription-upgrades.feature. It uses every structural keyword once, so you can delete what you don't need.

@billing
Feature: Subscription upgrades
  Customers on a paid plan can move to a higher plan at any time.
  They pay the prorated difference immediately.

  Background:
    Given Ana has an active "Starter" subscription billed monthly

  Rule: Upgrades are charged the prorated difference

    @smoke
    Scenario: Upgrade halfway through the billing period
      Given Ana is 15 days into a 30-day billing period
      When she upgrades to the "Pro" plan
      Then she is charged half the price difference
      And her plan is "Pro" immediately

    Scenario Outline: Proration at different points in the period
      Given Ana is <days> days into a 30-day billing period
      When she upgrades to the "Pro" plan
      Then she is charged <percent>% of the price difference

      Examples:
        | days | percent |
        | 0    | 100     |
        | 15   | 50      |
        | 29   | 3       |

  Rule: Downgrades take effect at the next renewal

    Scenario: Downgrade keeps current features until renewal
      When Ana downgrades to the "Free" plan
      Then she keeps "Starter" features until her renewal date
      And she is not charged or refunded today

What each part does:

  • Feature names the capability. The free text below it explains why it exists. Runners ignore it, and reports show it. A .feature file can only contain one Feature.
  • Background holds Given steps shared by every scenario in the Feature (or in one Rule, if you put it inside the rule). It runs before each scenario, after any Before hooks.
  • Rule groups the scenarios that illustrate one business rule. It has been part of Gherkin since version 6.
  • Scenario (or its synonym Example) is one concrete example of the rule.
  • Scenario Outline + Examples run the same steps once per table row, filling in the <placeholders>.
  • Tags (@billing, @smoke) let you filter runs and attach hooks. They are inherited, so @billing applies to every scenario in the file.

Step definitions that make the template pass

Here is the Cucumber.js code behind the template: a small World object that holds the state for one scenario, and one step definition file for the billing domain.

// features/support/world.js
import { setWorldConstructor, World } from '@cucumber/cucumber';

const PRICES = { Free: 0, Starter: 10, Pro: 30 };

class ShopWorld extends World {
  customer = null;
  charge = null;
  account = null;

  subscribe(name, plan) {
    this.customer = { name, plan, featuresUntilRenewal: plan, day: 0, periodLength: 30 };
  }

  upgrade(plan) {
    const { day, periodLength } = this.customer;
    const difference = PRICES[plan] - PRICES[this.customer.plan];
    const percent = Math.round(((periodLength - day) / periodLength) * 100);
    this.charge = { percentOfDifference: percent, amount: (difference * percent) / 100 };
    this.customer.plan = plan;
    this.customer.featuresUntilRenewal = plan;
  }

  downgrade(plan) {
    this.charge = { percentOfDifference: 0, amount: 0 };
    this.customer.nextPlan = plan;
  }
}

setWorldConstructor(ShopWorld);
// features/step_definitions/billing.steps.js
import { Given, When, Then } from '@cucumber/cucumber';
import assert from 'node:assert/strict';

Given('Ana has an active {string} subscription billed monthly', function (plan) {
  this.subscribe('Ana', plan);
});

Given('Ana is {int} days into a {int}-day billing period', function (day, length) {
  this.customer.day = day;
  this.customer.periodLength = length;
});

When('she upgrades to the {string} plan', function (plan) {
  this.upgrade(plan);
});

When('Ana downgrades to the {string} plan', function (plan) {
  this.downgrade(plan);
});

Then('she is charged half the price difference', function () {
  assert.equal(this.charge.percentOfDifference, 50);
});

Then('she is charged {int}% of the price difference', function (percent) {
  assert.equal(this.charge.percentOfDifference, percent);
});

Then('her plan is {string} immediately', function (plan) {
  assert.equal(this.customer.plan, plan);
});

Then('she keeps {string} features until her renewal date', function (plan) {
  assert.equal(this.customer.featuresUntilRenewal, plan);
});

Then('she is not charged or refunded today', function () {
  assert.equal(this.charge.amount, 0);
});

With "type": "module" in package.json and @cucumber/cucumber 13.3.0 installed as a dev dependency, this is the real output:

$ npx cucumber-js features/billing
..........................

5 scenarios (5 passed)
26 steps (26 passed)
0m 0.011s (0m 0.000s executing your code)

The outline counts as three scenarios, one per Examples row. The step count is 21 Gherkin steps plus 5 runs of a @billing Before hook (shown in practice 9), because Cucumber.js counts hooks in that total.

If you came for BDD feature file examples in Python, the same file runs unchanged under Behave and pytest-bdd. Their step definitions are in the framework section.

Gherkin keywords at a glance

This table follows the Gherkin reference and the localisation table. Synonyms are interchangeable. Pick one per project.

Keyword Synonym What it does Rule to remember
Feature Business Need, Ability Names one capability and groups its scenarios First keyword in the file. One per file
Rule Groups the examples of one business rule Gherkin 6+. Can have its own Background and tags
Example Scenario One concrete example, written as steps Aim for 3-5 steps
Given * Puts the system in a known state No user interaction here
When * The event or action under test Usually one per scenario
Then * An outcome someone outside the system can observe Assert on output, not database rows
And, But * Continue the previous step type Keywords are not part of the step match
Background Given steps run before each scenario One per Feature or Rule. No tags. Before the first scenario
Scenario Outline Scenario Template A scenario run once per table row Needs an Examples table
Examples Scenarios The table of values for an outline Can be tagged. Several allowed
Doc String A block of text between triple quotes Passed as the step's last argument
Data Table Rows of cells between pipe characters Passed as the step's last argument
@tag Labels for selection and hooks Above Feature, Rule, Scenario, Scenario Outline or Examples only
# Comment Start of a line only. No block comments

Structural keywords take a colon (Feature:, Rule:, Background:, Scenario:, Examples:) and step keywords do not. Get this wrong and nothing warns you. In a test run for this guide, a step was written Given: Ana has an account. Cucumber.js 13.3.0 and Behave 1.3.3 both read that line as scenario description, so the step silently disappeared and the scenario failed on the next step with an unrelated error.

For a slower walk through each keyword, see the Gherkin syntax guide.

The BDD workflow: where feature files fit

Feature files are the middle of the BDD workflow, not the start. Cucumber's description of BDD names three practices that repeat for every change:

  1. Discovery. Product, testing and development talk through concrete examples of a user story and agree on scope.
  2. Formulation. Someone writes those examples as Gherkin, and the group checks the wording.
  3. Automation. A developer connects one example at a time to the system as a failing test, then makes it pass.

Organization problems start in step 2, when examples are typed into whatever file is open. Example Mapping gives you a structure that converts directly to Gherkin. Behave's release notes say Rule was added to the grammar to match it:

Example Mapping card Becomes in the feature file
Yellow card: the story The Feature (or a new part of an existing one)
Blue card: a rule or acceptance criterion A Rule
Green card: an example of that rule An Example or Scenario under the rule
Red card: an open question Nothing yet. Answer it before writing a scenario

A scenario is a specification and a test at once. If your team says BDD test cases or BDD test scenarios, they mean the same objects: each scenario, and each Examples row, is one test case in the runner's report. pytest-bdd collected the two feature files from this page as 8 pytest tests.

Folder structure

Keep all BDD files for one application in a single tree: features grouped by business domain, step definitions grouped by domain concept, and support code (World, hooks) apart.

features/
  billing/
    subscription-upgrades.feature
    invoices.feature
  accounts/
    sign-in.feature
    password-reset.feature
  step_definitions/
    billing.steps.js
    accounts.steps.js
  support/
    world.js
    hooks.js
cucumber.json

Cucumber.js finds features/**/*.{feature,feature.md} by default and loads support code from features/**/*.@(js|cjs|mjs) when your features live under features/ (configuration docs). So this layout needs no configuration. The other frameworks need small changes:

Framework Feature files Step definitions and hooks Config file
Cucumber.js features/**/*.feature (default glob) Any .js, .cjs or .mjs under features/ cucumber.json, cucumber.yaml or cucumber.mjs
Cucumber-JVM src/test/resources/<package>/ Classes in src/test/java/<package>/, found through the glue package junit-platform.properties
Behave features/, subfolders allowed features/steps/*.py and features/environment.py behave.ini
pytest-bdd Any folder. Set bdd_features_base_dir in pytest.ini conftest.py or modules pytest imports. A test module calls scenarios("billing", "accounts") pytest.ini
Reqnroll Any folder in the test project Classes marked [Binding], global to the project reqnroll.json

Sources: the Cucumber 10-minute tutorial for the JVM paths, the Behave setup docs, the pytest-bdd README and the Reqnroll docs. The Behave and pytest-bdd layouts were run with billing/ and accounts/ subfolders, and both found every scenario.

Two folder facts to know:

  • Folders do not scope step definitions. Cucumber's step organization guide says the runner in effect flattens the features/ tree. A step defined for billing is visible to accounts.
  • Reqnroll generates a *.feature.cs file next to each feature file. Its setup docs advise keeping those out of source control.

10 best practices for BDD features and scenarios

Each practice has the rule, a bad example, the fixed version and the source.

1. One feature per file, named after the capability

Name files after what the system does for the user: password-reset.feature, not JIRA-4812.feature or test3.feature. A product owner should be able to find the rules for password resets by browsing the folder.

Bad, a file named JIRA-4812.feature that collects two features:

Feature: Sign in

  Scenario: Correct password signs the customer in
    Given Ana has an account

Feature: Password reset

  Scenario: Reset link is emailed
    Given Ana has an account

This does not parse. Gherkin allows one Feature per file, and each runner says so in its own words:

# Cucumber.js 13.3.0
Parse error in "bad/two-features.feature" (6:1): expected: #EOF, #TableRow, #DocStringSeparator, #StepLine, #TagLine, #ExamplesLine, #ScenarioLine, #RuleLine, #Comment, #Empty, got 'Feature: Password reset'

# Behave 1.3.3
Parser failure in state=steps at line 6: "Feature: Password reset"
REASON: Multiple features in one file are not supported.

# pytest-bdd 9.0.1
pytest_bdd.exceptions.FeatureError: Multiple features are not allowed in a single feature file.

Fixed, two files, with the ticket kept as a tag:

# features/accounts/sign-in.feature
@accounts @JIRA-4812
Feature: Sign in

  Scenario: Correct password signs the customer in
    Given Ana has an account
    When Ana signs in with the correct password
    Then Ana sees her dashboard
# features/accounts/password-reset.feature
@accounts @JIRA-4812
Feature: Password reset

  Scenario: Reset link is emailed
    Given Ana has an account
    When Ana requests a password reset
    Then Ana receives a reset email

The Cucumber docs suggest tags for linking to tickets. File names stay stable when the ticket is closed.

2. Group features by business domain

Folders mirror how the business talks about the product: billing, accounts, search, notifications. Avoid splitting by sprint or by test type. The same behavior may be checked at several layers, and the domain is what stays stable.

Bad, a tree that records how the work was scheduled:

features/
  sprint-41/
    JIRA-4812.feature
    JIRA-4830.feature
  ui/
    login_page.feature
  api/
    login_endpoint.feature
  regression/
    misc.feature
# features/regression/misc.feature
Feature: Regression pack 3

  Scenario: Check invoice and login
    Given Ana has a paid invoice
    And Ana has an account

Fixed, a tree that records what the product does, with the layer expressed as a tag when you need it:

features/
  accounts/
    sign-in.feature
    password-reset.feature
  billing/
    invoices.feature
    subscription-upgrades.feature
# features/billing/invoices.feature
@billing
Feature: Invoices
  Every charge produces an invoice the customer can download.

  @api
  Scenario: Paid invoices can be downloaded as PDF
    Given Ana has a paid invoice
    When she downloads the invoice
    Then she receives it as a PDF

Use folders for the one grouping that never changes (the domain) and tags for everything else (layer, speed, status).

3. Use Rule to show the business rules

Before Rule existed, long feature files were a flat list of scenarios, and readers had to guess which rule each one illustrated. Put the rule in plain words after Rule: and the examples under it. If you can't state the rule in one sentence, you probably have two rules.

Bad, the rules are hidden in scenario titles:

Feature: Sign in

  Scenario: Sign in works
    Given Ana has an account
    When Ana signs in with the correct password
    Then Ana sees her dashboard

  Scenario: Lockout test 1
    Given Ana has an account
    And Ana has entered a wrong password 2 times
    When Ana signs in with the correct password
    Then Ana sees her dashboard

  Scenario: Lockout test 2
    Given Ana has an account
    And Ana has entered a wrong password 2 times
    When Ana signs in with a wrong password
    Then Ana's account is locked for 15 minutes

Fixed, each rule is stated once and owns its examples and its Background:

@accounts
Feature: Sign in
  Registered customers sign in with their email and password.

  Rule: A correct password signs the customer in

    @smoke
    Example: Correct password
      Given Ana has an account
      When Ana signs in with the correct password
      Then Ana sees her dashboard

  Rule: Three wrong passwords lock the account for 15 minutes

    Background:
      Given Ana has an account
      And Ana has entered a wrong password 2 times

    Example: Two wrong passwords do not lock the account
      When Ana signs in with the correct password
      Then Ana sees her dashboard

    @slow
    Example: Third wrong password locks the account
      When Ana signs in with a wrong password
      Then Ana's account is locked for 15 minutes

4. Write declarative steps, not UI scripts

Describe what the user achieves, not which buttons they press. Cucumber's guide on writing better Gherkin gives the test: if the wording would have to change when the implementation changes, rewrite it.

Bad, an imperative script that breaks when the page changes:

# Imperative: breaks when the UI changes
Feature: Sign in

  Scenario: Login
    Given I go to "/login"
    When I type "ana@example.com" into "#email"
    And I type "Passw0rd!" into "#password"
    And I click "#submit"
    Then I should see "Welcome back, Ana"

Fixed, a declarative example that survives a redesign:

# Declarative: survives a redesign
Feature: Sign in

  Scenario: Correct password signs the customer in
    Given Ana has an account
    When Ana signs in with the correct password
    Then Ana sees her dashboard

The click-by-click detail moves into the step definition, where developers can change it in one place.

Also split steps that join two things. The anti-patterns page calls these conjunction steps: Given Ana has an account and a paid invoice can only be reused when you need both. Two steps joined by And can be reused separately.

5. Given-When-Then, with 3-5 steps

The Gherkin reference recommends 3-5 steps per example. Use each keyword for its job:

  • Given puts the system in a known state. Avoid describing user interaction here.
  • When is the one action or event under test.
  • Then checks an outcome the user could observe, not a database row or an internal flag.

Bad, seven steps, interaction in Given, a database check in Then, and three When-Then pairs:

Feature: Invoices

  Scenario: Invoice stuff
    Given Ana opens the billing page and clicks "Upgrade"
    When she picks "Pro"
    Then the plan is "Pro"
    When she opens "Invoices"
    Then the "invoices" table has 1 row with status "paid"
    When she clicks "Download"
    Then a PDF downloads

Fixed, two examples of three steps each:

@billing
Feature: Invoices

  Rule: Every charge produces an invoice

    Example: Upgrade charge appears as a paid invoice
      Given Ana has an active "Starter" subscription billed monthly
      When she upgrades to the "Pro" plan
      Then her invoice list shows a paid invoice for the upgrade

    Example: Paid invoices can be downloaded as PDF
      Given Ana has a paid invoice
      When she downloads the invoice
      Then she receives it as a PDF

A scenario with several Whens usually tests several behaviors. Split it.

6. One behavior per scenario, and keep scenarios independent

Each scenario should pass or fail for one reason, and run in any order. Cucumber's page on state answers the question of sharing state between scenarios in three words: "Don't do it."

Bad, the second scenario only works if the first ran before it:

Feature: Password reset

  Scenario: Request a reset link
    Given Ana has an account
    When Ana requests a password reset
    Then Ana receives a reset email

  Scenario: Use the reset link
    When Ana opens the link from the previous scenario
    And Ana sets a new password
    Then Ana can sign in with the new password

Fixed, each scenario builds its own starting state:

@accounts
Feature: Password reset

  Rule: A reset link lets the customer choose a new password

    Example: Reset link is emailed
      Given Ana has an account
      When Ana requests a password reset
      Then Ana receives a reset email

    Example: Reset link sets a new password
      Given Ana has requested a password reset
      When Ana sets a new password with her reset link
      Then Ana can sign in with the new password

The state page lists the usual leaks: global or static variables, a database not cleaned in a Before hook, and browser cookies kept between scenarios. Two cheap checks expose order dependencies in Cucumber.js, and the sample project passes both:

npx cucumber-js --order random
npx cucumber-js --parallel 2

--order random shuffles scenarios (add a seed such as random:42 to repeat a run), and --parallel runs them in worker threads since Cucumber.js 13.0.0 (filtering docs, parallel docs).

7. Keep Background short and vivid

Only use Background for Given steps that every scenario in the file (or rule) needs. The reference's advice: keep it short, avoid complicated setup the reader doesn't need to know, and use vivid names ("Ana has a Starter plan") instead of "User A". If the Background scrolls off the screen, readers forget it is there.

Bad, six lines of setup with anonymous users and technical detail:

Feature: Sign in

  Background:
    Given the database is reset
    And a user "User A" exists with email "usera@example.com" and password "Passw0rd!"
    And a user "User B" exists with email "userb@example.com" and password "Passw0rd!"
    And the user "User B" has role "admin"
    And the browser is open
    And I am on "/login"

  Scenario: Login
    When "User A" logs in
    Then "User A" sees the dashboard

Fixed, one line the reader has to remember, and the rest moved into hooks and step definitions:

@accounts
Feature: Sign in

  Background:
    Given Ana has an account

  Scenario: Correct password signs the customer in
    When Ana signs in with the correct password
    Then Ana sees her dashboard

The hard limits from the reference: one Background per Feature or Rule, placed before the first scenario, with no tags. Break one and the file fails to parse (exact messages are in the troubleshooting table). If two groups of scenarios need different setup, split them into more rules or more features. Database resets and browser startup are not behavior, so they belong in a Before hook.

8. Use Scenario Outlines for data, not for different behaviors

Outlines are for the same behavior with different inputs: boundary values, currencies, roles. If rows need different Then wording or different steps, they are different behaviors and should be separate scenarios.

Bad, an outline used as a container for unrelated checks:

Feature: Sign in

  Scenario Outline: Misc checks
    When I <action>
    Then I see <result>

    Examples:
      | action                        | result                |
      | click "Forgot password"       | "Reset your password" |
      | type "x" into "#email"        | "Enter a valid email" |
      | click "#submit" with no input | "Email is required"   |

Fixed, one behavior with the values that matter, and the boundary rows tagged separately:

@billing
Feature: Proration

  Background:
    Given Ana has an active "Starter" subscription billed monthly

  Scenario Outline: Proration at different points in the period
    Given Ana is <days> days into a 30-day billing period
    When she upgrades to the "Pro" plan
    Then she is charged <percent>% of the price difference

    @smoke
    Examples: Typical day
      | days | percent |
      | 15   | 50      |

    @edge
    Examples: First and last day
      | days | percent |
      | 0    | 100     |
      | 29   | 3       |

Running this with --tags "@edge" executed only the two boundary rows in both Cucumber.js and Behave. Keep tables small. Five meaningful rows beat fifty generated ones. See BDD scenario outline tips and examples for more.

9. Use tags for selection and hooks, with a small fixed vocabulary

Agree on a short list, for example @smoke, @wip, @slow, plus one tag per domain, and document it. Tags can sit on Feature, Rule, Scenario, Scenario Outline and Examples, and are inherited downward. They can't go on Background or steps.

Bad, tags added by whoever touched the file last:

@test @Regression @regression_suite @sprint42 @johns @fix_later @Smoke
Feature: Sign in

  @smoke_test @TC-01 @P1 @p1
  Scenario: Correct password signs the customer in
    Given Ana has an account
    When Ana signs in with the correct password
    Then Ana sees her dashboard

Fixed, one domain tag on the feature and one purpose tag where it applies:

@accounts
Feature: Sign in

  @smoke
  Scenario: Correct password signs the customer in
    Given Ana has an account
    When Ana signs in with the correct password
    Then Ana sees her dashboard

A vocabulary that covers most suites:

Tag type Examples Placed on Used for
Domain @billing, @accounts Feature Running one area. Scoping hooks
Speed or suite @smoke, @slow Scenario, Rule or Examples Choosing what runs on each commit and what runs nightly
Status @wip Scenario Keeping unfinished work out of the main run
Layer @api, @ui Scenario or Feature Starting a browser only when needed
Traceability @JIRA-4812 Feature or Scenario Linking to a ticket

Run subsets with tag expressions such as "@smoke and not @wip". Hooks can target tags too. These are the hooks from the sample project:

// features/support/hooks.js
import { Before, After } from '@cucumber/cucumber';

Before({ tags: '@billing' }, function () {
  this.charge = null;
});

After({ tags: '@accounts and not @slow' }, function () {
  this.account = null;
});

The full syntax and the per-framework commands are in the tag strategy section below.

10. Organize step definitions by domain concept, not by feature file

Cucumber's step organization guide recommends one step-definition file per major domain object (billing, accounts, authentication), not one per feature. The anti-patterns page calls the opposite feature-coupled step definitions and lists the cost: more step definitions, duplicated code and high maintenance.

Bad, step files that shadow feature files, each with its own copy of the common steps:

features/
  sign-in.feature
  password-reset.feature
  step_definitions/
    sign-in.steps.js          # defines "Ana has an account"
    password-reset.steps.js   # defines "Ana has an account" again
# features/password-reset.feature
Feature: Password reset

  Scenario: Reset link is emailed
    Given Ana has an account
    When Ana requests a password reset
    Then Ana receives a reset email

With two definitions of the same text, Cucumber.js refuses to choose. This is the real output from the sample project after a duplicate was added in a second file:

Ambiguous scenarios:
  1) Correct password # features/accounts/sign-in.feature:8
       Given Ana has an account
           Multiple matching step definitions found:
             • Ana has an account # features/step_definitions/accounts.steps.js:4
             • Ana has an account # dup/dup.steps.js:3

Fixed, one file per domain concept, used by every feature in that domain:

features/
  accounts/
    sign-in.feature
    password-reset.feature
  step_definitions/
    accounts.steps.js         # the only place "Ana has an account" is defined
    billing.steps.js
// features/step_definitions/accounts.steps.js
import { Given, When, Then } from '@cucumber/cucumber';
import assert from 'node:assert/strict';

Given('Ana has an account', function () {
  this.account = { failedAttempts: 0, lockedMinutes: 0, signedIn: false };
});

Given('Ana has entered a wrong password {int} times', function (times) {
  this.account.failedAttempts = times;
});

When('Ana signs in with the correct password', function () {
  this.account.signedIn = this.account.lockedMinutes === 0;
});

When('Ana signs in with a wrong password', function () {
  this.account.failedAttempts += 1;
  if (this.account.failedAttempts >= 3) this.account.lockedMinutes = 15;
});

Then('Ana sees her dashboard', function () {
  assert.equal(this.account.signedIn, true);
});

Then("Ana's account is locked for {int} minutes", function (minutes) {
  assert.equal(this.account.lockedMinutes, minutes);
});

Remember that Cucumber matches steps by their text and ignores the keyword. Given Ana signs in and When Ana signs in are the same step, so you can't define both. The duplicate above was registered with When, and it still collided with the Given. Behave differs: @given("Ana has an account") and @when("Ana has an account") ran side by side in a test, while two @given definitions with the same text stopped the run with behave.step_registry.AmbiguousStep.

Two more rules from the step organization guide:

  • Don't write step definitions for steps no scenario uses yet.
  • Merge steps that differ by one word (Ana opens the billing page, Ana opens the invoices page) into one parameterized step such as Ana opens the {string} page.

If you match steps with regular expressions instead of Cucumber Expressions, try the pattern in the regex tester first. A greedy (.*) can make two definitions match one step.

Tag strategy and tag expression syntax

A tag expression is a boolean expression over tags, with the operators and, or and not, and parentheses for grouping. The syntax is defined in the cucumber/tag-expressions project. Behave adopted it as tag expressions v2.

Expression Runs
@smoke Scenarios tagged @smoke, directly or through inheritance
not @wip Everything except @wip
@smoke and @billing Scenarios that carry both tags
@billing or @accounts Scenarios that carry either tag
(@billing or @accounts) and not @slow Either domain, minus slow scenarios

If your scripts still use the old tilde and comma syntax, the tag-expressions README gives the translation:

Old style Tag expression
--tags ~@wip --tags "not @wip"
--tags @billing,@accounts --tags "@billing or @accounts"
--tags @smoke --tags @billing --tags "@smoke and @billing"
--tags ~@wip --tags @billing,@accounts --tags "not @wip and (@billing or @accounts)"

The same selection in each framework:

# Cucumber.js
npx cucumber-js --tags "@smoke and not @slow"

# Cucumber-JVM with Maven and the JUnit Platform engine
mvn test -Dcucumber.filter.tags="@smoke and not @slow"

# Behave
behave --tags="@smoke and not @slow"

# pytest-bdd: tags become pytest markers, written without the @
pytest -m "smoke and not slow"

# Reqnroll: tags become test categories, written without the @
dotnet test --filter "TestCategory=smoke&TestCategory!=slow"

The Cucumber.js, Behave and pytest-bdd lines were run against the sample project, and each selected the same two scenarios. The JVM line is from the Cucumber API docs. The Reqnroll line combines its filter docs (use Category instead of TestCategory with xUnit) with the operators in Microsoft's filter reference. Those two lines were not run.

Details that differ between tools:

  • Repeating the option means AND. In Cucumber.js, --tags "@accounts" --tags "not @slow" ran the 2 scenarios that match both.
  • Cucumber-JVM also maps tags to JUnit tags. With Maven you can use -Dgroups="smoke" and -DexcludedGroups="slow", but those take JUnit tag expressions (&, |, !), not Cucumber ones (engine README).
  • pytest-bdd wants Python-friendly names. Its README asks for tags that are valid Python identifiers, so write @JIRA_4812, not @JIRA-4812. With --strict-markers, every tag must be listed under markers in pytest.ini, or collection fails with 'slow' not found in `markers` configuration option.
  • Reqnroll hook tags are OR-ed. A hook such as [BeforeScenario("@billing")] can list several tags, and it runs for scenarios that have at least one of them (hooks docs).

Behave 1.3.3 still accepts the old ~@slow and comma forms, because its tag_expression_protocol setting defaults to auto_detect. Its tag expression docs say the old style will be dropped in Behave 1.4.0.

Before and after: refactoring one messy feature file

This is what grows when a manual test script is pasted into Gherkin. It parses, and it breaks nearly every practice above.

# features/login_tests.feature (before)
@test @regression @sprint42 @JIRA-4812 @fix_later
Feature: Login tests

  Background:
    Given the database is reset
    And a user "User A" exists with email "usera@example.com" and password "Passw0rd!"
    And a user "User B" exists with email "userb@example.com" and password "Passw0rd!"
    And the user "User B" has role "admin"
    And the browser is open
    And I am on "/login"

  Scenario: TC-01 login
    When I type "usera@example.com" into "#email"
    And I type "Passw0rd!" into "#password"
    And I click "#submit"
    Then the URL is "/dashboard"
    And the "sessions" table has 1 row for "usera@example.com"
    When I click "#logout"
    Then the URL is "/login"

  Scenario: TC-02 lockout
    When I type "usera@example.com" into "#email"
    And I type "wrong" into "#password"
    And I click "#submit"
    And I type "wrong" into "#password"
    And I click "#submit"
    And I type "wrong" into "#password"
    And I click "#submit"
    Then I see "Account locked for 15 minutes"

  Scenario Outline: TC-03 misc checks
    When I <action>
    Then I see <result>

    Examples:
      | action                        | result                |
      | click "Forgot password"       | "Reset your password" |
      | type "x" into "#email"        | "Enter a valid email" |
      | click "#submit" with no input | "Email is required"   |

The refactor, step by step:

  1. Find the rules. Two are about signing in: a correct password signs you in, and three wrong passwords lock the account for 15 minutes. Sign-out, password reset and email validation move to their own files.
  2. Rename the file and feature after the capability: features/accounts/sign-in.feature, Feature: Sign in.
  3. Rewrite each scenario as one example of one rule, in declarative steps. The typed passwords become a Given that states the starting point.
  4. Shrink the Background to what the reader must know, and scope it to the rule that needs it.
  5. Replace the tag pile with one domain tag and purpose tags.
  6. Delete the catch-all outline. Each of its rows belongs to another feature.
# features/accounts/sign-in.feature (after)
@accounts
Feature: Sign in
  Registered customers sign in with their email and password.

  Rule: A correct password signs the customer in

    @smoke
    Example: Correct password
      Given Ana has an account
      When Ana signs in with the correct password
      Then Ana sees her dashboard

  Rule: Three wrong passwords lock the account for 15 minutes

    Background:
      Given Ana has an account
      And Ana has entered a wrong password 2 times

    Example: Two wrong passwords do not lock the account
      When Ana signs in with the correct password
      Then Ana sees her dashboard

    @slow
    Example: Third wrong password locks the account
      When Ana signs in with a wrong password
      Then Ana's account is locked for 15 minutes
Before After
Steps a reader must hold in their head 6 Background steps plus up to 8 per scenario At most 4 per example
Business rules stated 0 2
Tags 5, none usable for selection 3, each with one purpose
Boundary covered Only the third wrong attempt The second attempt (not locked) and the third (locked)

The "after" file is a BDD example you can run. With the accounts.steps.js file from practice 10 it passes in all three runners used for this guide:

$ npx cucumber-js features/accounts
.............

3 scenarios (3 passed)
13 steps (13 passed)
0m 0.008s (0m 0.000s executing your code)

$ behave
2 features passed, 0 failed, 0 skipped
4 rules passed, 0 failed, 0 skipped
8 scenarios passed, 0 failed, 0 skipped
32 steps passed, 0 failed, 0 skipped
Took 0min 0.000s

$ pytest -q
........                                                                 [100%]
8 passed in 0.02s

The Behave and pytest lines cover both feature files in the project, which is why they show 8 scenarios.

How each framework maps the same feature file

The feature file is portable. What changes is how steps are bound, where state lives and how tags reach the runner.

Cucumber-JVM Cucumber.js Behave pytest-bdd Reqnroll
Latest version, as of November 2026 8.0.4 13.3.0 1.3.3 9.0.1 3.3.4
Runtime Java 17 or later Node.js 22, 24 or 26+ Python 3 (its PyPI metadata still allows 2.7) Python 3.10 or later .NET 8, 9, 10 and .NET Framework 4.6.2+
Bind a step @Given("...") on a method Given('...', fn) @given('...') on a function @given(parsers.parse('...')) on a function [Given("...")] on a method in a [Binding] class
State within a scenario Fields of the step class, created fresh for each scenario The World (this) The context object pytest fixtures (target_fixture) Context injection or ScenarioContext
Step keyword matters No No Yes Yes Yes, unless you use [StepDefinition]
Hook for a tag @Before("@billing") Before({ tags: '@billing' }, fn) before_tag(context, tag) in environment.py A fixture, or the pytest_bdd_apply_tag hook [BeforeScenario("@billing")]

Versions and runtimes come from each project's registry page and changelog, checked on 2026-11-10. All five are open source: MIT for Cucumber-JVM, Cucumber.js and pytest-bdd, BSD-2-Clause for Behave and BSD-3-Clause for Reqnroll. Cucumber-JVM 8.0.0 (September 24, 2026) raised the baseline to Java 17 and deprecated cucumber-junit (JUnit 4) and cucumber-testng, so new JVM projects should start on cucumber-junit-platform-engine.

Here is the same Then step from the template in each framework. The first three are from the projects that were run.

// Cucumber.js: features/step_definitions/billing.steps.js
Then('she is charged {int}% of the price difference', function (percent) {
  assert.equal(this.charge.percentOfDifference, percent);
});
# Behave: features/steps/billing_steps.py
from behave import then


@then("she is charged {percent:d}% of the price difference")
def step_charged_percent(context, percent):
    assert context.charge["percent"] == percent
# pytest-bdd: tests/conftest.py
from pytest_bdd import parsers, then


@then(parsers.parse("she is charged {percent:d}% of the price difference"))
def charged_percent(charge, percent):
    assert charge["percent"] == percent

In pytest-bdd, charge is a fixture produced by the When step, declared as @when(parsers.parse('she upgrades to the "{plan}" plan'), target_fixture="charge"). The test module is two lines:

# tests/test_features.py
from pytest_bdd import scenarios

scenarios("billing", "accounts")

The next two follow the official docs but were not run for this guide, because the test machine had no Maven or Gradle and no .NET SDK. Billing stands for your own application class.

// Cucumber-JVM: src/test/java/com/example/billing/BillingSteps.java
package com.example.billing;

import io.cucumber.java.en.Then;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class BillingSteps {
    private final Billing billing = new Billing();

    @Then("she is charged {int}% of the price difference")
    public void sheIsChargedPercent(int percent) {
        assertEquals(percent, billing.lastCharge().percentOfDifference());
    }
}
// Reqnroll: StepDefinitions/BillingSteps.cs
using System;
using Reqnroll;

[Binding]
public class BillingSteps
{
    private readonly Billing _billing = new();

    [Then("she is charged {int}% of the price difference")]
    public void ThenSheIsChargedPercent(int percent)
    {
        if (_billing.LastCharge.PercentOfDifference != percent)
            throw new Exception("Unexpected proration percent");
    }
}

On the JVM you also need a suite class. The engine README shows it: a class annotated with @Suite, @IncludeEngines("cucumber") and @SelectPackages("com.example"), with the glue package set through @ConfigurationParameter.

SpecFlow is retired: use Reqnroll for .NET

If your .NET project still references SpecFlow, plan the move. This is what primary sources showed on 2026-11-10:

  • specflow.org redirects to a Tricentis community page that states SpecFlow has been retired.
  • The SpecFlowOSS GitHub organization shows no public repositories and a notice that it was archived on January 13, 2025.
  • On NuGet, the last stable SpecFlow package is 3.9.74 from May 3, 2022, and the package is flagged as deprecated (legacy).
  • The Reqnroll project wrote in its end-of-life announcement that Tricentis set the end of life for December 31, 2024, and that Reqnroll is a fork of the SpecFlow codebase under a new name because the SpecFlow name is trademarked.

The migration guide describes two paths: a compatibility package that keeps the TechTalk.SpecFlow namespace working, or a search-and-replace to the Reqnroll namespace. It also warns that SpecFlow+ LivingDoc was closed source and has no direct replacement.

  1. Feb 2018Behave 1.2.6 is published. The next stable release on PyPI comes seven years later.
  2. May 3, 2022SpecFlow 3.9.74, the last stable SpecFlow package on NuGet.
  3. Feb 8, 2024Reqnroll 1.0.0 is published on NuGet as a fork of SpecFlow.
  4. Nov 14, 2024pytest-bdd 8.0.0 adds the Rule keyword and switches to the official Gherkin parser (changelog).
  5. Dec 31, 2024SpecFlow end of life, per the Tricentis notice quoted in Reqnroll's announcement. The GitHub organization is archived on January 13, 2025.
  6. Aug 4, 2025Behave 1.3.0 reaches PyPI with Rule support and Cucumber-style tag expressions.
  7. Jun 2, 2026Cucumber.js 13.0.0 moves the parallel runtime to worker threads and drops Node.js 20 (changelog).
  8. Sep 24, 2026Cucumber-JVM 8.0.0: Java 17 baseline, JUnit Platform 6, cucumber-junit and cucumber-testng deprecated.

Which framework to pick

Downloads in the last 30 days, npm and PyPI packages only
@cucumber/cucumber (npm) 8.62M
playwright-bdd (npm) 3.75M
behave (PyPI) 3.52M
pytest-bdd (PyPI) 3.11M

Registries count CI installs, so treat these as activity, not users. Maven Central and NuGet do not publish a comparable monthly figure. Source: npm downloads API (Oct 10 to Nov 8, 2026) and PyPI Stats (last month, read Nov 10, 2026)

Best for Java, Kotlin and Scala projects: Cucumber-JVM
The official Cucumber implementation for the JVM. Runs on the JUnit Platform, so Maven, Gradle and IDEs discover scenarios like any other test.
Best for JavaScript and TypeScript projects: Cucumber.js
Zero-config features/ layout, built-in parallel and random order. If your tests already use Playwright Test, look at playwright-bdd, which runs Gherkin through the Playwright runner.
Best for Python teams that already use pytest: pytest-bdd
Scenarios become pytest tests, tags become markers and state lives in fixtures, so your existing plugins and conftest.py keep working.
Best for Python teams that want a standalone BDD runner: Behave
Closest to Cucumber's model: a context object, environment.py hooks, and the most readable parse errors of the three runners tested here.
Best for .NET projects, including every former SpecFlow suite: Reqnroll
Actively released, supports .NET 8 to 10, and works with NUnit, MSTest, xUnit and TUnit.

Choose by the language your application team already works in, so step definitions can call the same helpers as the rest of your tests. For a wider comparison that includes non-Gherkin tools, see popular test automation frameworks compared. If you take the playwright-bdd route, Playwright is listed on DevHunt.

Troubleshooting: parse errors and runner messages

Every message below is copied from a run on 2026-11-10 (Cucumber.js 13.3.0, Behave 1.3.3, pytest-bdd 9.0.1), with long token lists shortened to .... pytest-bdd uses the official Gherkin parser, so its parse errors carry the same expected: ... got ... text as Cucumber.js.

What you see Cause Fix
(6:1): expected: #EOF, #TableRow, ... got 'Feature: Password reset', or in Behave Multiple features in one file are not supported. Two Feature keywords in one file Split into two files (practice 1)
(4:3): expected: #TagLine, #RuleLine, #Comment, #Empty, got 'Background:', or in Behave Background does not support tags. A tag above Background Move the tag to the Feature or Rule
(6:3): expected: #EOF, #TableRow, ... got 'Background:', or in Behave Background should not be used here. A second Background in the same Feature or Rule Merge them, or split the scenarios into two rules
In Behave Background may not occur after Scenario/ScenarioOutline. (Cucumber.js prints the same got 'Background:' error) Background placed below a scenario Put it before the first scenario
Multiple matching step definitions found: (Cucumber.js) or AmbiguousStep: @given('...') has already been defined (Behave) Two step definitions match the same text Keep one, in the domain file (practice 10)
Undefined step whose text still contains <times> Examples header does not match the placeholder, or the outline has no Examples Make header and placeholder identical
A step never runs and nothing reports it A colon after a step keyword (Given:) turns the line into description text Remove the colon
0 scenarios and exit code 0 The tag expression matches nothing: a typo, or old ~@tag or comma syntax Fix the expression. Assert the scenario count in CI

Keep the suite healthy

  • Review features with the people who own the rules. BDD scenarios are meant to be read by product owners and testers, not only run by CI. Cucumber's page on who does what suggests a developer and a tester pair on the Gherkin while the product owner reviews it.
  • Delete scenarios that no longer describe real behavior. A failing scenario nobody understands is worse than none.
  • Watch the run time. Tag slow end-to-end scenarios and run them less often. Push most checks down to API or unit level, and keep a few UI scenarios for the critical paths.

If you are new to the tooling, start with Cucumber for beginners, or browse testing tools on DevHunt.

How this guide was checked

Sources read on 2026-11-10: the Cucumber docs linked above, the cucumber/tag-expressions README, the Cucumber.js and Cucumber-JVM docs and changelogs, the Behave and Reqnroll docs, and the pytest-bdd README and changelog. Versions and dates come from npm, PyPI, NuGet and Maven Central. The examples ran in temporary local projects: @cucumber/cucumber 13.3.0 on Node.js 26.7.0, and Behave 1.3.3 and pytest-bdd 9.0.1 on Python 3.11. Every Gherkin block was parsed with @cucumber/gherkin 42.0.1. All parse except the two-feature file in practice 1, which is there to show the error. The Cucumber-JVM and Reqnroll snippets were not executed.

FAQ

What is a BDD scenario?

A BDD scenario is one concrete example of how the system should behave, written in Gherkin as Given (starting state), When (action) and Then (expected outcome). Tools like Cucumber map each step to code, so the scenario is both documentation and an automated test.

How many steps should a BDD scenario have?

The Cucumber Gherkin reference recommends 3-5 steps per example. If a scenario needs more, it is usually testing several behaviors or describing UI clicks instead of behavior.

What does a good BDD feature file look like?

It has one Feature named after a capability, one Rule per business rule, and two or three short examples under each rule. Good BDD feature files read like a specification, with no selectors, URLs or test IDs in the steps. The template at the top of this page is one that runs.

Where should BDD feature files live?

In the repository of the application they describe, in folders named after business domains, such as features/billing/ and features/accounts/. Cucumber.js and Behave look in features/ by default, Cucumber-JVM projects use src/test/resources, and pytest-bdd reads the folder set in bdd_features_base_dir.

What is the Rule keyword in Gherkin?

Rule groups the scenarios that illustrate one business rule, and it can have its own Background and tags. It was added in Gherkin 6. As of November 2026 it works in Cucumber-JVM, Cucumber.js, Reqnroll, Behave 1.3.0 and later, and pytest-bdd 8.0.0 and later.

Should I use Scenario or Example?

They are synonyms in Gherkin. Example reads naturally under a Rule, and Scenario is more common in older suites. Pick one per project and stick to it.

How do I run only some BDD scenarios?

Tag them and pass a tag expression: --tags "@smoke and not @slow" in Cucumber.js and Behave, -Dcucumber.filter.tags on the JVM, pytest -m "smoke and not slow" in pytest-bdd, and dotnet test --filter in Reqnroll. You can also run one scenario by line, for example npx cucumber-js features/billing/subscription-upgrades.feature:12.

What's the difference between BDD and TDD?

TDD is a developer practice: write a failing unit test, make it pass, refactor. BDD builds on it by describing behavior in business language, as examples agreed with product and QA, often in Gherkin. Teams commonly use both: BDD scenarios for features, TDD for the code underneath.

Is SpecFlow still supported?

No. Tricentis retired SpecFlow at the end of 2024, its GitHub organization is archived, and the last stable NuGet package dates from May 2022. Reqnroll is the open-source continuation, built from the SpecFlow codebase, and it publishes a migration guide.

What is a BDD framework?

It is the tool that parses Gherkin feature files and runs the matching step definitions. Common options are Cucumber (JavaScript, Java, Ruby and more), Reqnroll (the open-source successor to SpecFlow for .NET), Behave and pytest-bdd for Python, and playwright-bdd for Playwright.