Skip to content
SDETRole
SDETRole

Lesson 11 of 12 · Automation Frameworks

Parallel Execution

Run suites faster with workers, sharding and Selenium Grid: how Playwright, JUnit 5 and TestNG parallelise, what makes tests unsafe to run in parallel, and how to fix it.

intermediate 8 min read Updated

You will learn to

  • Distinguish worker-level parallelism, sharding across machines and remote browser grids
  • Configure fullyParallel, workers and shards in Playwright
  • Run Selenium tests in parallel with JUnit 5 or TestNG and thread-safe drivers
  • Diagnose and fix tests that pass alone but fail in parallel

Prerequisites

  • Framework Architecture
  • Test Data Management

A UI suite that takes an hour to run gets run less often, and feedback that arrives an hour after a commit is feedback developers have already stopped waiting for. Parallel execution is the most direct way to shorten that loop. It is also where weaknesses in test design surface: tests that quietly depended on each other, on shared accounts or on running in a particular order start failing as soon as they run side by side.

Three levels of parallelism

Level What runs in parallel Example
Workers Several test processes or threads on one machine Playwright workers, JUnit 5 parallel execution, TestNG thread-count
Sharding Slices of the suite on several machines npx playwright test --shard=2/4 on four CI jobs
Remote browsers Browser sessions on other machines or containers Selenium Grid, cloud device and browser providers

They combine: four CI shards, each with four workers, each driving a browser locally or on a grid.

Parallelism has limits. Total time can never drop below your slowest single test, plus setup such as installing browsers. More workers also compete for CPU and memory on the machine, and every worker adds load on the application under test. Past a point, adding workers makes tests slower and flakier. Measure, then tune.

Playwright: workers and fullyParallel

Playwright runs tests in worker processes. Each worker is a separate OS process with its own browser, so workers share nothing in memory.

By default:

  • different test files run in parallel across workers;
  • tests within one file run in order, in the same worker;
  • the number of workers defaults to half the CPU cores.
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  fullyParallel: true,                    // also parallelise tests within each file
  workers: process.env.CI ? 4 : undefined, // undefined = default (half the cores)
  retries: process.env.CI ? 2 : 0,
});

fullyParallel: true lets Playwright distribute individual tests rather than whole files, which balances load better when some files are much bigger than others. It requires that tests within a file are independent, which they should be anyway.

You can override per file or describe block:

// a group that really must share state and run in order
test.describe.configure({ mode: 'serial' });

Serial mode means that if one test fails, the remaining tests in the group are skipped, and the whole group retries together. Treat it as a last resort for genuinely sequential journeys, not as a fix for flaky tests.

From the command line, npx playwright test --workers=1 is the quickest way to check whether a failure is caused by parallelism.

Worker-aware data

Each test can find out which worker it is running in. testInfo.parallelIndex is a number from 0 to workers - 1, stable for the slot even when a worker process is restarted after a failure, which makes it suitable for picking from a pool of pre-created accounts:

import { test as base } from '@playwright/test';

const accounts = ['qa-user-0@example.com', 'qa-user-1@example.com', 'qa-user-2@example.com', 'qa-user-3@example.com'];

export const test = base.extend<{}, { account: string }>({
  account: [
    async ({}, use, workerInfo) => {
      await use(accounts[workerInfo.parallelIndex % accounts.length]);
    },
    { scope: 'worker' },
  ],
});

Creating a fresh, unique user per test is still better where the system allows it. Account pools are a pragmatic option when user creation is slow or restricted.

Sharding across machines

npx playwright test --shard=1/4   # job 1
npx playwright test --shard=2/4   # job 2
# ... and so on

Each job runs a quarter of the suite. With fullyParallel: true Playwright shards by individual test; without it, by file, which can leave shards unbalanced when file sizes vary. Combine with the blob reporter and merge-reports to get one report (see Test Reporting and CI/CD Integration).

Selenium with JUnit 5

JUnit 5 parallel execution is switched on in src/test/resources/junit-platform.properties:

junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4

With JUnit 5’s default lifecycle, each test method gets a new instance of the test class, so a WebDriver held in an instance field and created in @BeforeEach is not shared between threads:

class SearchTest {
    private WebDriver driver; // one per test instance, so one per thread

    @BeforeEach
    void setUp() {
        driver = new ChromeDriver();
    }

    @AfterEach
    void tearDown() {
        driver.quit();
    }
}

The danger is static state: a static driver, a static “current user” or a singleton page object factory will be shared across threads and corrupt each other’s tests. Use @ResourceLock on tests that must not run concurrently with others touching the same resource.

Selenium with TestNG

In TestNG, parallelism is configured in testng.xml:

