Once you’ve mastered the basics of BDD (Behavior-Driven Development) with Jest and Cucumber, it’s time to explore advanced features that make your tests more powerful, maintainable, and integrated into your development workflow.
This article is the second part in our series on BDD with Jest and Cucumber, which builds on BDD fundamental concepts to introduce advanced Jest-Cucumber capabilities and best practices that will help your team create robust, readable, and efficient test suites. From handling complex testing scenarios to organizing your test codebase effectively, these techniques will take your BDD implementation to the next level.
You can find the complete code example here.
Got the basics covered? Great! Jest-Cucumber has a few more advanced features to tackle more complex testing scenarios. Let’s explore some of the most useful ones.
Scenario Outlines and Examples
What if you want to test the same scenario with slightly different inputs and outputs? Like testing login with a valid username, an invalid username, empty username? You could copy-paste the scenario multiple times, but that’s messy. Enter Scenario Outline
!
# specifications/features/login.feature
Feature: User Login
Scenario Outline: Login with various credentials
When I submit login with username "<username>" and password "<password>"
Then I should receive "<message>"
Examples:
| username | password | message |
| testuser | testpass | success |
| wronguser| testpass | Invalid credentials |
| testuser | wrongpass | Invalid credentials |
| "" | testpass | Invalid credentials |
| testuser | "" | Invalid credentials |
How it works:
- Use
Scenario Outline:
instead ofScenario
. - Use placeholders like
<username>
in your steps. - Add an
Examples:
table below. The header row names match the placeholders. Each row below is one run of the scenario, filling in the placeholders.
Your step definitions don’t change much! The same step function will just be called multiple times with different values from the table.
// specifications/step-definitions/login.steps.js (partial)
test("Login with various credentials", ({ given, when, then }) => {
when(
/^I submit login with username "(.*)" and password "(.*)"$/,
async (username, password) => {
customWorld.response = await loginUser(username, password);
}
);
then(/^I should receive "(.*)"$/, (message) => {
if (message === "success") {
expect(customWorld.response.status).toBe(200);
expect(customWorld.response.body.token).toBeDefined();
expect(customWorld.response.body.token).toBeValidJWT();
} else if (message === "Invalid credentials") {
expect(customWorld.response.status).toBe(401);
expect(customWorld.response.body.message).toBe(message);
}
});
});
Backgrounds
Sometimes, almost every scenario in a feature needs the same starting steps (Given
s). Instead of repeating them, use a Background
:
# features/manage_items.feature (example)
Feature: Managing Items in a Cart
Background:
Given the login system is available
And I am a registered user with username "testuser" and password "testpass"
Scenario: Successful login
# Given the login system is available
# And I am a registered user with username "testuser" and password "testpass"
When I submit login with username "testuser" and password "testpass"
Then I should receive a valid JWT token
Scenario Outline: Login with various credentials
# Given the login system is available
# And I am a registered user with username "testuser" and password "testpass"
When I submit login with username "<username>" and password "<password>"
Then I should receive "<message>"
The Background
steps run before each Scenario in that feature file. Keeps things DRY (Don’t Repeat Yourself). Your step definitions for those Given
s remain the same.
Hooks (beforeEach, afterEach, beforeAll, afterAll)
Just like in standard Jest tests, you often need setup and teardown logic. Jest-Cucumber lets you use Jest’s familiar hooks within the defineFeature
scope:
beforeAll(fn)
: Runs once before any test in thedefineFeature
block. Good for setting up a database connection, maybe?beforeEach(fn)
: Runs before eachtest
(Scenario) block. Perfect for resetting the state.afterEach(fn)
: Runs after eachtest
block. Good for cleanup, like logging out the user, clearing mocks.afterAll(fn)
: Runs once after all tests in thedefineFeature
block have finished. Good for closing database connections.
Not just that, you can use the Jest built-in setupFilesAfterEnv
option that enables you to build a global setup for your entire tests. And you can use these hooks inside it.
Tags
Tags are like labels you put on Features or Scenarios using the @
symbol. They are super useful for organizing and running specific subsets of your tests.
@smoke
Scenario: Successful login
...
@regression
Scenario Outline: Login with various credentials
...
You can then tell Jest to only run tests with (or without) specific tags using the tagFilter
option in the Jest-Cucumber configuration.
setJestCucumberConfiguration({
tagFilter: "@smoke and not @regression"
});
This is great for CI/CD pipelines – run quick @smoke
tests on every commit, run full @regression
suite nightly, or simply ignore it to run all of them.
Data Tables
Need to pass a chunk of structured data from your feature file to a step? Gherkin supports Data Tables.
Scenario: Login with data table
When I submit multiple login attempts:
| username | password | expected_result |
| testuser | testpass | success |
| wronguser | wrongpass | failure |
Then the login results should match expectations
In your step definition, the data table is usually passed as an argument (often an array of objects):
when("I submit multiple login attempts:", async (dataTable) => {
/*
dataTable will be like the following:
[
{ username: "testuser", password: "testpass", expected_result: "success" },
{ username: "wronguser", password: "wrongpass", expected_result: "failure" }
]
*/
for (const { username, password, expected_result } of dataTable) {
const response = await loginUser(username, password);
customWorld.loginResults.push({
username,
password,
expected_result,
response,
});
}
});
💌 Enjoying this post? Subscribe to get more practical dev tips right in your inbox.
DocStrings
A DocString allows you to pass multi-line text as a parameter in step definitions. It is useful for testing JSON payloads, multi-line messages, or long text inputs.
In a feature file, you can use triple double-quotes ("""
) to define a multi-line string inside a scenario.
Scenario: Registration with detailed response validation
When I submit registration with username "testuser" and password "test123"
Then I should receive a response matching:
"""
{
"status": 201,
"body": {
"message": "User registered successfully"
}
}
"""
And the step
function provides a docString
parameter that captures the multi-line text. You can use it to pass structured data (like JSON or messages) into your step definitions.
then("I should receive a response matching:", (docString) => {
const expectedResponse = JSON.parse(docString);
expect(customWorld.response.status).toBe(expectedResponse.status);
expect(customWorld.response.body.message).toBe(expectedResponse.body.message);
});
Handling Asynchronous Operations
Modern JavaScript is full of async stuff (promises, async/await
). Your step definitions need to handle this correctly so Jest waits for the operation to finish before moving on.
Just use async
and await
in your step functions! Jest understands this perfectly.
when(
/^I submit login with username "(.*)" and password "(.*)"$/,
async (username, password) => {
customWorld.response = await loginUser(username, password);
}
);
If a step function returns a Promise (implicitly with async
or explicitly), Jest will wait for it to resolve or reject. If it rejects, the step fails. Simple!
Custom World (Context Injection – Explaining Alternatives)
In traditional Cucumber.js, there’s a concept called the “World” – an isolated context object passed to each step in a scenario, used to share state (like the user ID fetched in a Given
step, used in a When
step).
Jest-Cucumber doesn’t really have a direct, built-in “World” concept like that. But don’t worry, sharing state between steps in a scenario is easy using standard JavaScript patterns within the Jest environment:
- Module Scope / Closures: Define variables outside your
test
blocks but inside thedefineFeature
callback. These variables are accessible by all steps within that feature’s execution scope. This is often the simplest way.
defineFeature(feature, (test) => {
// Custom World object for sharing context between steps
let customWorld = {
response: null,
registrationData: [],
registrationResults: [],
};
// Don't forget to reset your state (World) before every test.
beforeEach(function () {
customWorld = {
response: null,
registrationData: [],
registrationResults: [],
};
});
test("Successful user registration", ({ given, when, then }) => {
givenRegistrationSystemIsAvailable(given);
when(
/^I submit registration with username "(.*)" and password "(.*)"$/,
async (username, password) => {
customWorld.response = await registerUser(username, password);
}
);
then("I should receive a successful registration message", () => {
expect(customWorld.response.status).toBe(201);
expect(customWorld.response.body.message).toBe("User registered successfully");
});
});
});
beforeEach
/afterEach
for Setup: Use hooks to initialize or reset context objects before each scenario. Don’t forget, it is your responsibility.
This testContext
object acts kinda like the Cucumber World.

Writing tests is one thing. Writing good, maintainable BDD tests with Jest-Cucumber is another. Here’s some advice.
Organizing Your Features and Steps
As your project grows, so will your tests. Keep them organized!
- Structure: Group features and steps logically. Common ways:
- By Feature:
features/login/login.feature
,step-definitions/login/login.steps.js
- By Domain:
features/authentication/login.feature
,features/authentication/register.feature
,step-definitions/authentication.steps.js
(one step file for the whole domain) - Mix: Major features get their own folders, smaller ones grouped.
Choose a structure and stick to it. Consistency helps everyone find stuff.
- By Feature:
- Naming: Use clear names.
authentication.feature
is better thantest1.feature
.login.steps.js
is better thansteps.js
.
Writing Maintainable Gherkin
Your .feature
files are documentation. Keep them clean and focused.
- Behavior, Not Clicks: Describe what the user wants to achieve, not how they click buttons.
- Less Good:
When I click the element with ID "user-name-input"
- Better:
When I enter my username
The step definition handles the “how”. This makes features less brittle if the UI changes.
- Less Good:
- Declarative Language: Focus on the state changes and outcomes.
- Declarative:
When the user logs in with their credentials
. - Imparative:
When I enter a username and a password and I click the login button
. The declarative approach describes the event in terms of its business impact. However, the imperative approach focuses more on specific UI interactions, which might change.
- Declarative:
- Keep Scenarios Focused: Each scenario should ideally test one specific rule or path. Avoid monster scenarios that do everything. If a scenario gets too long, maybe it’s trying to test too much?
- Independence: Try to make scenarios independent so they can run in any order and don’t rely on side effects from previous ones.
Reusable Feature And Step Definitions
Avoid writing the exact same feature step logic over and over.
- Parameterize: Use RegExp captures (
(\d+)
,"(.*?)"
) to make steps more generic.when('I click the {string} button', (buttonName) => { ... })
can handle many buttons.
- Helper Functions: If a step definition gets complex, extract the logic into separate helper functions (maybe in a
utils
folder). Keep the step definition itself simple, mainly coordinating calls to your app code or helpers. - Eliminate Redundancy: Sometimes a step like
Given the login system is available
is needed in many features:- Feature: To eliminate the Feature definition, use Backgrounds.
- Step: To eliminate the Step definition, wrap it in a function to reuse it.
// Eliminate a Feature redundancy
Background:
Given the login system is available
And I am a registered user with username "testuser" and password "testpass"
// Eliminate a Step redundancy
const givenLoginSystemIsAvailable = (given) => {
given("the login system is available", () => {});
};
test("Successful login", ({ given, when, then }) => {
givenLoginSystemIsAvailable(given);
...
Managing Test State
State from one test leaking into another causes unpredictable, unreliable results.
- Clean Up: Use
beforeEach
to reset state (clear variables, reset mocks, clean databases/tables) before every scenario. UseafterEach
for any necessary teardown. - Isolation: Treat each
Scenario
(eachtest
block indefineFeature
) as if it runs in a clean room. - Avoid Global Mutable State: Be careful with shared variables defined at the very top level of a step file if they get modified by tests. Scope them within
defineFeature
or manage them via context objects reset inbeforeEach
.
Integrating with CI/CD Pipelines
BDD tests are most valuable when run automatically and frequently.
- Automate: Configure your Continuous Integration (CI) server (like Jenkins, GitLab CI, GitHub Actions) to run tests on every code push or pull request.
- Reporting: Use Jest reporters (like
jest-junit
for Jenkins or Jest’s built-in JSON reporter) to generate reports that your CI server can display. This makes it easy to see test results alongside builds. You can often configure Jest injest.config.js
to use multiple reporters.
module.exports = {
...
reporters: [
"default",
[
"jest-junit",
{
outputDirectory: "reports",
outputName: "junit.xml",
},
],
],
};
You’d need to npm install --save-dev jest-junit
.
Following these practices makes your BDD suite a valuable asset, not a maintenance nightmare.

I know, there are a lot of approaches and acronyms you heard about when you decided to write tests.
What Should You Use: BDD, TDD, or ATDD?
Right, they sound similar but have different focuses.
- TDD (Test-Driven Development): This is more developer-focused. You write a failing unit test first, then write just enough code to make it pass, and finally refactor. It’s a tight loop focused on code correctness at a low level.
- BDD (Behavior-Driven Development): Builds on TDD. It focuses on the behavior of the system at the high level. You write a scenario in plain language (Gherkin), then write the code to make that scenario pass. It’s about ensuring the software does what users expect.
- ATDD (Acceptance Test-Driven Development): In my opinion, the same as BDD.
When to Choose?
- Use BDD for higher-level, end-to-end, or integration tests that describe key user journeys or business rules. These are great for ensuring the application does what it’s supposed to do.
- Use TDD for lower-level unit tests to verify individual functions, components, or modules work correctly in isolation. These are great for catching regressions and guiding code design.
Many successful teams use both. They write BDD scenarios for the important features and then use TDD to implement the underlying code units, ensuring both the overall behavior and the internal components are solid. They complement each other really well. Don’t feel you have to pick just one!
Does BDD Replace Integration Tests Entirely?
Absolutely NOT. As we said, BDD focuses on ensuring the application behaves as expected from a user’s perspective. However, it does not test the internal interactions between system components, databases, or external services—this is where integration tests are crucial.
For example, if you use patterns like the Repository Pattern, integration tests shine here to confirm that repositories interact correctly with the database.
Again, don’t feel you have to pick just one!
Advanced BDD techniques with Jest-Cucumber empower your team to create comprehensive, maintainable test suites that truly capture the behavior of your application.
By leveraging features like scenario outlines, hooks, and tags, you can handle complex testing scenarios while keeping your tests organized and efficient.
As you implement these practices, focus on creating tests that remain readable, maintainable, and valuable as documentation. Whether used alongside TDD, integration tests, or other testing approaches, a well-implemented BDD strategy with Jest and Cucumber can significantly improve both your development process and the quality of your Node.js applications.
If you enjoyed this article, I’d truly appreciate it if you could share it—it really motivates me to keep creating more helpful content!
If you’re interested in exploring more, check out these articles
- BDD Fundamentals with Jest and Cucumber
- Unit, Integration, and E2E Testing in One Example Using Jest
- Open-Closed Principle: The Hard Parts
- Isolation Levels In SQL Server With Examples
- Strategy vs State vs Template Design Patterns
Thanks for sticking with me until the end—I hope you found this article valuable and enjoyable!
Want more dev insights like this? Subscribe to get practical tips, tutorials, and tech deep dives delivered to your inbox. No spam, unsubscribe anytime.