Hire Me
← All Writing Testing

BDD with Cucumber and Spring Boot

How to use Cucumber for Behaviour-Driven Development in Spring Boot — covering feature files, step definitions, Spring context wiring, shared state, and Testcontainers.

At DWP Digital, we had a persistent problem: the acceptance criteria written by the business analyst, the tests written by the developers, and the actual system behaviour were three different things. A claim eligibility check described in Jira had enough ambiguity to be interpreted four ways, and each developer had independently chosen a different interpretation. By the time it went to UAT, two of those interpretations were wrong. BDD with Cucumber didn’t fix the underlying communication issue on its own — but it gave us a format that forced the conversation to happen before code was written, and produced executable specifications that the business could actually read.

Here’s what a production Cucumber setup looks like in a Spring Boot project.

Feature File Structure

Feature files live in src/test/resources/features/. They are plain text, owned collaboratively by the team, and readable by non-developers. The language is business language, not implementation language.

Feature: Universal Credit eligibility assessment

  Background:
    Given the eligibility rules engine is available

  Scenario: Claimant under working age is ineligible
    Given a claimant aged 15
    When an eligibility assessment is requested
    Then the assessment result is INELIGIBLE
    And the reason code is UNDER_WORKING_AGE

  Scenario Outline: Working-age claimants with varying employment status
    Given a claimant aged <age> with employment status <status>
    When an eligibility assessment is requested
    Then the assessment result is <result>

    Examples:
      | age | status     | result   |
      | 25  | UNEMPLOYED | ELIGIBLE |
      | 30  | EMPLOYED   | ELIGIBLE |
      | 67  | RETIRED    | INELIGIBLE |

The Background step runs before each scenario. Scenario Outline with Examples eliminates repetition while keeping each case explicit and readable.

Step Definitions and Spring Wiring

Step definitions are Spring-managed beans. Add the Cucumber Spring dependency to wire the Spring context:

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-spring</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit-platform-engine</artifactId>
    <scope>test</scope>
</dependency>

Annotate your test configuration class with @CucumberContextConfiguration to tell Cucumber to use the Spring context:

@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TestContainersConfig.class)
public class CucumberSpringConfig {
    // Spring Boot context loaded once, shared across all scenarios
}

Step definition classes are @Component beans with no additional annotations:

@Component
public class EligibilitySteps {

    @Autowired
    private EligibilityService eligibilityService;

    private ClaimantRequest claimantRequest;
    private AssessmentResult assessmentResult;

    @Given("a claimant aged {int}")
    public void aClaimantAged(int age) {
        this.claimantRequest = ClaimantRequest.builder()
            .age(age)
            .employmentStatus(EmploymentStatus.UNEMPLOYED)
            .build();
    }

    @Given("a claimant aged {int} with employment status {string}")
    public void aClaimantAgedWithStatus(int age, String status) {
        this.claimantRequest = ClaimantRequest.builder()
            .age(age)
            .employmentStatus(EmploymentStatus.valueOf(status))
            .build();
    }

    @When("an eligibility assessment is requested")
    public void anEligibilityAssessmentIsRequested() {
        this.assessmentResult = eligibilityService.assess(claimantRequest);
    }

    @Then("the assessment result is {string}")
    public void theAssessmentResultIs(String expectedResult) {
        assertThat(assessmentResult.getOutcome())
            .isEqualTo(AssessmentOutcome.valueOf(expectedResult));
    }

    @Then("the reason code is {string}")
    public void theReasonCodeIs(String expectedCode) {
        assertThat(assessmentResult.getReasonCode()).isEqualTo(expectedCode);
    }
}

The Cucumber expression syntax ({int}, {string}) matches parameter types automatically. No regex needed for most scenarios.

Sharing State Between Steps

When a scenario spans multiple step definition classes, shared state needs to be injected rather than held as instance fields. Cucumber Spring creates a new instance of each step definition class per scenario, so instance fields are naturally scenario-scoped. For cross-class state, use a ScenarioContext component scoped to the scenario:

@Component
@ScenarioScope
public class ScenarioContext {

    private ClaimantRequest claimantRequest;
    private AssessmentResult assessmentResult;
    private String currentClaimReference;

    // getters and setters
}

@ScenarioScope is a Cucumber Spring scope — it creates a new bean per scenario, destroyed when the scenario ends. Inject ScenarioContext into any step definition class that needs to read or write shared state:

@Component
public class PaymentSteps {

    @Autowired
    private ScenarioContext context;

    @Autowired
    private PaymentService paymentService;

    @Then("a payment of {double} is scheduled")
    public void aPaymentIsScheduled(double amount) {
        Payment payment = paymentService.findByClaimRef(context.getCurrentClaimReference());
        assertThat(payment.getAmount()).isEqualByComparingTo(BigDecimal.valueOf(amount));
    }
}

This is cleaner than passing state through static fields or thread locals — it’s explicit, injection-managed, and torn down correctly at scenario end.

Integrating with Testcontainers

BDD scenarios that exercise a real database need real infrastructure. Wire Testcontainers into the @CucumberContextConfiguration class:

@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TestContainersConfig.class) // shared Postgres + Kafka containers
public class CucumberSpringConfig {

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // TestContainersConfig handles ServiceConnection auto-wiring
    }
}

With @ServiceConnection on the container beans in TestContainersConfig, Spring Boot wires the datasource URL automatically. Scenarios that test persistence paths hit a real PostgreSQL instance — no mocked repositories, no surprises at deployment time.

Database state between scenarios is managed with @Transactional on the step definition class, which rolls back after each scenario, or with explicit @After hooks that delete test data by a known prefix.

Running Cucumber Tests

Configure the JUnit 5 Platform entry point:

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

Run with mvn test or directly in your IDE. The html plugin generates a readable report you can share with the business.

When BDD Adds Value vs When It’s Overhead

BDD is not free. Writing feature files and step definitions takes longer than writing JUnit tests. The overhead is worth it when:

BDD is overhead when:

At DWP Digital, BDD was genuinely valuable for eligibility rules — policy-driven logic that changed based on ministerial guidance and needed to be sign-off-able by policy teams. For infrastructure work, API integration, or purely technical components, plain JUnit tests were faster and clearer.

ProTips

If you’re looking for a Java contractor who has shipped BDD-tested systems in production, get in touch.