Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 24
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 5
CI/CD 6
  • GitHub Actions
  • Slack Reporting
QA Strategy 4
Case Studies 5
Blog/Automation
AutomationHalmurat T./March 17, 2026/12 min

Shared Test Users Are Sabotaging Your Parallel Suite

Filed underplaywright/parallel-execution/framework-design/design-patterns
Shared Test Users Are Sabotaging Your Parallel Suite

Table of Contents
  • Why Do Shared Test Users Break Parallel Execution?
  • How Do You Confirm Shared Users Are Causing Flaky Tests?
  • What to Look for in the Trace
  • What Are the Best Patterns for Test User Isolation in Playwright?
  • Pattern 1: Worker-Indexed User Pool
  • Pattern 2: Dynamic User Creation via API
  • Pattern 3: Cached Auth State per Worker
  • Which Test User Isolation Pattern Should You Use?
  • How Do You Prevent Data Collisions Between Parallel Workers?
  • Your One Next Step

On this page

  • Why Do Shared Test Users Break Parallel Execution?
  • How Do You Confirm Shared Users Are Causing Flaky Tests?
  • What to Look for in the Trace
  • What Are the Best Patterns for Test User Isolation in Playwright?
  • Pattern 1: Worker-Indexed User Pool
  • Pattern 2: Dynamic User Creation via API
  • Pattern 3: Cached Auth State per Worker
  • Which Test User Isolation Pattern Should You Use?
  • How Do You Prevent Data Collisions Between Parallel Workers?
  • Your One Next Step

If every test in your Playwright suite logs in as testuser@company.com, you don’t have a test suite — you have a race condition waiting for enough parallelism to trigger it. I’ve seen this pattern at four different enterprises, and it always plays out the same way: the suite works fine with 1 worker, starts “flaking” at 4 workers, and becomes unusable at 8.

Why Do Shared Test Users Break Parallel Execution?

Shared test users break parallel execution because multiple workers authenticate as the same identity simultaneously, creating session conflicts, data contention, and unpredictable state mutations. This is the single most common root cause of non-deterministic failures in parallel Playwright suites.

Sequential execution masks the problem completely. When tests run one at a time, a single shared user works fine — each test finishes its session before the next one starts. The moment you add workers: 4 to your Playwright config, you’ve introduced concurrent access to a resource (the user account) with zero coordination.

The failures show up in three ways, and I’ve debugged all three at different companies:

Session invalidation. Most auth systems enforce single-session or rotate tokens on new login. Worker 1 logs in as testuser, Worker 3 logs in as testuser 200ms later, and Worker 1’s session is dead. This is the most common failure mode — I covered the full debugging story in the race condition that hid behind our retry config.

Data contention. Worker 1 adds an item to testuser’s cart. Worker 2 checks testuser’s cart count and gets an unexpected result. Neither test is wrong. They’re fighting over the same data.

State mutation. Worker 1 changes the user’s locale to French. Worker 2 expects English. Worker 2 fails with element text mismatches that make no sense until you realize another test changed the user’s settings mid-run.

[ WARNING ]

The telltale sign: your failure rate correlates with your worker count. If bumping workers from 2 to 4 doubles your failures, shared state is almost certainly the cause.

How Do You Confirm Shared Users Are Causing Flaky Tests?

The fastest way to confirm shared test users are causing flaky tests is to temporarily set workers: 1 in your Playwright config and rerun the suite. If failures drop to near-zero with a single worker and return when you restore parallelism, shared state is your problem.

Before you start refactoring fixtures, here’s the full diagnostic approach:

playwright.config.ts
export default defineConfig({
// Temporarily drop to 1 worker
workers: 1,
// Keep everything else the same
});

Run the full suite. If your failure rate drops to near-zero with 1 worker and climbs back when you restore parallel workers, you have a shared-state problem. This isn’t a fix — it’s a 5-minute confirmation that saves you from chasing the wrong problem.

For a more targeted diagnosis, check your Playwright traces. The Trace Viewer shows the exact network timeline. Look for 401 responses that happen mid-test (not on the initial login). That’s another worker’s login invalidating your session.

terminal
npx playwright test --trace on --workers 4
npx playwright show-trace test-results/failing-test/trace.zip

What to Look for in the Trace

Open the Network tab and scan for this pattern:

  1. POST /auth/login returns 200
  2. A few successful API calls
  3. Suddenly, a GET or POST returns 401 or 403 — with no logout in between
  4. The test gets redirected to the login page

That gap between step 2 and step 3 is where another worker logged in with the same credentials and killed your session. If you see this, you’ve found your culprit.

