Parallel Execution with Cucumber

8 min read

A 50-scenario UI suite running sequentially takes 10–15 minutes. The same suite on 4 threads finishes in 3–4 minutes. That's the difference between a pipeline that gates every commit and one that developers skip because it's too slow. Parallel execution is not a luxury for large Cucumber suites — it's a requirement.

What Cucumber parallelises

Cucumber 7 with the JUnit Platform engine supports scenario-level parallelism: individual scenarios run concurrently across a thread pool. Each scenario gets its own step definition instances (PicoContainer creates fresh objects per scenario), its own TestContext, and its own browser session. Scenarios are independent by design — parallelism exposes any place where that independence is violated.

Enabling parallel execution

Option 1: junit-platform.properties (the simplest approach)

Create src/test/resources/junit-platform.properties:

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

No code changes needed. Run mvn test and 4 scenarios execute simultaneously.

Option 2: Maven Surefire configuration (keeps config in pom.xml)

<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=fixed
                cucumber.execution.parallel.config.fixed.parallelism=4
            </configurationParameters>
        </properties>
    </configuration>
</plugin>

Option 3: dynamic thread count (scales to available CPUs)

cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=dynamic
cucumber.execution.parallel.config.dynamic.factor=1.0

With dynamic strategy and factor 1.0, Cucumber uses one thread per available CPU. Factor 0.5 uses half the CPUs — useful on shared CI agents.

Parallelism strategies

Three execution modes:

# Run scenarios in the same feature file sequentially; features run in parallel
cucumber.execution.parallel.config.strategy=fixed
cucumber.execution.parallel.config.fixed.parallelism=4
 
# All scenarios run in parallel regardless of feature file
# (default when parallel is enabled — scenarios are the unit)

Scenario-level parallelism is more granular — a single large feature file is no longer a bottleneck. Feature-level parallelism is safer for codebases that haven't yet cleaned up shared state.

The non-negotiable: thread-safe step state

Parallel execution breaks every form of shared mutable state. The three patterns to eliminate:

Static WebDriver fields:

// ❌ BROKEN in parallel
public class LoginSteps {
    private static WebDriver driver = new ChromeDriver();
}

Thread 1 quits the driver. Thread 2 tries to use it. NoSuchSessionException.

Static RestAssured baseURI overrides:

// ❌ BROKEN in parallel — racing writers
RestAssured.baseURI = "https://env1.api.com";   // thread 1
RestAssured.baseURI = "https://env2.api.com";   // thread 2 — overwrites

Build a RequestSpecification per scenario and pass it through TestContext instead of using the global RestAssured statics.

Static counters or accumulators in step definitions:

// ❌ BROKEN in parallel
public class CartSteps {
    private static int itemCount = 0;
}

With PicoContainer, TestContext and step definition classes are scenario-scoped. If all state is stored in TestContext instance fields (never static), scenarios are isolated and parallelism is safe.

Running specific tag subsets in parallel

Parallel execution composes naturally with tag filtering:

# Run smoke suite on 4 threads, headless
mvn test \
  -Dcucumber.filter.tags="@smoke" \
  -Dheadless=true \
  -Dcucumber.execution.parallel.enabled=true \
  -Dcucumber.execution.parallel.config.fixed.parallelism=4

Or bake parallel config into dedicated runner classes:

@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "stepdefinitions,hooks")
@ConfigurationParameter(key = FILTER_TAGS_PROPERTY_NAME, value = "@regression and not @wip")
@ConfigurationParameter(key = "cucumber.execution.parallel.enabled", value = "true")
@ConfigurationParameter(key = "cucumber.execution.parallel.config.strategy", value = "fixed")
@ConfigurationParameter(key = "cucumber.execution.parallel.config.fixed.parallelism", value = "4")
public class ParallelRegressionRunner { }

Synchronisation for truly shared resources

Most test suites are fully parallel-safe after removing static fields. Occasionally a test needs an exclusive resource — a database sequence, a unique email generator, a rate-limited API key. Use synchronized blocks or AtomicInteger for atomic counters:

public class TestData {
    private static final AtomicInteger userCounter = new AtomicInteger(0);
 
    public static String uniqueEmail() {
        return "user" + userCounter.incrementAndGet() + "@test.com";
    }
}

AtomicInteger is thread-safe without synchronized. For complex coordination (waiting until a resource is free), ReentrantLock or Semaphore from java.util.concurrent are appropriate — but these situations are rare if your test data strategy is sound.

Speed impact: parallel vs sequential

UI scenarios don't scale linearly beyond ~4 threads because browser startup and CPU contention become the bottleneck. API scenarios scale much better — 16+ threads is realistic for pure API suites where there's no browser overhead.

⚠️ Common mistakes

  • Enabling parallel before removing static state. Run grep -r "static.*WebDriver\|static.*driver" src/test/ before enabling parallelism. Any match is a race condition waiting to happen.
  • Shared test data in a database. If two parallel scenarios both create a user named "TestUser" and then assert "TestUser exists", one scenario's assertion may see the other's data. Use unique values per scenario: timestamps, UUID.randomUUID(), AtomicInteger counters.
  • Screenshots writing to the same file. driver.getScreenshotAs(new File("screenshot.png")) in two parallel scenarios overwrites the same file. Attach screenshots to the Scenario object instead — Cucumber handles unique storage per scenario.
  • @BeforeSuite and @AfterSuite with shared state. @Before is per-scenario (PicoContainer scope). Suite-level setup (e.g., starting a WireMock server once) goes in a JUnit 5 @BeforeAll in the runner class or a separate setup mechanism — not in Cucumber hooks.

🎯 Practice task

Enable and verify parallel execution for your BDD suite. 30–40 minutes.

  1. Add junit-platform.properties with cucumber.execution.parallel.enabled=true and parallelism=2.
  2. Search your codebase for static WebDriver or static driver — remove any found. Confirm all driver access goes through TestContext.
  3. Run mvn test -Dcucumber.filter.tags="@ui" with 2 threads. Watch 2 Chrome windows open simultaneously in the console output.
  4. Add a log line to @Before and @After that prints the thread name: Thread.currentThread().getName(). Run again — confirm different thread names appear for concurrent scenarios.
  5. Increase to parallelism=4. Run the full @regression suite. Time it with and without parallel and compare.
  6. Stretch: introduce a deliberate race condition by adding a static int counter = 0; to a step definition class and incrementing it without synchronisation. Run 10 scenarios in parallel. Observe non-deterministic failures or incorrect final counts. Fix with AtomicInteger and confirm the issue resolves.

Next lesson: reporting — Cucumber HTML, the cucumber-reporting plugin, Allure, and ExtentReports.

// tip to track lessons you complete and pick up where you left off across devices.