Cucumber-JVM with JUnit 5 and Parallel Execution

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=dynamic

The 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=4

Or 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 that

To explicitly set scenario-level:

cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=fixed
cucumber.execution.parallel.config.fixed.parallelism=8

Scenario-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-junit

Jenkins 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-junit

Controlling 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=2

Tagging 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 complete

Run 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.

Read more

Start now free