What Are the Best Patterns for Test User Isolation in Playwright?

There are three proven patterns for isolating test users in parallel Playwright runs: worker-indexed user pools, dynamic user creation via API, and cached auth state per worker. The right choice depends on your auth system, user provisioning speed, and how much control you have over the test environment.

I’ve implemented all three at different companies, and each has distinct trade-offs.

Pattern 1: Worker-Indexed User Pool

The simplest approach. Pre-create a pool of test users and assign one per worker using Playwright’s built-in workerInfo.workerIndex.

tests/auth.fixture.ts
import { test as base } from '@playwright/test';
export const test = base.extend<{}, { testUser: { email: string; password: string } }>({
testUser: [async ({}, use, workerInfo) => {
const email = `testuser-w${workerInfo.workerIndex}@corp.com`;
const password = 'TestPassword1!';
await use({ email, password });
}, { scope: 'worker' }],
});

Then in your tests:

tests/checkout.spec.ts
import { test } from './auth.fixture';
import { expect } from '@playwright/test';
test('can complete checkout', async ({ page, testUser }) => {
await loginAs(page, testUser.email, testUser.password);
// This user belongs exclusively to this worker
await page.goto('/checkout');
await expect(page.getByText('Your Cart')).toBeVisible();
});

When to use it: Your user provisioning is slow or requires manual steps (SSO, approval workflows, license seats). You know your max worker count ahead of time.

The catch: You need at least as many pre-created users as your max workers setting. If someone bumps workers to 8 but you only have 4 users, you’re back to sharing. I add a guard for this:

tests/auth.fixture.ts
testUser: [async ({}, use, workerInfo) => {
const maxUsers = 6;
if (workerInfo.workerIndex >= maxUsers) {
throw new Error(`Worker ${workerInfo.workerIndex} exceeds user pool size (${maxUsers}). Add more test users.`);
}
const email = `testuser-w${workerInfo.workerIndex}@corp.com`;
await use({ email, password: 'TestPassword1!' });
}, { scope: 'worker' }],

Pattern 2: Dynamic User Creation via API

If your system exposes a user creation API (or you can hit the database directly in a test environment), create a fresh user per worker. This is what I recommend for teams that have the infrastructure to support it.

tests/auth.fixture.ts
import { test as base } from '@playwright/test';
import { createTestUser, deleteTestUser } from './helpers/user-api';
export const test = base.extend<{}, { testUser: TestUser }>({
testUser: [async ({}, use, workerInfo) => {
const user = await createTestUser({
email: `pw-${workerInfo.workerIndex}-${Date.now()}@test.corp.com`,
password: 'TestPassword1!',
role: 'standard',
});
await use(user);
await deleteTestUser(user.id);
}, { scope: 'worker' }],
});

The Date.now() suffix prevents collisions if a previous run’s cleanup failed. The workerIndex prefix makes it easy to correlate failures back to specific workers in logs.

When to use it: You have API access to create/delete users, and your provisioning takes under 5 seconds. This is the gold standard for isolation.

The catch: If user creation is slow (enterprise SSO, multi-step verification), this adds significant startup time per worker. At one financial services company, user provisioning took 30 seconds — creating 8 users sequentially added 4 minutes to every test run. We solved that with Pattern 3.

Pattern 3: Cached Auth State per Worker

This is the pattern I’ve landed on most often. Combine dynamic user creation with Playwright’s storageState to authenticate once per worker and reuse the session across all tests on that worker.

tests/auth.fixture.ts
import { test as base } from '@playwright/test';
import path from 'path';
export const test = base.extend<{}, { authenticatedState: string }>({
authenticatedState: [async ({ browser }, use, workerInfo) => {
const stateFile = path.join(
__dirname, `.auth/worker-${workerInfo.workerIndex}.json`
);
// Authenticate once, save state to disk
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.fill('[data-testid="email"]', `testuser-w${workerInfo.workerIndex}@corp.com`);
await page.fill('[data-testid="password"]', 'TestPassword1!');
await page.click('[data-testid="login-button"]');
await page.waitForURL('/dashboard');
await context.storageState({ path: stateFile });
await context.close();
await use(stateFile);
}, { scope: 'worker' }],
});

Then every test on that worker starts pre-authenticated:

tests/dashboard.spec.ts
import { test } from './auth.fixture';
test('dashboard loads account summary', async ({ browser, authenticatedState }) => {
const context = await browser.newContext({ storageState: authenticatedState });
const page = await context.newPage();
await page.goto('/dashboard');
// Already logged in — no login step needed
});

