Cucumber Getting Started: Write Your First BDD Test in Java

Cucumber Getting Started: Write Your First BDD Test in Java

Behavior-Driven Development (BDD) is a way of writing tests that describes system behavior in plain language that both technical and non-technical team members can read and understand. Cucumber is the most widely-used tool for this in the Java ecosystem — it runs tests written in Gherkin syntax (Given/When/Then scenarios) against real application code.

This guide walks through setting up Cucumber from scratch, writing your first feature file, implementing step definitions, and running tests.

What Cucumber Does

Cucumber bridges the gap between business requirements and test code:

  1. Feature files (.feature) — plain English descriptions of behavior using Gherkin syntax
  2. Step definitions — Java code that implements each line of the feature file
  3. Cucumber runner — connects them and runs as a test suite

The result: executable specifications. Your acceptance criteria double as automated tests.

Project Setup

Maven

<dependencies>
    <!-- Cucumber JUnit 5 integration -->
    <dependency>
        <groupId>io.cucumber</groupId>
        <artifactId>cucumber-junit-platform-engine</artifactId>
        <version>7.18.0</version>
        <scope>test</scope>
    </dependency>
    
    <!-- Cucumber Java step definitions -->
    <dependency>
        <groupId>io.cucumber</groupId>
        <artifactId>cucumber-java</artifactId>
        <version>7.18.0</version>
        <scope>test</scope>
    </dependency>
    
    <!-- JUnit Platform (required) -->
    <dependency>
        <groupId>org.junit.platform</groupId>
        <artifactId>junit-platform-suite</artifactId>
        <scope>test</scope>
    </dependency>
    
    <!-- JUnit Jupiter for assertions -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Gradle

dependencies {
    testImplementation 'io.cucumber:cucumber-java:7.18.0'
    testImplementation 'io.cucumber:cucumber-junit-platform-engine:7.18.0'
    testImplementation 'org.junit.platform:junit-platform-suite'
    testImplementation 'org.junit.jupiter:junit-jupiter'
}

Your First Feature File

Create src/test/resources/features/account.feature:

Feature: Bank Account

  As a bank customer
  I want to manage my account balance
  So that I can track my finances

  Scenario: Deposit money into account
    Given I have a bank account with a balance of 100
    When I deposit 50 into the account
    Then the account balance should be 150

  Scenario: Withdraw money from account
    Given I have a bank account with a balance of 200
    When I withdraw 75 from the account
    Then the account balance should be 125

  Scenario: Cannot withdraw more than balance
    Given I have a bank account with a balance of 50
    When I try to withdraw 100 from the account
    Then I should get an insufficient funds error

Implementing Step Definitions

Create src/test/java/steps/AccountSteps.java:

package steps;

import io.cucumber.java.en.Given;
import io.cucumber.java.en.When;
import io.cucumber.java.en.Then;
import static org.junit.jupiter.api.Assertions.*;

public class AccountSteps {
    
    private BankAccount account;
    private Exception thrownException;
    
    @Given("I have a bank account with a balance of {int}")
    public void i_have_a_bank_account_with_a_balance_of(int initialBalance) {
        account = new BankAccount(initialBalance);
    }
    
    @When("I deposit {int} into the account")
    public void i_deposit_into_the_account(int amount) {
        account.deposit(amount);
    }
    
    @When("I withdraw {int} from the account")
    public void i_withdraw_from_the_account(int amount) {
        account.withdraw(amount);
    }
    
    @When("I try to withdraw {int} from the account")
    public void i_try_to_withdraw_from_the_account(int amount) {
        try {
            account.withdraw(amount);
        } catch (Exception e) {
            thrownException = e;
        }
    }
    
    @Then("the account balance should be {int}")
    public void the_account_balance_should_be(int expectedBalance) {
        assertEquals(expectedBalance, account.getBalance());
    }
    
    @Then("I should get an insufficient funds error")
    public void i_should_get_an_insufficient_funds_error() {
        assertNotNull(thrownException, "Expected an exception but none was thrown");
        assertInstanceOf(InsufficientFundsException.class, thrownException);
    }
}

The {int} in step definitions is a Cucumber expression that captures the integer from the Gherkin text and passes it as a parameter.

The Application Code Being Tested

public class BankAccount {
    private int balance;
    
    public BankAccount(int initialBalance) {
        this.balance = initialBalance;
    }
    
    public void deposit(int amount) {
        if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive");
        balance += amount;
    }
    
    public void withdraw(int amount) {
        if (amount > balance) throw new InsufficientFundsException(
            "Cannot withdraw " + amount + ", balance is " + balance);
        balance -= amount;
    }
    
    public int getBalance() {
        return balance;
    }
}

public class InsufficientFundsException extends RuntimeException {
    public InsufficientFundsException(String message) {
        super(message);
    }
}

The Test Runner

Create src/test/java/runner/CucumberTestRunner.java:

package runner;

import org.junit.platform.suite.api.ConfigurationParameter;
import org.junit.platform.suite.api.IncludeEngines;
import org.junit.platform.suite.api.SelectClasspathResource;
import org.junit.platform.suite.api.Suite;

import static io.cucumber.junit.platform.engine.Constants.*;

@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, 
    value = "pretty, html:target/cucumber-reports/report.html")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "steps")
public class CucumberTestRunner {}

Run tests:

mvn test -Dtest=CucumberTestRunner

Or with Gradle:

./gradlew test --tests "runner.CucumberTestRunner"

Gherkin Syntax Explained

