Skip to main content

Run Parallel and Serial Tests Together in Playwright

Playwright tests that change global state break parallel test runs. Learn different ways to run serial and parallel tests together without failures.

Modern test frameworks like Playwright allow you to run your tests in parallel instead of serially, allowing for faster feedback loops in your automated test runs. Serial test execution is many times slower than parallel runs because you are running tests one at a time. Every test you add slows your test pipeline down by the duration of that test. Parallel execution of your test automation lets you concurrently execute tests, where, with enough workers, your test pipeline would only be as slow as the longest-running test in the test suite.

This article covers a common problem that keeps test suites from running fully in parallel: tests that change global state to set up preconditions, which breaks other tests running at the same time that expect different preconditions.

This is what I am currently dealing with. Our test framework before Playwright had one big limitation: everything ran serially, or across several isolated test environments. Some tests changed global settings in web.config. Others alter global state by moving the server date to set up a precondition, like a billing rollover at month end. Run any of those next to another test and you break the other test. The entire legacy test suite was built around serial execution concepts that led to inefficient feedback loops that slowed down the Software Development Life Cycle (SDLC).

While modernizing and converting the legacy tests to Playwright, I was able to rework most scenarios to run in parallel without collisions or flakiness, but a few scenarios still required changing global state settings on the server, and they couldn't stay in the suite without making all the tests run serially.

Then I learned Playwright projects can separate parallel-safe tests from serial-only ones and run the serial ones on their own.

Three Ways to Run Serial and Parallel Tests Together

  • Option A, project dependencies (the quick win): Split the tests into two projects and let Playwright run the serial one after the parallel one passes. It's a few lines of config and no CI changes, but there's a catch: one failing parallel test means the serial tests never run.
  • Option B, separate CI steps (fixes the catch): The same split, with each project run as its own CI step. It takes more wiring, but you get results from both suites on every run.
  • Option C, preconfigured environments (my long-term goal): Stop changing settings from inside the tests. Stand up environments already configured for each precondition and point each test group at its own. It's the fastest and cleanest, but it isn't always feasible because of hosting cost and how easily you can create environments.

Options A and B split the tests the same way, by filename or by tag.

I built a small demo of every approach in this article. It's in the playwright-serial-parallel-dependencies-demo repo, and the results below come from running it.

Demoing the Problem: Playwright Tests That Change Shared State

The demo uses one shared JSON file as a stand-in for global server state, holding a feature flag and a "server date". The parallel-safe tests in the demo project only read it and expect the defaults. The serial-only tests change its state to set up a precondition, then reset it afterward:

tests/server-settings.serial.spec.ts
test.afterEach(async () => {
  await resetSettings();
});

test('server date set to month end triggers billing rollover', async () => {
  await writeSettings({ featureFlag: 'off', serverDate: '2026-01-31' });
  expect((await readSettings()).serverDate).toBe('2026-01-31');
});

To see the shared state collision problem you can run npm run test:collision which evaluates to playwright test -c playwright.collision.config.ts. The output below is trimmed to the first failure's details.

Terminal
npm run test:collision

> playwright-serial-parallel-dependencies-demo@1.0.0 test:collision
> playwright test -c playwright.collision.config.ts


Running 11 tests using 5 workers

   1 tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 4 uses default settings (319ms)
   2 tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 1 uses default settings (319ms)
   3 tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 3 uses default settings (318ms)
   4 tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 2 uses default settings (312ms)
  -   9 tests\reports.spec.ts:15:7 › reports (parallel-safe) › deliberately failing test (only when DEMO_FAIL=1)
   5 tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 1 renders with default settings (311ms)
   8 tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 3 renders with default settings (305ms)
   7 tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 4 renders with default settings (305ms)
   6 tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 2 renders with default settings (307ms)
  10 tests\server-settings.serial.spec.ts:13:5 › feature flag on enables new checkout (515ms)
  11 tests\server-settings.serial.spec.ts:19:5 › server date set to month end triggers billing rollover (512ms)


  1) tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 2 renders with default settings ──

    Error: expect(received).toBe(expected) // Object.is equality

    Expected: "off"
    Received: "on"

    ...

  3 failed
    tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 2 renders with default settings ───
    tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 3 renders with default settings ───
    tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 4 renders with default settings ───
  1 skipped
  7 passed (1.7s)

As shown above, running them together in a parallel pool introduces failures when the tests that expect the default state evaluate when the serial tests change the state before the teardown runs. The demo holds the changed state for half a second to make the collision likely, but a real backend process reacting to a changed date, feature flag, or web.config setting fails the same way, just less predictably.

