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 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 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.
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.
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.
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.
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.
| age | status | result | is readable. | a | b | c | is not.@After hooks for teardown, not @Before. @Before teardown means a test failure leaves dirty data that confuses the next run. @After teardown runs regardless of outcome.If you’re looking for a Java contractor who has shipped BDD-tested systems in production, get in touch.