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=4Or 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 -BAll 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 workersTwo parallel jobs (Level 2), each using 4 threads internally (Level 1) = 8 tests running simultaneously.
Parallelism impact on a 200-test suite (baseline: 40 min serial)
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
ThreadLocalWebDriver. A shared static driver field causes tests to control the wrong browser at random. Every parallel Selenium project needsThreadLocal<WebDriver>— no exceptions. Set it up before you enableparallel="methods"in TestNG. - Setting
thread-counthigher 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 TestNGgroups, 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.
- 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 withThreadLocalor per-test initialisation. - If you use JUnit 5: add the
junit-platform.propertiesfile withparallelism=4. Run. Same check for shared state. - If you use Playwright: confirm
workers: 4(or your CI machine's CPU count) is set inplaywright.config.ts. - Measure the before/after run time. Record it.
- Extend to CI: update your GitHub Actions workflow to run two test groups as separate parallel jobs (
unit-testsandui-smokeas separate jobs in thejobs: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.