Cucumber Parallel Execution: Run BDD Tests Faster in CI
A Cucumber test suite with 200 scenarios running sequentially might take 10-15 minutes. That's too long for a feedback loop during development and an obstacle in CI pipelines. Parallel execution can cut that to 2-4 minutes, but it requires making your tests thread-safe.
This guide covers configuring parallel execution in JUnit Platform, making step definitions thread-safe, handling database isolation between parallel scenarios, and avoiding the common pitfalls.
How Cucumber Parallel Execution Works
Cucumber on JUnit Platform can execute scenarios in parallel using Java's ForkJoinPool or a fixed thread pool. Each thread runs one scenario at a time. Scenarios share the JVM but each gets its own Cucumber context (step definition instances, hooks, scenario state).
The fundamental constraint: scenarios must be completely independent. Parallel execution surfaces dependencies that happen to work in sequential order but fail non-deterministically when order isn't guaranteed.
Basic Configuration
Create src/test/resources/junit-platform.properties:
# Enable parallel execution
cucumber.execution.parallel.enabled=true
# Execution strategy
cucumber.execution.parallel.config.strategy=dynamic
# For fixed thread count instead of dynamic:
# cucumber.execution.parallel.config.strategy=fixed
# cucumber.execution.parallel.config.fixed.parallelism=4Strategy options:
dynamic— JUnit Platform chooses thread count based on available processors. Good default.fixed— Explicit thread count. Use this when you have resource constraints (database connections, external API rate limits).custom— ImplementParallelExecutionConfigurationStrategyfor full control.
That's the minimal configuration. Scenarios run in parallel without any other changes.
What Will Break Immediately
Before diving into solutions, here's what breaks in most test suites when you enable parallelism:
1. Shared static state:
// BROKEN — shared static state
public class UserSteps {
private static User currentUser; // Same variable for all threads!
@Given("I am logged in as {string}")
public void i_am_logged_in_as(String name) {
currentUser = userService.findByName(name); // Thread A sets this...
// Thread B runs here and overwrites it
}
@Then("my username should be {string}")
public void my_username_should_be(String expected) {
assertEquals(expected, currentUser.getName()); // Reads Thread B's user!
}
}2. Shared database state: Two scenarios both insert a user with email test@example.com. The second one gets a unique constraint violation.
3. Shared WireMock server: Scenario A stubs GET /api/users to return Alice. Scenario B stubs the same endpoint to return Bob. One scenario's requests hit the other's stub.
4. Shared Spring beans with mutable state: A @Bean that accumulates state across calls will get contaminated by parallel scenarios.
Making Step Definitions Thread-Safe
Use Instance Variables, Not Static
Each Cucumber test gets its own instance of each step definition class (when using @ScenarioScope or Cucumber's PicoContainer DI). Use instance variables:
// SAFE — instance variable
public class OrderSteps {
private Order currentOrder; // Each scenario gets its own instance
@When("I create an order for {string}")
public void i_create_an_order_for(String product) {
currentOrder = orderService.create(product); // Thread-safe
}
@Then("the order should be in {string} status")
public void the_order_should_be_in_status(String status) {
assertEquals(status, currentOrder.getStatus()); // Uses this scenario's order
}
}Thread-Safe Shared Context
When multiple step definition classes need to share state within a scenario, use PicoContainer (constructor injection). Cucumber creates a new instance of each class per scenario, so the context is isolated:
// Scenario-scoped context — new instance per scenario
public class ScenarioState {
public String authToken;
public ResponseEntity<?> lastResponse;
public Long createdUserId;
}
// Step definitions receive it via constructor
public class AuthSteps {
private final ScenarioState state;
public AuthSteps(ScenarioState state) { // Constructor injection
this.state = state;
}
@Given("I am authenticated as {string}")
public void i_am_authenticated_as(String username) {
state.authToken = authService.login(username);
}
}
public class ProfileSteps {
private final ScenarioState state;
public ProfileSteps(ScenarioState state) {
this.state = state;
}
@When("I view my profile")
public void i_view_my_profile() {
state.lastResponse = restTemplate
.withHeader("Authorization", "Bearer " + state.authToken)
.get("/api/profile");
}
}Each scenario gets its own ScenarioState, AuthSteps, and ProfileSteps instances. No sharing.
With Spring: use @ScenarioScope from cucumber-spring:
@Component
@ScenarioScope // One instance per scenario, not per Spring context
public class ScenarioState {
public String authToken;
public ResponseEntity<?> lastResponse;
}Database Isolation for Parallel Tests
Database state is the hardest parallel isolation problem. Options:
Option 1: Separate Schemas Per Thread
Each thread uses a dedicated database schema. No collision possible.
@Configuration
public class ParallelTestDatabaseConfig {
@Bean
@ScenarioScope
public DataSource scenarioDataSource() throws SQLException {
// Create a schema named after the thread
String schemaName = "test_" + Thread.currentThread().getId();
DataSource ds = createDataSource(schemaName);
createSchema(ds, schemaName);
runMigrations(ds);
return ds;
}
}This is clean but requires your test datasource to be @ScenarioScope (or thread-local), which may require additional configuration.
Option 2: Unique Data Per Scenario
Design tests to use data that won't conflict with other scenarios:
# Instead of hardcoded email:
Given a user exists with email "test@example.com"
# Use a random or scenario-unique identifier:
Given a user exists with a unique email address@Given("a user exists with a unique email address")
public void a_user_exists_with_unique_email() {
String uniqueEmail = "test-" + UUID.randomUUID() + "@example.com";
state.user = userService.createUser(uniqueEmail);
}Works well for create-and-verify scenarios. Fails for scenarios that need to find pre-existing data.
Option 3: @Before Cleanup + Retry-On-Conflict
@Before
public void cleanupTestData() {
// Delete data from previous failed runs matching this test's data
userRepository.deleteByEmailStartingWith("test-");
orderRepository.deleteByStatusAndCreatedAtBefore("test-pending", oneHourAgo());
}Fragile — depends on cleanup not interfering with other running scenarios.
Option 4: Transactional Rollback (Works for Service-Layer Tests)
@CucumberContextConfiguration
@SpringBootTest
@Transactional // Each scenario runs in a transaction that rolls back
public class CucumberSpringConfiguration {}Limitation: Transaction rollback doesn't work for full HTTP tests. The HTTP request runs in the server's transaction, not the test's transaction. Use this only when testing the service layer directly (no HTTP calls in test scenarios).
Option 5: H2 with Unique Database Per Scenario
@Bean
@ScenarioScope
public DataSource scenarioH2DataSource() {
String dbName = "testdb-" + UUID.randomUUID().toString().replace("-", "");
return DataSourceBuilder.create()
.url("jdbc:h2:mem:" + dbName + ";DB_CLOSE_DELAY=-1")
.driverClassName("org.h2.Driver")
.build();
}Each scenario gets its own H2 in-memory database. Completely isolated. Fast. Only works with H2 (not production-like).
WireMock Parallelism
If you use WireMock for external service mocking, each parallel scenario needs its own WireMock server on a different port:
@Component
@ScenarioScope
public class WireMockFixture {
private WireMockServer server;
private int port;
@PostConstruct
public void start() {
server = new WireMockServer(wireMockConfig().dynamicPort());
server.start();
port = server.port();
}
@PreDestroy
public void stop() {
server.stop();
}
public WireMockServer getServer() { return server; }
public String baseUrl() { return "http://localhost:" + port; }
}Inject WireMockFixture into step definitions. Each scenario gets its own WireMock server with its own port — stubs don't leak between scenarios.
Controlling Parallel Degree
More threads isn't always faster:
Factors limiting parallel benefit:
- Database connection pool size
- WireMock server capacity
- Application thread pool size
- I/O-bound operations (disk, network)
For a typical CI machine with a PostgreSQL database:
# Start conservative — increase based on actual results
cucumber.execution.parallel.config.strategy=fixed
cucumber.execution.parallel.config.fixed.parallelism=4Monitor database connection pool exhaustion:
# application-test.yaml
spring:
datasource:
hikari:
maximum-pool-size: 20 # Enough for 4 parallel scenarios x 5 connections each
connection-timeout: 10000Detecting Flaky Tests Before Enabling Parallelism
Run existing tests with different orderings before enabling parallel execution:
# Randomize execution order to surface order-dependent tests
mvn test -Dcucumber.execution.order=random -Dcucumber.execution.seed=12345
# Run multiple times with different seeds
for seed in 1 2 3 4 5; do
mvn test -Dcucumber.execution.order=random -Dcucumber.execution.seed=$seed
doneTests that fail with random ordering will fail with parallel execution. Fix them first.
Monitoring Parallel Test Health
Add logging to track which scenario is running on which thread:
public class ParallelTestHooks {
@Before
public void logScenarioStart(Scenario scenario) {
log.info("Thread {} starting: {}",
Thread.currentThread().getId(),
scenario.getName());
}
@After
public void logScenarioEnd(Scenario scenario) {
log.info("Thread {} finished: {} — {}",
Thread.currentThread().getId(),
scenario.getName(),
scenario.isFailed() ? "FAILED" : "PASSED");
}
}When tests fail intermittently in parallel, these logs help identify which scenarios interfere with each other.
Measuring the Speedup
Before and after enabling parallelism:
# Sequential
time mvn test -Dcucumber.execution.parallel.enabled=false
# Parallel with 4 threads
time mvn test -Dcucumber.execution.parallel.enabled=true \
-Dcucumber.execution.parallel.config.strategy=fixed \
-Dcucumber.execution.parallel.config.fixed.parallelism=4A well-isolated test suite with 200 scenarios and 4 threads should see roughly 3-4x speedup (not exactly 4x due to setup overhead and sequential scenarios).
Wrapping Up
Parallel Cucumber execution is straightforward to configure — one property file change. Making it work requires thread-safe step definitions, isolated scenario state, and isolated database resources.
The payoff is significant. A test suite that takes 12 minutes sequentially often runs in 3-4 minutes with 4 threads. For developer feedback loops and CI pipelines, that difference matters.
Start with dynamic parallel strategy and run on a sample of your scenarios. Fix the failures that appear (usually static state and shared database records), then enable for the full suite.