Cucumber-JVM with JUnit 5 and Parallel Execution
A Cucumber suite that takes 20 minutes to run is a suite developers skip. Parallel execution cuts that time proportionally to the number of available cores — a suite that ran sequentially in 20 minutes can finish in 4 minutes on a 6-core machine. But parallelism also exposes shared state bugs that sequential runs hide. This post covers the JUnit 5 Platform integration, parallel configuration, making step definitions thread-safe, and wiring it all into CI with usable reports.
JUnit Platform Integration
Cucumber-JVM 7.x integrates with JUnit 5 via the cucumber-junit-platform-engine artifact. This replaces the older cucumber-junit (@RunWith(Cucumber.class)) approach. The engine plugs into the JUnit Platform, which means Surefire, Gradle's test runner, and IDEs all discover and run Cucumber tests through the standard JUnit 5 path.
Dependencies
<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>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-spring</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Gradle (Kotlin DSL):
dependencies {
val cucumberVersion = "7.18.0"
testImplementation("io.cucumber:cucumber-java:$cucumberVersion")
testImplementation("io.cucumber:cucumber-junit-platform-engine:$cucumberVersion")
testImplementation("io.cucumber:cucumber-spring:$cucumberVersion")
testImplementation("org.junit.platform:junit-platform-suite:1.10.2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}@Suite Setup
The suite class is the entry point. It declares where Cucumber finds features, where it scans for step definitions, and what plugins to activate:
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 = GLUE_PROPERTY_NAME,
value = "com.example.bdd"
)
@ConfigurationParameter(
key = PLUGIN_PROPERTY_NAME,
value = "pretty, html:target/cucumber-reports/index.html, json:target/cucumber-reports/report.json, junit:target/cucumber-reports/junit.xml"
)
@ConfigurationParameter(
key = FILTER_TAGS_PROPERTY_NAME,
value = "not @wip"
)
public class CucumberSuite {
}The @Suite annotation marks this as a JUnit Platform Suite. @IncludeEngines("cucumber") routes execution to the Cucumber engine. @SelectClasspathResource("features") tells Cucumber where to find .feature files — this path is relative to the test classpath root, so it maps to src/test/resources/features/.
The FILTER_TAGS_PROPERTY_NAME configuration excludes scenarios tagged @wip from the normal run — useful for scenarios under active development that aren't ready for CI.
Parallel Execution Configuration
Cucumber's parallel execution is configured through cucumber.properties (placed in src/test/resources/) or via @ConfigurationParameter annotations on the suite class.
Enable Parallelism
# src/test/resources/cucumber.properties
cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=dynamicThe dynamic strategy uses Runtime.getRuntime().availableProcessors() to determine thread count. For CI environments with a known core count, fixed gives more predictable behaviour:
cucumber.execution.parallel.config.strategy=fixed
cucumber.execution.parallel.config.fixed.parallelism=4Or configure via the suite class:
@ConfigurationParameter(
key = "cucumber.execution.parallel.enabled",
value = "true"
)
@ConfigurationParameter(
key = "cucumber.execution.parallel.config.strategy",
value = "dynamic"
)Execution Mode
By default, Cucumber parallelises at the scenario level. You can also parallelise at the feature level, which reduces thread overhead at the cost of less granular parallelism:
# Parallel at feature level (features run in parallel, scenarios within a feature run sequentially)
cucumber.execution.parallel.config.strategy=dynamic
# Default is scenario-level — no additional property needed for thatTo explicitly set scenario-level:
cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=fixed
cucumber.execution.parallel.config.fixed.parallelism=8Scenario-level parallelism is almost always what you want. Feature-level parallelism gives less speedup and is mainly useful when scenarios within a feature have intentional shared state (which is itself an anti-pattern).
Thread-Safe Step Definitions
This is where parallel execution breaks suites that were fine sequentially. Instance fields in step definition classes are not thread-safe:
// BROKEN under parallel execution
public class OrderSteps {
private ResponseEntity<String> lastResponse; // shared mutable state — race condition
@When("I place an order for {string}")
public void placeOrder(String product) {
lastResponse = restTemplate.postForEntity("/orders", new OrderRequest(product), String.class);
}
@Then("the response status is {int}")
public void assertStatus(int expected) {
// Thread A's lastResponse may be overwritten by Thread B before this runs
assertThat(lastResponse.getStatusCode().value()).isEqualTo(expected);
}
}The Fix: Scenario-Scoped Beans
Move shared mutable state into a @ScenarioScope Spring bean. cucumber-spring creates a new instance per scenario and destroys it after, isolated from other scenarios running in parallel:
package com.example.bdd.support;
import io.cucumber.spring.ScenarioScope;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
@Component
@ScenarioScope
public class ScenarioState {
private ResponseEntity<String> lastResponse;
private String currentUserId;
public void setLastResponse(ResponseEntity<String> response) {
this.lastResponse = response;
}
public ResponseEntity<String> getLastResponse() {
return lastResponse;
}
public void setCurrentUserId(String userId) {
this.currentUserId = userId;
}
public String getCurrentUserId() {
return currentUserId;
}
}Inject ScenarioState into step definition classes instead of storing state in instance fields:
public class OrderSteps {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ScenarioState state; // thread-safe: new instance per scenario
@When("I place an order for {string}")
public void placeOrder(String product) {
var response = restTemplate.postForEntity("/orders", new OrderRequest(product), String.class);
state.setLastResponse(response);
}
@Then("the response status is {int}")
public void assertStatus(int expected) {
assertThat(state.getLastResponse().getStatusCode().value()).isEqualTo(expected);
}
}Every scenario gets its own ScenarioState instance. No shared mutable state, no race conditions.
Dealing with External Resources
External resources — databases, message queues, email inboxes — are inherently shared. Parallel scenarios that write to the same tables or queues will interfere.
Strategy 1: Unique identifiers per scenario Generate unique identifiers for test data so scenarios don't collide:
@Component
@ScenarioScope
public class ScenarioState {
private final String scenarioId = UUID.randomUUID().toString().substring(0, 8);
public String getScenarioId() {
return scenarioId;
}
public String uniqueEmail() {
return "user-" + scenarioId + "@example.com";
}
}@When("I register a new user")
public void registerUser() {
restTemplate.postForEntity("/users", new UserRequest(state.uniqueEmail()), String.class);
}Strategy 2: Cleanup in @After hooks Delete data created by the scenario after it runs. Use the scenario ID to find only that scenario's data:
@After
public void cleanup() {
userRepository.deleteByEmailContaining(state.getScenarioId());
}Strategy 3: Database partitioning For heavy suites, provision a separate schema or database per test worker. This eliminates all cross-scenario database contention. Configure Spring's datasource to use a worker-specific URL:
@Bean
@Primary
public DataSource dataSource() {
int workerId = Integer.parseInt(System.getProperty("worker.id", "0"));
return DataSourceBuilder.create()
.url("jdbc:postgresql://localhost/testdb_worker_" + workerId)
.build();
}Hooks in Parallel Execution
@Before and @After hooks run in the same thread as the scenario they belong to. They are safe to use under parallel execution, provided the actions inside them don't access shared mutable state.
@BeforeAll and @AfterAll (class-level) run once per JVM and are not scenario-scoped. Use them for expensive one-time setup only, and make sure that setup is thread-safe (read-only after initialisation):
public class DatabaseHooks {
@BeforeAll
public static void startDatabase() {
// Testcontainers: start once, share across all scenarios
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
postgres.start();
System.setProperty("spring.datasource.url", postgres.getJdbcUrl());
}
}Reporting
Surefire Configuration
Surefire 3.x handles the JUnit Platform natively. Configure it to not fork a new JVM per test class (which interferes with parallel execution) and to pick up the Cucumber suite:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<properties>
<configurationParameters>
cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=dynamic
</configurationParameters>
</properties>
</configuration>
</plugin>Surefire picks up the CucumberSuite class automatically because it extends the JUnit Platform. The configurationParameters block here overrides what's in cucumber.properties — useful for enabling parallelism only in CI without affecting local runs.
Cucumber HTML Report
The HTML plugin configured in CucumberSuite produces a self-contained report at target/cucumber-reports/index.html. It shows each feature, scenario, and step with pass/fail status and execution time.
For a richer report with trend history and feature-level dashboards, use maven-cucumber-reporting:
<plugin>
<groupId>net.masterthought</groupId>
<artifactId>maven-cucumber-reporting</artifactId>
<version>5.8.1</version>
<executions>
<execution>
<id>generate-reports</id>
<phase>verify</phase>
<goals><goal>generate</goal></goals>
<configuration>
<projectName>${project.artifactId}</projectName>
<outputDirectory>${project.build.directory}/site</outputDirectory>
<jsonFiles>
<param>**/cucumber-reports/report.json</param>
</jsonFiles>
<parallelTesting>true</parallelTesting>
</configuration>
</execution>
</executions>
</plugin>Set <parallelTesting>true</parallelTesting> when running in parallel — it adjusts the report's timing calculations to account for concurrent execution.
JUnit XML for CI
The junit:target/cucumber-reports/junit.xml plugin output produces a standard JUnit XML report that every CI platform understands. GitHub Actions:
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Cucumber Tests
path: target/cucumber-reports/junit.xml
reporter: java-junitJenkins uses the "Publish JUnit test result report" post-build action pointing at **/cucumber-reports/junit.xml.
CI Configuration
A complete GitHub Actions workflow for a Maven + Cucumber project:
name: BDD Tests
on:
push:
branches: [main]
pull_request:
jobs:
cucumber:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Run Cucumber tests
run: mvn verify -Dcucumber.execution.parallel.enabled=true
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: test
- name: Upload Cucumber reports
uses: actions/upload-artifact@v4
if: always()
with:
name: cucumber-reports
path: target/cucumber-reports/
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Cucumber BDD Tests
path: target/cucumber-reports/junit.xml
reporter: java-junitControlling Parallelism in CI
CI runners typically have 2-4 vCPUs. The dynamic strategy will use all of them. If you want to cap parallelism to avoid overwhelming a shared test database:
- name: Run Cucumber tests
run: mvn verify
env:
cucumber.execution.parallel.enabled: "true"
cucumber.execution.parallel.config.strategy: "fixed"
cucumber.execution.parallel.config.fixed.parallelism: "2"Or pass as system properties:
mvn verify \
-Dcucumber.execution.parallel.enabled=true \
-Dcucumber.execution.parallel.config.strategy=fixed \
-Dcucumber.execution.parallel.config.fixed.parallelism=2Tagging for Selective Execution
Tags control which scenarios run in which contexts. Common patterns:
@smoke
Scenario: Basic login
@regression @slow
Scenario: Full checkout flow with payment processing
@wip
Scenario: New feature not yet completeRun only smoke tests in pre-deployment checks:
mvn test -Dcucumber.filter.tags="@smoke"Run everything except slow tests and wip:
mvn test -Dcucumber.filter.tags="not @slow and not @wip"In the suite class:
@ConfigurationParameter(
key = FILTER_TAGS_PROPERTY_NAME,
value = "not @wip and not @slow"
)Tags compose with boolean operators: and, or, not. Use them to create fast feedback loops (smoke on every commit, regression on merge to main, full suite nightly).
Common Parallel Execution Problems
Scenarios pass individually but fail in parallel Almost always shared mutable state. Audit every instance field in every step definition class. Move anything that holds per-scenario data into a @ScenarioScope bean.
Database constraint violations or duplicate key errors Two scenarios are inserting data with the same identifiers. Add a scenarioId prefix to all test data identifiers (emails, usernames, order IDs).
Port already in use Two scenarios are both starting an embedded server on the same port. If using WebEnvironment.RANDOM_PORT, Spring picks a free port per context. If using DEFINED_PORT, scenarios share the port — which is fine as long as you're not trying to start/stop the server per scenario.
Tests are slower in parallel than sequential Usually means the Spring context is being reloaded too often. Check for @DirtiesContext annotations and remove any that aren't strictly necessary. One shared context across all scenarios is almost always faster than per-scenario contexts, even in parallel.
Thread count has no effect The cucumber.execution.parallel.enabled=true property must be visible to the Cucumber engine. If it's set in Surefire's configurationParameters but also overridden to false in cucumber.properties, the file takes precedence. Check for conflicting configurations.
Parallel Cucumber with JUnit 5 is not complicated once you accept that all per-scenario state must live in @ScenarioScope beans and all shared resources must use unique identifiers. The configuration overhead is a one-time cost. The payoff is a BDD suite that gives you feedback in minutes instead of half an hour.
HelpMeTest complements your Cucumber suite with AI-powered E2E monitoring that runs plain-English tests continuously against production — no code required, usage-based pricing at $0.003 per test run.