Parallel Test Execution Strategies

9 min read

Serial test execution is the default everywhere. TestNG runs one test after another. Playwright runs one browser context at a time. A CI pipeline runs one job at a time unless you tell it otherwise. If your 200-test suite takes 40 minutes in serial, it will still take 40 minutes in CI unless you actively change the execution model. This lesson covers the three levels of parallelism available in any CI setup and how to combine them.

The three levels

Parallelism in test automation happens at three distinct levels, from narrowest to widest scope:

Level 1 — In-process threads: tests run in parallel within a single CI job, on multiple threads of one machine. This is the cheapest form of parallelism — it uses hardware you're already paying for.

Level 2 — Parallel jobs: independent test groups run as separate jobs in your CI workflow, each on its own machine. GitHub Actions matrix builds from the previous chapter are level 2 parallelism.

Level 3 — Sharding across machines: a single test suite is sliced into chunks and each chunk runs on a separate machine. This is covered in the next lesson.

These levels compose. A team running level 1 (4 in-process threads) inside level 2 (4 parallel jobs) gets 16× the throughput of a serial baseline — if the test suite is designed to support it.

Level 1: In-process parallelism

TestNG

Configure parallelism in testng.xml:

<suite name="Regression" parallel="methods" thread-count="4" verbose="2">
    <test name="All Tests">
        <packages>
            <package name="com.example.tests"/>
        </packages>
    </test>
</suite>

parallel="methods" runs each @Test method on its own thread. parallel="classes" runs each test class on its own thread — all methods within a class run sequentially on that thread. parallel="tests" runs each <test> tag on its own thread.

thread-count="4" sets the pool size. A safe starting value is 2–4 on a standard CI runner (2–4 vCPUs). Higher counts don't help once you've saturated the CPU — they just increase contention.

The critical requirement: thread-safe tests. Each thread needs its own WebDriver instance. The standard pattern is ThreadLocal<WebDriver>:

public class DriverManager {
    private static final ThreadLocal<WebDriver> driver = new ThreadLocal<>();
 
    public static WebDriver getDriver() { return driver.get(); }
    public static void setDriver(WebDriver d) { driver.set(d); }
    public static void removeDriver() { driver.remove(); }
}

Tests that share a single static WebDriver will race each other, producing random failures that are nearly impossible to debug. Set up ThreadLocal before enabling parallel execution — the order matters.

JUnit 5

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=4

Or annotate individual classes:

@Execution(ExecutionMode.CONCURRENT)
class CheckoutTests { ... }

Playwright

Playwright runs tests in parallel by default using workers. Configure the count in playwright.config.ts:

export default defineConfig({
  workers: process.env.CI ? 4 : 2,  // more workers in CI, fewer locally
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
  },
});

Each worker is a separate browser process — no ThreadLocal needed because Playwright's isolation model handles it automatically.

Level 2: Parallel jobs in GitHub Actions

Independent test types can run as separate jobs. They start simultaneously and each uses its own runner:

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
      - run: mvn test -Dgroups=unit -B
 
  api-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
      - run: mvn test -Dgroups=api -B
 
  ui-smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
      - run: mvn test -Dgroups=smoke -Dheadless=true -B

All three jobs start immediately when the workflow triggers. Total pipeline time is the longest individual job, not the sum of all three. If unit tests take 3 minutes, API tests take 6 minutes, and UI smoke takes 8 minutes — the pipeline completes in 8 minutes, not 17.

Level 2: Parallel stages in Jenkins

stage('Test') {
    parallel {
        stage('Unit') {
            steps { sh 'mvn test -Dgroups=unit -B' }
        }
        stage('API') {
            steps { sh 'mvn test -Dgroups=api -B' }
        }
        stage('UI Smoke') {
            steps { sh 'mvn test -Dgroups=smoke -Dheadless=true -B' }
        }
    }
}

On a Jenkins setup with multiple agents (or a single agent with enough CPU), these three stages run simultaneously. The parallel block in Declarative Pipeline is the Jenkins equivalent of parallel jobs in GitHub Actions.

Combining levels for maximum throughput

The real gains come from stacking levels. Level 2 (parallel jobs) combined with Level 1 (in-process threads within each job):

jobs:
  api-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
      - run: mvn test -Dgroups=api -DthreadCount=4 -B  # Level 1: 4 threads
 
  ui-smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx playwright test --grep @smoke --workers=4  # Level 1: 4 workers

Two parallel jobs (Level 2), each using 4 threads internally (Level 1) = 8 tests running simultaneously.

Diminishing returns and practical limits

More parallelism is not always better:

  • A 2-vCPU CI runner saturates at 2–4 threads. Adding more threads increases context-switching overhead without improving throughput.
  • Tests that share state (a database record, a user account, a file) cause race conditions under high parallelism. The fix is test isolation, not fewer threads.
  • GitHub Actions free tier has a concurrent job limit. Exceeding it queues jobs rather than running them immediately.

A safe starting strategy: 4 in-process threads for API/unit tests, 2 in-process workers for UI tests (browsers are memory-heavy), and 3–4 parallel jobs across independent test types.

⚠️ Common mistakes

  • Enabling parallel execution without ThreadLocal WebDriver. A shared static driver field causes tests to control the wrong browser at random. Every parallel Selenium project needs ThreadLocal<WebDriver> — no exceptions. Set it up before you enable parallel="methods" in TestNG.
  • Setting thread-count higher than CPU cores. On a 2-vCPU runner, thread-count="16" doesn't give 8× speedup — it gives contention and flakiness. Match thread count to available CPUs.
  • Not tagging tests by type before parallelising. If your test suite doesn't have @Tag("api"), @Tag("ui"), or TestNG groups, you can't split it into parallel jobs cleanly. Add tags first, parallelize second.

🎯 Practice task

Enable in-process parallelism for your test suite — 30 minutes.

  1. If you use TestNG: add parallel="methods" thread-count="4" to your suite XML. Run locally. If tests fail randomly, find any shared static state (static WebDriver, static test data) and fix it with ThreadLocal or per-test initialisation.
  2. If you use JUnit 5: add the junit-platform.properties file with parallelism=4. Run. Same check for shared state.
  3. If you use Playwright: confirm workers: 4 (or your CI machine's CPU count) is set in playwright.config.ts.
  4. Measure the before/after run time. Record it.
  5. Extend to CI: update your GitHub Actions workflow to run two test groups as separate parallel jobs (unit-tests and ui-smoke as separate jobs in the jobs: block). Confirm both appear as separate status checks on a pull request.

The next lesson adds a third level of parallelism: sharding — splitting the test suite across multiple CI runners, each running a fraction of the total tests.

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