Master BDD in Node.js using Jest and Cucumber

Advanced BDD Techniques with Jest and Cucumber

Master advanced features and best practices for effective BDD (Behavior Driven Development) in Node.js using Jest and Cucumber

Introduction

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.

Advanced Jest-Cucumber Features

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:

  1. Use Scenario Outline: instead of Scenario.
  2. Use placeholders like <username> in your steps.
  3. 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 (Givens). 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 Givens 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 the defineFeature block. Good for setting up a database connection, maybe?
  • beforeEach(fn): Runs before each test (Scenario) block. Perfect for resetting the state.
  • afterEach(fn): Runs after each test block. Good for cleanup, like logging out the user, clearing mocks.
  • afterAll(fn): Runs once after all tests in the defineFeature 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:

  1. Module Scope / Closures: Define variables outside your test blocks but inside the defineFeature 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");
    });
  });
});
  1. 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.

bdd-nodejs-jest-cucumber

Best Practices for Effective BDD with Jest-Cucumber

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.
  • Naming: Use clear names. authentication.feature is better than test1.feature. login.steps.js is better than steps.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.
  • 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.
  • 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. Use afterEach for any necessary teardown.
  • Isolation: Treat each Scenario (each test block in defineFeature) 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 in beforeEach.

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 in jest.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.

Finally, Is BDD Suitable For You?

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!

Conclusion

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.

Think about it

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

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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top