"Serial" Means Three Different Things in Playwright

Playwright has a few different ways of configuring serial runs depending on your use case and intention.

  • workers: 1 limits how many tests run at once. It says nothing about order, and one failure doesn't affect the other tests.
  • test.describe.configure({ mode: 'serial' }) groups tests that depend on each other. The Playwright docs say: "If one of the serial tests fails, all subsequent tests are skipped." That's right for a chain of dependent steps, and wrong for independent tests that each change some state.
  • A serial project is a separate set of tests, selected by a matcher, that runs apart from the parallel-safe set. This is the one that solves the mixed-suite problem.

Even in parallel test runs many of my longer system testing user-journey type scenarios will be broken into individual tests in the same file that need to run serially, sequentially. For example, the first test may create an applicant and then the test after uses that applicant to apply for an account and so on. These test spec files use test.describe.configure({ mode: 'serial' }) so that the tests in the spec run sequentially and skip tests that appear after the failing test in that spec file. So for my example, if the applicant creation test fails there is no point running the next test where that applicant is used to apply for an account. Playwright will still parallelize the spec files in that project while running tests within the spec files serially with that setting applied.

Using workers: 1 on the project will prevent Playwright from running the spec files in parallel as well.

Options A and B that we will cover in the following article sections put the state-changing tests in a serial-only project, and workers: 1 inside that project makes them run one at a time, serially.

Split Tests with Playwright Projects and Matchers

Playwright projects let you run different subsets of your tests with different settings. We will use this feature to determine which tests to run and when in the different solutions we will be showing to solve the parallel + serial test problem.

First, name the state-changing files *.serial.spec.ts and match on that using the projects setting in playwright.config.ts:

playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  projects: [
    {
      name: 'parallel-safe',
      testMatch: /.*\.spec\.ts/,
      testIgnore: /.*\.serial\.spec\.ts/,
      fullyParallel: true,
    },
    {
      name: 'serial-only',
      testMatch: /.*\.serial\.spec\.ts/,
      workers: 1, // per-project workers requires Playwright 1.52+
    },
  ],
});

Note the use of testIgnore. *.serial.spec.ts also matches .*\.spec\.ts, so without it the serial files would land in both projects and run in parallel too.

The parallel-safe project will contain tests that play nicely with others: they can run in parallel without colliding with other tests. There we set fullyParallel to true.

The serial-only project will hold the tests that change things that would create failures or flakiness in other tests if run at the same time. There we set the workers to 1 to ensure only one test runs at a time if they are in that project. These tests that don't play nicely with others will have the file suffix .serial.spec.ts so we can use the testMatch parameter to find them.

The different solutions we will be going over use the project classifications in different ways to solve the parallel vs. serial testing problem.

Match on Tags Instead of Filenames

A filename convention forces you to keep state-changing tests in their own files. If you'd rather keep related tests together, tag the serial ones (see Playwright test tags) and match on the tag:

tests-tagged/checkout.spec.ts
test.describe('checkout with mutated server state', { tag: '@serial' }, () => {
  test.describe.configure({ mode: 'serial' });

  test('feature flag on enables new checkout', async () => {
    // ...
  });
});
playwright.tagged.config.ts
projects: [
  { name: 'parallel-safe', grepInvert: /@serial/, fullyParallel: true },
  { name: 'serial-only', grep: /@serial/, workers: 1 },
],

You can skip projects entirely and filter on the command line with --grep @serial and --grep-invert @serial. The demo has that version too.

One trap: the demo keeps its tagged tests in a separate tests-tagged/ folder. A tagged file inside the folder your filename-based projects already scan would be picked up by the parallel-safe project, and the @serial tests would run in parallel anyway.

Option A: Playwright Project Dependencies (The Quick Win)

Once the tests are split, you can use the Playwright dependencies project configuration setting to mark that the serial-only project depends on the parallel-safe project finishing first.

playwright.dependencies.config.ts
{
  name: 'serial-only',
  testMatch: /.*\.serial\.spec\.ts/,
  dependencies: ['parallel-safe'], // the magic happens here
  workers: 1,
},

This is a quick and easy way to solve the problem. The parallel tests run first, and the serial ones run after they finish, so nothing collides. In the demo project, running npm run test:deps will run with this configuration-based solution.

Terminal
npx playwright test -c playwright.dependencies.config.ts