When to use it: Login is slow (OAuth, MFA, SSO redirects) and you want to avoid authenticating in every single test. This is especially valuable when login involves a third-party redirect that adds 3-5 seconds per test.

The catch: If your auth tokens expire mid-run, you need a refresh mechanism. I typically add a token expiry check at the start of the fixture and re-authenticate if needed.

[ TIP ]

Whichever pattern you choose, the principle is the same: one user identity per parallel worker, never shared across workers. If you follow this rule, you’ve eliminated the most common source of non-deterministic failures in parallel suites. For the same principle applied to WebDriver instances in Java, see our breakdown of thread safety in parallel test execution.

Which Test User Isolation Pattern Should You Use?

For most teams, the best approach is combining Pattern 2 (dynamic user creation) with Pattern 3 (cached auth state). This gives you full isolation with minimal login overhead, and it’s the combination I default to on every new Playwright project.

Here’s the decision framework I use:

ConstraintRecommended pattern
No API access, manual user provisioningPattern 1 (worker-indexed pool)
API access, fast provisioning (<5s)Pattern 2 (dynamic creation)
Slow login flow (SSO/OAuth/MFA)Pattern 3 (cached auth state)
API access + slow loginPattern 2 + Pattern 3 combined

At the Canadian telecom where I first hit this problem, we went from a 12% retry rate to 0.3% overnight by switching from a single shared user to worker-scoped dynamic users. The remaining 0.3% turned out to be actual infrastructure flakes (DNS timeouts in the test environment), not test isolation issues.

How Do You Prevent Data Collisions Between Parallel Workers?

Even with per-worker test users, you can still get data contention if your tests write to a shared database. The solution is to scope test data to each worker using prefixes or transaction rollbacks, so workers never read or write each other’s data.

Here are two patterns I’ve used to handle this:

Prefix test data with worker index. If Worker 2 creates an order, the order name is test-w2-order-12345. Workers only query their own prefixed data.

tests/helpers/test-data.ts
export function testOrderName(workerIndex: number): string {
// Worker-scoped prefix prevents cross-worker data collisions
return `test-w${workerIndex}-order-${Date.now()}`;
}

Use transactions with rollback. If your test environment supports it, wrap each test’s database operations in a transaction and roll back after the test completes. This is the cleanest approach but requires database access from the test layer, which not every team has.

Your One Next Step

Audit your Playwright config right now. Open playwright.config.ts and check your workers setting. Then grep your codebase for hardcoded test credentials. If the same email appears in more than one test file and workers is greater than 1, you have a race condition — you just might not have triggered it yet. For a broader strategy on isolating your entire test environment — not just user accounts — see building a controlled test environment.

terminal — find shared test credentials
grep -r "testuser@\|test\.user@\|qa\.user@" tests/ --include="*.ts" -l

If that command returns more than one file, start with Pattern 1 today. It takes 30 minutes to implement and eliminates the most common cause of non-deterministic parallel test failures.

§ Frequently Asked FAQ
+ Can I use Playwright's built-in auth setup (projects) instead of custom fixtures?

Yes — Playwright’s authentication docs recommend a setup project that runs before tests. But the default example uses a single auth state file, which means all workers share the same session. You still need per-worker state files. The patterns above work within Playwright’s project-based auth setup; just generate a separate state file per worker instead of a single storageState.json.

+ What if our test environment only has one user account?

This is more common than you’d think, especially with licensed enterprise software. Your options: run with workers: 1 (sacrificing speed for reliability), request additional test accounts from the team that manages the environment, or build a test user provisioning API as a side project. I’ve had success framing the request as “we need N test accounts to run our suite in under 10 minutes instead of 40” — that usually gets attention.

+ Does this apply to API tests too, or just UI tests?

It applies to any parallel test that shares user identity. API tests that authenticate with the same token or credentials are just as vulnerable. The difference is that API tests often fail more subtly — you get unexpected data rather than a visible 401 redirect. If your API tests share a user and run in parallel, apply the same isolation patterns.

§ Further Reading 03 of 03
01Automation

Playwright's storageState() Is Contaminating Your Tests

storageState() saves cookies, localStorage, and sessionStorage in one blob. Here's how it silently poisons parallel tests and how to strip it to cookies only.

Read →
02Automation

Selenium's Alert Handling Crashed Our Parallel Suite

How UnhandledAlertException broke 8-thread parallel execution and why Playwright's event-driven dialog model avoids that entire failure pattern in practice.

Read →
03Automation

Migrating Off Cypress? Here's When to Keep It

An honest take on Cypress vs Playwright migrations from an SDET who's done three, including the signals that tell you not to migrate your suite just yet.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.