Getting Started with Cucumber-JVM and Spring Boot
Cucumber-JVM brings Behaviour-Driven Development to the JVM. Combined with Spring Boot, it gives you executable specifications that run against a real (or sliced) application context. This guide covers everything from zero to a working BDD suite: dependency setup, Spring integration, writing your first feature file, defining steps, and avoiding the mistakes that waste hours.
Why Cucumber-JVM + Spring Boot?
BDD tests written in Gherkin serve two audiences: stakeholders who need to verify behaviour in plain language, and developers who need a safety net that survives refactoring. Spring Boot's test slices (@SpringBootTest, @WebMvcTest) let you target exactly the layer you care about without spinning up the world.
The combination works because Cucumber-JVM's Spring integration (cucumber-spring) manages the application context lifecycle for you. One context per test run, shared across all scenarios in a feature, reset between scenarios using @DirtiesContext when needed.
1. Project Setup
Maven
Add these dependencies to pom.xml. Pin versions explicitly — Cucumber's BOM simplifies this:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-bom</artifactId>
<version>7.18.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.10.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Cucumber core -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit 5 platform runner -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring integration -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-spring</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot test support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit Platform Suite (for the runner class) -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Also configure Surefire to use the JUnit Platform:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>Gradle (Kotlin DSL)
dependencies {
val cucumberVersion = "7.18.0"
testImplementation("io.cucumber:cucumber-java:$cucumberVersion")
testImplementation("io.cucumber:cucumber-spring:$cucumberVersion")
testImplementation("io.cucumber:cucumber-junit-platform-engine:$cucumberVersion")
testImplementation("org.junit.platform:junit-platform-suite:1.10.2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}2. Directory Structure
Cucumber-JVM expects feature files on the classpath. The conventional layout for a Maven project:
src/
test/
java/
com/example/
bdd/
CucumberSuite.java ← JUnit Platform runner
config/
CucumberSpringConfig.java ← Spring context config
steps/
UserSteps.java
OrderSteps.java
resources/
features/
user_registration.feature
order_placement.featureFor Gradle, src/test/resources works identically.
3. The Runner Class
Cucumber-JVM 7.x uses the JUnit Platform Suite API instead of the older @RunWith(Cucumber.class):
package com.example.bdd;
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/index.html")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME,
value = "com.example.bdd")
public class CucumberSuite {
// intentionally empty
}GLUE_PROPERTY_NAME tells Cucumber where to scan for step definitions and hooks. Point it at the root package containing your steps — all subpackages are scanned automatically.
4. Spring Context Configuration
This is the piece most tutorials skip, and it's why people see NullPointerException in their step definitions.
Create a dedicated configuration class annotated with both @CucumberContextConfiguration and a Spring test annotation:
package com.example.bdd.config;
import io.cucumber.spring.CucumberContextConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
@CucumberContextConfiguration
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class CucumberSpringConfig {
// Spring will load your full application context.
// Beans are injectable in any step definition class.
}@CucumberContextConfiguration marks this class as the source of Spring context for the test run. There must be exactly one such class on the glue path. If you have two, Cucumber throws an error at startup.
Using Test Slices Instead of Full Context
For tests that only exercise the web layer, @WebMvcTest is much faster:
@CucumberContextConfiguration
@WebMvcTest(UserController.class)
public class CucumberWebMvcConfig {
// Only the web layer is loaded.
// @MockBean any service dependencies here or in step definitions.
}5. Your First Feature File
Create src/test/resources/features/user_registration.feature:
Feature: User Registration
New users can create an account with a valid email and password.
Background:
Given the application is running
Scenario: Successful registration with valid credentials
When I register with email "alice@example.com" and password "S3cur3P@ss"
Then the response status is 201
And a user with email "alice@example.com" exists in the database
Scenario: Registration fails with a duplicate email
Given a user with email "bob@example.com" already exists
When I register with email "bob@example.com" and password "AnyP@ss1"
Then the response status is 409
And the error message contains "Email already in use"Keep feature files free of technical detail. No JSON payloads, no SQL, no HTTP headers — those belong in step definitions.
6. Step Definitions
package com.example.bdd.steps;
import com.example.bdd.support.TestContext;
import io.cucumber.java.en.And;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
public class UserSteps {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@Value("${local.server.port}")
private int port;
private ResponseEntity<String> lastResponse;
@Given("the application is running")
public void theApplicationIsRunning() {
// Nothing to do — Spring Boot test already started the server.
// This step exists to make the Background readable.
}
@Given("a user with email {string} already exists")
public void aUserAlreadyExists(String email) {
userRepository.save(User.builder()
.email(email)
.passwordHash("hashed")
.build());
}
@When("I register with email {string} and password {string}")
public void iRegister(String email, String password) {
var request = new RegistrationRequest(email, password);
lastResponse = restTemplate.postForEntity("/api/users/register", request, String.class);
}
@Then("the response status is {int}")
public void theResponseStatusIs(int expectedStatus) {
assertThat(lastResponse.getStatusCode().value()).isEqualTo(expectedStatus);
}
@And("a user with email {string} exists in the database")
public void aUserExistsInDatabase(String email) {
assertThat(userRepository.findByEmail(email)).isPresent();
}
@And("the error message contains {string}")
public void theErrorMessageContains(String fragment) {
assertThat(lastResponse.getBody()).contains(fragment);
}
}Step definition classes are Spring beans when cucumber-spring is on the classpath. @Autowired works exactly as in any other Spring component.
7. Sharing State Between Steps
When a scenario spans multiple step definition classes, you need a way to share state (like lastResponse above) without coupling the classes. The standard pattern is a TestContext scoped to cucumber-glue:
package com.example.bdd.support;
import io.cucumber.spring.ScenarioScope;
import org.springframework.stereotype.Component;
import org.springframework.http.ResponseEntity;
@Component
@ScenarioScope
public class TestContext {
private ResponseEntity<String> lastResponse;
public void setLastResponse(ResponseEntity<String> response) {
this.lastResponse = response;
}
public ResponseEntity<String> getLastResponse() {
return lastResponse;
}
}@ScenarioScope creates a new bean instance for each scenario and destroys it after. Inject TestContext into any step definition that needs to read or write shared state.
8. Hooks
Hooks run before and after scenarios. Use them for setup/teardown, not for test logic:
package com.example.bdd.steps;
import io.cucumber.java.After;
import io.cucumber.java.Before;
import io.cucumber.java.Scenario;
import org.springframework.beans.factory.annotation.Autowired;
public class Hooks {
@Autowired
private UserRepository userRepository;
@Before
public void beforeEach(Scenario scenario) {
// Runs before every scenario. Good for clearing test data.
userRepository.deleteAll();
}
@After
public void afterEach(Scenario scenario) {
if (scenario.isFailed()) {
// Attach a screenshot or log excerpt.
scenario.attach(("Scenario failed: " + scenario.getName()).getBytes(),
"text/plain", "failure-note");
}
}
}Tag hooks to limit scope:
@Before("@api")
public void setupApiTests() { ... }9. Running Tests
Maven:
mvn test
# Run only scenarios tagged @smoke:
mvn test -Dcucumber.filter.tags="@smoke"
# Run a single feature file:
mvn test -Dcucumber.features="src/test/resources/features/user_registration.feature"Gradle:
./gradlew test
./gradlew test -Dcucumber.filter.tags="@smoke"IDE: IntelliJ IDEA recognises Gherkin syntax natively. Right-click any feature file or scenario and select "Run". The JUnit Platform runner handles execution.
10. Application Context Management
By default, Spring reuses one application context across all scenarios in a test run. This is fast but means scenarios can share dirty state if they mutate the database without cleanup.
Your options:
Option 1: Clean in @Before hooks (preferred for most cases) Delete test data before each scenario. Faster than restarting the context.
Option 2: @DirtiesContext on the config class Forces a context reload between scenarios. Use only when a scenario modifies Spring beans (rare):
@CucumberContextConfiguration
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class CucumberSpringConfig { }Option 3: @Transactional + rollback Works for scenarios that don't use WebEnvironment.RANDOM_PORT (because the HTTP call crosses transaction boundaries). Useful for @DataJpaTest slices:
@CucumberContextConfiguration
@DataJpaTest
@Transactional
public class CucumberJpaConfig { }11. Common Pitfalls
"No step definitions found" / "No glue classes found" Check that GLUE_PROPERTY_NAME in CucumberSuite points to the correct base package. Cucumber scans that package and all sub-packages. If your step definitions are in com.example.bdd.steps, the glue must be com.example.bdd or narrower.
NullPointerException in step definitions Usually means @CucumberContextConfiguration is missing or on a class that isn't on the glue path. Also happens when you put @Autowired fields in a class that hasn't been discovered as a Spring bean.
"Ambiguous step definitions" Two step methods match the same Gherkin text. This is a hard error in Cucumber 7+. Rename one of the patterns or make them more specific with Cucumber Expressions parameter types.
Application context loads twice You have two classes annotated with @CucumberContextConfiguration. Cucumber requires exactly one. Delete the duplicate.
Scenarios failing intermittently Usually shared mutable state between scenarios. Switch to @ScenarioScope beans for anything that holds per-scenario data, and clean the database in @Before.
local.server.port is 0 This property is only available when webEnvironment = WebEnvironment.RANDOM_PORT. If you're using DEFINED_PORT, use server.port instead. If you're using MOCK, there is no real port — use MockMvc directly.
Putting It Together
A working Cucumber-JVM + Spring Boot setup requires four moving parts to be in sync: the runner class pointing at the right feature path, the glue pointing at the right package, exactly one @CucumberContextConfiguration, and step definitions that are Spring-managed beans. Get those four right and the rest is just writing feature files and step code.
The payoff is executable documentation that business stakeholders can read, QA can validate, and developers can run in CI on every commit.
For teams running BDD suites in CI, HelpMeTest adds AI-powered E2E monitoring in plain English — no code required, usage-based pricing at $0.003/run — complementing your Cucumber specs with 24/7 coverage across environments.