Given / When / Then

  • Given — the initial context (preconditions)
  • When — the action taken
  • Then — the expected outcome

These map to Arrange/Act/Assert in unit testing. Use them consistently.

You can also use And and But to continue the same step type:

Scenario: Transfer between accounts
  Given I have a savings account with a balance of 500
  And I have a checking account with a balance of 100
  When I transfer 200 from savings to checking
  Then my savings balance should be 300
  And my checking balance should be 300
  But the transfer should not affect any other accounts

Background

Run the same Given steps before every scenario in a feature:

Feature: User Authentication

  Background:
    Given the application is running
    And the user database is empty

  Scenario: Successful registration
    When a user registers with email "alice@example.com"
    Then the account should be created

  Scenario: Duplicate email rejected
    Given a user exists with email "alice@example.com"
    When another user tries to register with email "alice@example.com"
    Then I should see "Email already in use"

Scenario Outline: Data-Driven Tests

Run the same scenario with multiple data sets:

Scenario Outline: Calculate loan payment
  Given a loan amount of <principal>
  And an annual interest rate of <rate>%
  And a term of <years> years
  When I calculate the monthly payment
  Then the monthly payment should be approximately <payment>

  Examples:
    | principal | rate | years | payment |
    | 100000    | 5.0  | 30    | 537     |
    | 200000    | 4.5  | 15    | 1530    |
    | 50000     | 6.0  | 10    | 555     |

Step definitions use <parameter> values automatically:

@Given("a loan amount of {double}")
public void a_loan_amount_of(double principal) { ... }

@Given("an annual interest rate of {double}%")
public void an_annual_interest_rate_of(double rate) { ... }

Doc Strings

For multi-line content (JSON, XML, prose):

Scenario: Create user via API
  When I send a POST request to "/api/users" with body:
    """
    {
      "name": "Alice",
      "email": "alice@example.com",
      "role": "admin"
    }
    """
  Then the response status should be 201
@When("I send a POST request to {string} with body:")
public void i_send_a_post_request_with_body(String path, String body) {
    response = httpClient.post(path, body);
}

Data Tables

For tabular data within a step:

Scenario: Import users in bulk
  When I import the following users:
    | name    | email                | role  |
    | Alice   | alice@example.com    | admin |
    | Bob     | bob@example.com      | user  |
    | Charlie | charlie@example.com  | user  |
  Then 3 users should be in the system
@When("I import the following users:")
public void i_import_the_following_users(DataTable dataTable) {
    List<Map<String, String>> users = dataTable.asMaps();
    userService.bulkImport(users);
}

Sharing State Between Steps

Cucumber creates a new instance of each step definition class per scenario. Use a shared context object to pass state between steps:

// Shared context — injected by Cucumber's DI
public class TestContext {
    public HttpResponse lastResponse;
    public String authToken;
    public User currentUser;
}

// Step definition classes receive it via constructor injection
public class AuthSteps {
    private final TestContext context;
    
    public AuthSteps(TestContext context) {
        this.context = context;
    }
    
    @When("I log in as {string}")
    public void i_log_in_as(String username) {
        context.authToken = authService.login(username, "password");
    }
}

public class ProfileSteps {
    private final TestContext context;
    
    public ProfileSteps(TestContext context) {
        this.context = context;
    }
    
    @When("I view my profile")
    public void i_view_my_profile() {
        context.lastResponse = httpClient
            .withHeader("Authorization", "Bearer " + context.authToken)
            .get("/api/profile");
    }
}

Add cucumber-picocontainer dependency for constructor injection:

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-picocontainer</artifactId>
    <version>7.18.0</version>
    <scope>test</scope>
</dependency>

Hooks: Setup and Teardown

public class Hooks {
    
    @Before
    public void beforeScenario(Scenario scenario) {
        System.out.println("Starting: " + scenario.getName());
        // Set up test data, start server, etc.
    }
    
    @After
    public void afterScenario(Scenario scenario) {
        if (scenario.isFailed()) {
            // Take screenshot, log extra details
        }
        // Clean up test data
    }
    
    @Before("@database")
    public void beforeDatabaseScenario() {
        // Only run before scenarios tagged @database
        database.reset();
    }
}

Use tags to run subsets of scenarios:

@smoke @authentication
Scenario: Login with valid credentials
  ...

@database @slow
Scenario: Complex database transaction
  ...
# Run only smoke tests
mvn test -Dcucumber.filter.tags="@smoke"

# Run everything except slow tests
mvn test -Dcucumber.filter.tags="not @slow"

Running and Reporting

HTML Report

Configure the pretty and HTML plugins in the runner:

@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, 
    value = "pretty, html:target/cucumber-reports/index.html, json:target/cucumber-reports/results.json")

Parallel Execution

Cucumber supports parallel scenario execution with JUnit Platform:

Create src/test/resources/junit-platform.properties:

cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=dynamic

With Spring or other frameworks that have shared state, you'll need thread-safe test context objects.

Wrapping Up

Cucumber's value comes from the discipline of writing scenarios before code. The Gherkin files become a living specification — when requirements change, scenarios change first, tests fail, then implementation follows.

The setup is straightforward: feature files in src/test/resources/features/, step definitions in src/test/java/, a runner class to connect them. From there, the complexity scales with your application — not with Cucumber itself.

For teams new to BDD, start with a single feature covering your most important business workflow. The process of writing scenarios in Given/When/Then often surfaces ambiguities in requirements before a single line of code is written. That's the real value.

Read more

Start now free