
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.
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:
Featurenames the capability. The free text below it explains why it exists. Runners ignore it, and reports show it. A.featurefile can only contain oneFeature.BackgroundholdsGivensteps shared by every scenario in the Feature (or in oneRule, if you put it inside the rule). It runs before each scenario, after any Before hooks.Rulegroups the scenarios that illustrate one business rule. It has been part of Gherkin since version 6.Scenario(or its synonymExample) is one concrete example of the rule.Scenario Outline+Examplesrun 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@billingapplies 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:
- Discovery. Product, testing and development talk through concrete examples of a user story and agree on scope.
- Formulation. Someone writes those examples as Gherkin, and the group checks the wording.
- 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.csfile 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 asAna 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 undermarkersinpytest.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:
- 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.
- Rename the file and feature after the capability:
features/accounts/sign-in.feature,Feature: Sign in. - Rewrite each scenario as one example of one rule, in declarative steps. The typed passwords become a
Giventhat states the starting point. - Shrink the Background to what the reader must know, and scope it to the rule that needs it.
- Replace the tag pile with one domain tag and purpose tags.
- 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.
- Feb 2018Behave 1.2.6 is published. The next stable release on PyPI comes seven years later.
- May 3, 2022SpecFlow 3.9.74, the last stable SpecFlow package on NuGet.
- Feb 8, 2024Reqnroll 1.0.0 is published on NuGet as a fork of SpecFlow.
- Nov 14, 2024pytest-bdd 8.0.0 adds the
Rulekeyword and switches to the official Gherkin parser (changelog). - Dec 31, 2024SpecFlow end of life, per the Tricentis notice quoted in Reqnroll's announcement. The GitHub organization is archived on January 13, 2025.
- Aug 4, 2025Behave 1.3.0 reaches PyPI with
Rulesupport and Cucumber-style tag expressions. - Jun 2, 2026Cucumber.js 13.0.0 moves the parallel runtime to worker threads and drops Node.js 20 (changelog).
- Sep 24, 2026Cucumber-JVM 8.0.0: Java 17 baseline, JUnit Platform 6,
cucumber-junitandcucumber-testngdeprecated.
Which framework to pick
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)
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.conftest.py keep working.context object, environment.py hooks, and the most readable parse errors of the three runners tested here.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.