Running 11 tests using 9 workers

  -   9 [parallel-safe] › tests\reports.spec.ts:15:7 › reports (parallel-safe) › deliberately failing test (only when DEMO_FAIL=1)
   1 [parallel-safe] › tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 2 uses default settings (309ms)
   2 [parallel-safe] › tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 4 uses default settings (320ms)
   4 [parallel-safe] › tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 1 uses default settings (307ms)
   3 [parallel-safe] › tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 3 uses default settings (316ms)
   5 [parallel-safe] › tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 1 renders with default settings (319ms)
   6 [parallel-safe] › tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 2 renders with default settings (313ms)
   7 [parallel-safe] › tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 4 renders with default settings (311ms)
   8 [parallel-safe] › tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 3 renders with default settings (319ms)
  10 [serial-only] › tests\server-settings.serial.spec.ts:13:5 › feature flag on enables new checkout (517ms)
  11 [serial-only] › tests\server-settings.serial.spec.ts:19:5 › server date set to month end triggers billing rollover (508ms)

  1 skipped
  10 passed (1.9s)

The Gotcha: A Failing Test Skips the Serial Suite

There is a gotcha or trade-off to leveraging the dependencies setting to separate and run the serial tests after the parallel tests finish. Per the Playwright documentation:

"If the tests from a dependency fails then the tests that rely on this project will not be run."

So if there is a test failure in the parallel tests the serial tests will not run so you'd lose visibility and coverage for that run for your serial tests since they are skipped.

The demo project uses this DEMO_FAIL test that has been getting skipped in the earlier runs to show this downside in action.

Terminal
DEMO_FAIL=1 npx playwright test -c playwright.dependencies.config.ts

Running 11 tests using 9 workers

   9 [parallel-safe] › tests\reports.spec.ts:15:7 › reports (parallel-safe) › deliberately failing test (only when DEMO_FAIL=1) (4ms)
   3 [parallel-safe] › tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 4 renders with default settings (305ms)
   1 [parallel-safe] › tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 2 uses default settings (312ms)
   5 [parallel-safe] › tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 3 uses default settings (317ms)
   4 [parallel-safe] › tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 4 uses default settings (316ms)
   2 [parallel-safe] › tests\pricing.spec.ts:8:9 › pricing (parallel-safe) › quote 1 uses default settings (322ms)
   7 [parallel-safe] › tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 2 renders with default settings (310ms)
   8 [parallel-safe] › tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 3 renders with default settings (310ms)
   6 [parallel-safe] › tests\reports.spec.ts:6:9 › reports (parallel-safe) › report 1 renders with default settings (316ms)


  1) [parallel-safe] › tests\reports.spec.ts:15:7 › reports (parallel-safe) › deliberately failing test (only when DEMO_FAIL=1) 

    Error: forced failure to demonstrate dependency blocking

    ...

  1 failed
    [parallel-safe] › tests\reports.spec.ts:15:7 › reports (parallel-safe) › deliberately failing test (only when DEMO_FAIL=1) 
  2 did not run
  8 passed (656ms)

The two serial tests never ran as indicated by 2 did not run in the terminal output. In a real suite, it means one failing test in a large parallel-safe project hides the results of the entire serial project. It also changes what "the suite ran" means when someone triages a red build. Every failure has to be fixed before you can see whether the second half is healthy at all. In other words, you don't know what other issues are hiding until you fix the early test failures which can make troubleshooting frustrating if you fix one test, rerun, and find another issue that was hiding behind it.

For my suite that's a dealbreaker. The serial tests cover some of the more important financial transactions in the systems that are part of the critical path. I want to know about a failure there on the same run where a parallel test fails, not one fix cycle later.

Option B: Separate CI Steps for Playwright Projects (Fixing the Catch)

As mentioned, Option A, leveraging Playwright dependencies, is an easy solution to keep the serial and parallel tests separated, but has the trade-off where an upstream failure in the parallel test project causes your serial tests to be skipped. The fix in Option B is to keep leveraging Playwright projects to subset the parallel and serial tests, but drop dependencies and let CI do the ordering. The projects stay in the config, but each gets its own invocation:

playwright.config.ts
projects: [
  { name: 'parallel-safe', testMatch: /.*\.spec\.ts/, testIgnore: /.*\.serial\.spec\.ts/, fullyParallel: true },
  { name: 'serial-only', testMatch: /.*\.serial\.spec\.ts/, workers: 1 },
  // no `dependencies` between them
],

In GitHub Actions, run the parallel-safe step first, let the serial step run whether or not the first one failed, then fail the job if either did:

.github/workflows/split-steps.yml
- name: Parallel-safe tests
  id: parallel
  run: npx playwright test --project=parallel-safe
  continue-on-error: true

- name: Serial-only tests
  id: serial
  run: npx playwright test --project=serial-only --workers=1
  continue-on-error: true

- name: Fail job if either suite failed
  if: steps.parallel.outcome == 'failure' || steps.serial.outcome == 'failure'
  run: exit 1

continue-on-error: true keeps a failed step from stopping the job, and the outcome check in the last step reads the result of each step before that setting is applied. The last step is what turns the build red.

Run the same forced failure locally as two separate commands and you get the result the dependency version couldn't:

Terminal
DEMO_FAIL=1 npx playwright test --project=parallel-safe
# 1 failed, 8 passed

npx playwright test --project=serial-only
# 2 passed

The parallel step fails and the serial step still runs and passes. The order is still preserved, because the serial tests only start after the parallel step finishes. The two suites just no longer depend on each other's results with no coverage compromise this time.

The tag version works with the same two steps. Change the commands to --grep-invert @serial and --grep @serial --workers=1.

Option C: Preconfigured Test Environments (My Long-Term Goal)

Both options so far keep the underlying problem. The tests still change global state on the fly, so they still need the environment to themselves. That covers things like the server date, web server settings, language settings, and feature flags.

What I'd rather do is stand up test environments that are already configured with those settings. It's less invasive and tends to be less of a flaky pattern than changing major server settings mid-test. It also lessens the amount of tests in the serial project grouping if groups of tests with similar state settings can run together on isolated environments.

One environment would have its server date set to a month end. One has its language set to Spanish. One has the new-checkout feature flag on. Tests that need a given precondition run against the environment that already has it, so nothing has to change a setting mid-run. That lets each group of tests run isolated from the rest and in parallel with them, with no ordering step in CI at all.

Playwright supports this with per-project use options. The Playwright docs say you can override options for a specific project, and baseURL is one of them. Name the test files after the environment they need, then match on that naming convention:

playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';

dotenv.config({ path: path.resolve(__dirname, '.env') });

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  projects: [
    {
      name: 'parallel-safe',
      testMatch: /.*\.spec\.ts/,
      testIgnore: /.*\.(month-end|spanish|new-checkout)\.spec\.ts/,
      use: { baseURL: process.env.DEFAULT_BASE_URL },
    },
    {
      name: 'month-end-date',
      testMatch: /.*\.month-end\.spec\.ts/,
      use: { baseURL: process.env.MONTH_END_BASE_URL },
    },
    {
      name: 'spanish-locale',
      testMatch: /.*\.spanish\.spec\.ts/,
      use: { baseURL: process.env.SPANISH_BASE_URL },
    },
    {
      name: 'new-checkout-flag',
      testMatch: /.*\.new-checkout\.spec\.ts/,
      use: { baseURL: process.env.NEW_CHECKOUT_BASE_URL },
    },
  ],
});

Each environment flavor gets its own base URL in a .env file, which the config loads with dotenv as the Playwright docs suggest:

.env
DEFAULT_BASE_URL=https://test.example.com
MONTH_END_BASE_URL=https://test-month-end.example.com
SPANISH_BASE_URL=https://test-es.example.com
NEW_CHECKOUT_BASE_URL=https://test-new-checkout.example.com

A test file such as billing.month-end.spec.ts then uses relative navigation like page.goto('/billing'), and baseURL decides which environment it hits. In CI you'd set the same variables from your environment or secrets instead of a file.

The demo repo runs this configuration in one command with no dependencies and no separate steps:

Terminal
npx playwright test -c playwright.environments.config.ts
◇ injected env (4) from .env.example

Running 10 tests using 10 workers

◇ injected env (0) from .env.example
◇ injected env (0) from .env.example
◇ injected env (0) from .env.example
   1 [parallel-safe] › tests-environments\pricing.spec.ts:8:9 › pricing (default environment) › quote 1 runs against the default environment (310ms)
   2 [parallel-safe] › tests-environments\pricing.spec.ts:8:9 › pricing (default environment) › quote 4 runs against the default environment (313ms)
◇ injected env (0) from .env.example
◇ injected env (0) from .env.example
◇ injected env (0) from .env.example
   3 [month-end-date] › tests-environments\billing.month-end.spec.ts:7:9 › billing rollover (month-end environment) › rollover scenario 1 (319ms)
◇ injected env (0) from .env.example
   4 [parallel-safe] › tests-environments\pricing.spec.ts:8:9 › pricing (default environment) › quote 3 runs against the default environment (314ms)
   5 [month-end-date] › tests-environments\billing.month-end.spec.ts:7:9 › billing rollover (month-end environment) › rollover scenario 2 (314ms)
   6 [parallel-safe] › tests-environments\pricing.spec.ts:8:9 › pricing (default environment) › quote 2 runs against the default environment (311ms)
◇ injected env (0) from .env.example
   7 [spanish-locale] › tests-environments\account.spanish.spec.ts:7:9 › account page (Spanish environment) › account scenario 2 (316ms)
◇ injected env (0) from .env.example
   8 [new-checkout-flag] › tests-environments\checkout.new-checkout.spec.ts:7:9 › checkout (new-checkout flag environment) › checkout scenario 1 (306ms)
   9 [new-checkout-flag] › tests-environments\checkout.new-checkout.spec.ts:7:9 › checkout (new-checkout flag environment) › checkout scenario 2 (305ms)
◇ injected env (0) from .env.example
  10 [spanish-locale] › tests-environments\account.spanish.spec.ts:7:9 › account page (Spanish environment) › account scenario 1 (306ms)

  10 passed (726ms)

All four projects ran together, each handing its own baseURL to its tests. The demo's tests check that each project provides a set, valid base URL that differs from every other environment's, but they don't prove an environment is actually configured, since the example URLs don't point at real servers.

The injected env lines are printed by dotenv, not Playwright. The demo loads .env.example so it runs out of the box. The (4) is the main process loading the four base URLs, and the (0) lines appear as each worker starts and loads the config.

Preconfigured Test Environments: The Cost

Preconfigured test environments remove the need for serial tests since you can run those tests in parallel on isolated environments preconfigured with their preconditions where they won't pollute other tests running at the same time on other servers. This is the most performant of the solutions, but may not be possible for everyone.

For example, I want to use this approach, but I want these tests running on every pull request. We may have multiple pull requests that come in at a time and these preconfigured environments, which we provision from a base image with the PR applied, may need 5 or more flavors so that would mean creating 25 environments to support 5 pull requests. Since the system under test requires VMs instead of containers this is pretty expensive in provisioning time and quota.

Below are some things to evaluate:

  • Hosting: Every flavor is another environment to pay for, and your hosting quota may not stretch to it.
  • Creating environments: It only works if you can stand up a preconfigured environment easily and repeatably. If a new environment takes days of manual setup, you won't keep several of them healthy.
  • Drift: Each flavor has to stay close to the baseline environment. A month-end environment that has quietly fallen behind the default one tests a different system.
  • Hardcoded URLs: A test that calls an absolute URL ignores baseURL, and so does any test that reaches the server some other way, like a database connection.
  • Provisioning: Playwright only routes the tests. You still have to build and configure the environments yourself.

If you can't afford it, options A and B above are still solid. They're slower, because the serial tests wait for the parallel ones and run one at a time, but they need no extra environments.

Which Approach to Use for Serial and Parallel Playwright Tests

Option A: Project dependenciesOption B: Separate CI stepsOption C: Preconfigured environments
Tests change global state on the flyYesYesNo, the environment is already set up
Runs state-changing tests after parallel onesYesYes, by step orderNo ordering needed, everything runs together
Runs state-changing tests when a parallel test failsNoYesYes
Single npx playwright test commandYesNo, one command per projectYes
Extra infrastructureNoneNoneOne environment per flavor
SpeedSlowestSlowFastest
Best whenThe serial tests only make sense if the rest passed, like a smoke gateThe two suites are independent and you can't add environmentsYou can stand up preconfigured environments at a reasonable cost

If the serial tests build on the parallel ones, dependencies is the simpler option and skipping them on failure is the right behavior. For tests that only need the environment to themselves, use separate CI steps. If you can afford the environments, use those.

Reduce How Many Tests Need to Run Serially

Splitting the suites fixes when the tests run. It doesn't make the state-changing tests any less fragile, and options A and B still need the environment to themselves. Fewer tests in the serial project means faster runs, so it's worth asking of each one whether it really needs to change global state or could be rewritten to leverage Playwright features that can emulate some of these things.

For example, if a test only depends on the browser's clock, Playwright has a page.clock API for controlling it (see the Playwright Clock docs). I haven't used it. It wouldn't help my case anyway, because the date I care about is on the server and triggers backend business processes, but it might solve some time travel test cases for others.

testInfo.outputPath: When Multiple Tests Save the Same File

If you are running into situations where parallel files touch the same output file you can leverage Playwright testInfo.outputPath command which returns a path scoped to the current test to prevent parallel collisions.

playwright.config.ts
import { test } from '@playwright/test';

test('exports rates PDF', async ({ page }, testInfo) => {
  // ...some sort of file download scenario
  const file = testInfo.outputPath('insurance-rates.pdf'); // Scoped, won't conflict with other tests downloading the same-named file with different contents
  await download.saveAs(filePath);
  // ...verify the file contents
});

Test Locks: When Only a Few Tests Share the Resource

Playwright 1.63 added test locks. Give the tests that touch the same shared resource the same named lock, and Playwright never runs them at the same time, across files, workers and projects, while every other test keeps running in parallel.

tests-locks/mutators.spec.ts
test('feature flag on enables new checkout', { lock: 'server-settings' }, async () => {
  // ...
});

test('server date set to month end triggers billing rollover', { lock: 'server-settings' }, async () => {
  // ...
});

This is a good fit when a few tests only conflict with each other, like several tests that edit the same account setting. Those can stay in the main parallel suite instead of moving to the serial-only project. In the demo, the two tests above overwrote each other's state and failed in every one of 5 runs when I removed the lock, and passed in every one of 5 runs with it.

It doesn't solve my problem, though. A lock only keeps apart the tests that hold it. The parallel-safe tests that read the settings don't hold it, so they keep running while a locked test has the state changed. That's what npm run test:locks:partial shows in the demo:

Terminal
npm run test:locks:partial

> playwright-serial-parallel-dependencies-demo@1.0.0 test:locks:partial
> playwright test -c playwright.locks.config.ts mutators readers-unlocked


Running 6 tests using 5 workers

  3 tests-locks\readers-unlocked.spec.ts:9:9 › reports (no lock) › report 3 renders with default settings (320ms)
  1 tests-locks\readers-unlocked.spec.ts:9:9 › reports (no lock) › report 2 renders with default settings (322ms)
  5 tests-locks\readers-unlocked.spec.ts:9:9 › reports (no lock) › report 1 renders with default settings (315ms)
  4 tests-locks\readers-unlocked.spec.ts:9:9 › reports (no lock) › report 4 renders with default settings (322ms)
  2 tests-locks\mutators.spec.ts:13:5 › feature flag on enables new checkout (518ms)
  6 tests-locks\mutators.spec.ts:19:5 › server date set to month end triggers billing rollover (503ms)


  1) tests-locks\readers-unlocked.spec.ts:9:9 › reports (no lock) › report 1 renders with default settings 

    Error: expect(received).toBe(expected) // Object.is equality

    Expected: "off"
    Received: "on"

    ...

  4 failed
    tests-locks\readers-unlocked.spec.ts:9:9 › reports (no lock) › report 1 renders with default settings 
    tests-locks\readers-unlocked.spec.ts:9:9 › reports (no lock) › report 2 renders with default settings 
    tests-locks\readers-unlocked.spec.ts:9:9 › reports (no lock) › report 3 renders with default settings 
    tests-locks\readers-unlocked.spec.ts:9:9 › reports (no lock) › report 4 renders with default settings 
  2 passed (1.3s)

All four unlocked tests failed in each of 5 runs on my machine. You could give those tests the same lock and everything passes, but then they run one at a time too: the demo took about 2.6 seconds that way, against 1.3 seconds with the readers unlocked. When a change affects every test, like the server date, putting a lock on everything is a serial run again so locking should be used sparingly.

Also note that a lock on any test in a spec file is held for the duration of the whole file. That means if one test in a spec file needs the lock, the other tests in that file count as holding it too, and a locked test in another file has to wait until the whole spec file finishes. Keeping the tests that need a lock in their own file, or turning on fullyParallel, avoids that.

Takeaway: Run Serial and Parallel Playwright Tests Together Without Failures

Tests that change global state don't have to force your whole suite to run serially. Mark them, match them with a filename or a tag, and give them their own project with workers: 1. Then choose how the projects relate:

  • Option A, dependencies, is the quick win when the serial tests should only run once everything else has passed.
  • Option B, separate CI steps, takes a little more wiring, but a red parallel run never hides your serial results.
  • Option C, preconfigured environments, is my long-term goal: if you can afford them, the tests never have to change global state at all.

The demo repo has every variant if you want to try them. For more, see my Playwright articles.