<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Regression" parallel="methods" thread-count="4">
  <test name="UI">
    <classes>
      <class name="com.example.tests.SearchTest"/>
      <class name="com.example.tests.CheckoutTest"/>
    </classes>
  </test>
</suite>

Unlike JUnit 5, TestNG runs all methods of a class on the same instance. With parallel="methods", an instance field holding the driver is shared by concurrent threads, which is a classic cause of tests typing into each other’s browsers. The standard fix is a ThreadLocal:

public final class DriverManager {
    private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();

    public static void start() {
        DRIVER.set(new ChromeDriver());
    }

    public static WebDriver get() {
        return DRIVER.get();
    }

    public static void stop() {
        WebDriver driver = DRIVER.get();
        if (driver != null) {
            driver.quit();
            DRIVER.remove(); // avoid leaking drivers between tests on pooled threads
        }
    }
}

Call start() in @BeforeMethod and stop() in @AfterMethod(alwaysRun = true). parallel="classes" is a simpler alternative: each class runs in one thread, so instance fields are safe, at the cost of coarser balancing.

Selenium Grid

When one machine cannot run enough browsers, or you need browsers it does not have, use Selenium Grid 4. The quickest start is the official Docker image:

docker run -d -p 4444:4444 --shm-size="2g" selenium/standalone-chrome

Or, with Java installed, java -jar selenium-server-<version>.jar standalone. Tests connect with RemoteWebDriver instead of a local driver:

WebDriver driver = new RemoteWebDriver(
    URI.create("http://localhost:4444").toURL(),
    new ChromeOptions());

Grid queues session requests beyond its capacity, so a suite with more threads than browser slots waits rather than fails. Cloud browser providers expose the same WebDriver endpoint model with many more browser and OS combinations.

Why tests fail in parallel

When a suite passes with one worker and fails with many, the cause is almost always shared state:

Symptom Likely cause Fix
Wrong data in assertions, “expected 3 items, got 4” Tests share a user, cart or record Unique test-owned data per test; account pools per worker
Test passes alone, fails after another test Hidden ordering dependency Each test sets up its own preconditions
Random login failures, sessions logged out Same account logged in from several browsers Separate accounts, or token-based auth per worker
Files overwritten, corrupt downloads Fixed output paths Use testInfo.outputPath() or per-thread temp directories
Timeouts rise with more workers Machine or application saturated Fewer workers, more shards, or a bigger environment
Java tests interfering with each other Static or shared driver Per-instance driver (JUnit 5) or ThreadLocal (TestNG)

Practice exercise

A Playwright suite passes with --workers=1 but, with 4 workers, cart.spec.ts fails intermittently: expect(cartCount).toHaveText('1') receives '2' or '3'. Every test logs in as shopper@example.com via storageState, and each adds one product to the cart. Explain the cause and propose two fixes, with code for one. Try the fixture pattern in the Playwright Lab.

Show a model answer

Cause: all workers share one account, and the cart is stored server-side per account. When tests run concurrently, each one sees items added by the others. With one worker they happen to run one after another, and each test probably clears or ends with an empty cart, which hides the dependency.

Fix 1 (best): give each test its own user, created through the API in a fixture, and log in programmatically:

export const test = base.extend<{ shopperPage: Page }>({
  shopperPage: async ({ browser, request }, use, testInfo) => {
    const email = `shopper.${testInfo.testId}.${Date.now()}@example.com`;
    const res = await request.post('/api/test/users', { data: { email, password: 'Secret123!' } });
    expect(res.status()).toBe(201);

    const context = await browser.newContext();
    const page = await context.newPage();
    await page.request.post('/api/login', { data: { email, password: 'Secret123!' } });
    await use(page);
    await context.close();
  },
});

page.request shares cookies with the page’s browser context, so the login session applies to the page.

Fix 2 (pragmatic): a pool of pre-created accounts, one per worker, selected with workerInfo.parallelIndex, plus clearing the cart through the API at the start of each test. This avoids creating users but still relies on a worker running its tests sequentially.

Either way, tests no longer depend on what other tests do, so they pass with any number of workers and in any order.

Key takeaways

  • Parallelism happens at three levels: workers on one machine, shards across machines, and remote browsers on a grid. They combine.
  • In Playwright, fullyParallel, workers and --shard control distribution; serial mode is a last resort.
  • JUnit 5 creates a test instance per method, so instance-field drivers are safe; TestNG shares instances, so use ThreadLocal or parallel="classes".
  • Tests that fail only in parallel almost always share state: accounts, data, files or static fields. Fix the isolation, not the worker count.
  • More workers are not free: measure the effect on duration and stability, and scale the environment with them.

Key terms

Finished this lesson?

Progress is saved in this browser. No account needed.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →