Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 020 / 100

Running tests in parallel: practical tips and trade-offs

Speed up suites safely.

Expona Admin8 min read

Running tests in parallel: practical tips and trade-offs

As your test suite grows, execution time becomes a real bottleneck. Running tests in parallel: practical tips and trade-offs explores how Playwright can execute your tests concurrently, cutting total time from minutes to seconds. But speed comes with complexity—data isolation, resource contention, and CI configuration all demand careful attention.

Why parallelization matters

Most teams start with sequential test execution. A 100-test suite might take 10 minutes to run end-to-end. When developers run tests before pushing code, that wait compounds across the team. Parallelization is one of the highest-impact optimizations available, but it requires understanding where Playwright parallelizes by default and where you need to configure it explicitly.

Files run in parallel by default

Playwright already parallelizes at the file level. If you have auth.spec.ts, checkout.spec.ts, and dashboard.spec.ts, Playwright spins up separate worker processes for each file and runs them concurrently. This is why many teams see immediate speedups just by upgrading Playwright.

The default worker count depends on your machine. On a 4-core laptop, Playwright uses 4 workers. In CI with 8 cores available, it often uses 4-8 workers. You can inspect the actual concurrency in your test output:

typescript
// playwright.config.ts
export default defineConfig({
  workers: 4, // explicitly set; defaults to CPU count / 2
  webServer: {
    command: 'npm run dev',
    port: 3000,
  },
  use: {
    baseURL: 'http://localhost:3000',
  },
});

This explicit configuration ensures consistent behavior across machines. A developer's 16-core workstation and your CI runner with 2 cores will both use 4 workers, avoiding surprises.

File-level parallelization works well when tests are truly independent. Problems emerge when tests share state—a database, a Redis cache, or shared API fixtures. That's where isolation becomes critical.

fullyParallel for tests within files

By default, tests within a single file run sequentially. This is intentional. Many test files share setup and teardown via test.beforeEach and test.afterEach, and running those in parallel would be dangerous without careful coordination.

However, if your tests truly don't depend on each other and don't share resources, you can enable fullyParallel:

typescript
// playwright.config.ts
export default defineConfig({
  fullyParallel: true,
  workers: 4,
});

Or per-file:

typescript
// product-listing.spec.ts
import { test, expect } from '@playwright/test';

test.describe.configure({ mode: 'parallel' });

test('displays product grid', async ({ page }) => {
  await page.goto('/products');
  const products = page.locator('[data-testid="product-card"]');
  await expect(products).toHaveCount(12);
});

test('filters by category', async ({ page }) => {
  await page.goto('/products?category=electronics');
  const products = page.locator('[data-testid="product-card"]');
  await expect(products.first()).toContainText('laptop');
});

test('sorts by price', async ({ page }) => {
  await page.goto('/products?sort=price');
  const firstPrice = await page.locator('[data-testid="price"]').first().textContent();
  expect(parseInt(firstPrice)).toBeLessThan(100);
});

Each test gets its own browser context, so there's no shared state in the browser itself. However, consider what happens in your application layer. If tests hit the same database without isolation, they'll interfere.

A real-world example: you have three tests that each create a user account with the email testuser@example.com. Run them sequentially and one succeeds, the others fail with "duplicate email." Run them in parallel and all three fail. This is a data isolation problem, not a Playwright configuration problem.

Isolate data per test

Data isolation is the hardest part of parallelization. You need strategies to ensure tests don't collide over shared resources.

Strategy 1: Unique identifiers per test

Generate unique data within each test. Playwright provides test.info().testId:

typescript
import { test, expect } from '@playwright/test';

test('user can create account', async ({ page }, testInfo) => {
  const uniqueEmail = `user-${testInfo.testId}-${Date.now()}@example.com`;
  
  await page.goto('/signup');
  await page.fill('[name="email"]', uniqueEmail);
  await page.fill('[name="password"]', 'SecurePass123');
  await page.click('button[type="submit"]');
  
  await expect(page).toHaveURL('/dashboard');
});

This guarantees no two tests use the same email, even when running in parallel. The downside: test data becomes less readable and harder to debug. A timestamp in test data is useful but feels clunky in assertions.

Strategy 2: API-based setup and teardown

Use Playwright's API request context to set up and clean up data via your backend:

typescript
import { test, expect, APIRequestContext } from '@playwright/test';

let apiContext: APIRequestContext;

test.beforeAll(async ({ playwright }) => {
  apiContext = await playwright.request.newContext({
    baseURL: 'http://localhost:3000/api',
  });
});

test.afterAll(async () => {
  await apiContext.dispose();
});

test.beforeEach(async ({ page }, testInfo) => {
  // Create test user via API
  const response = await apiContext.post('/users', {
    data: {
      email: `user-${testInfo.testId}@example.com`,
      name: 'Test User',
    },
  });
  const user = await response.json();
  
  // Store in page context for cleanup
  page.testUserId = user.id;
});

test.afterEach(async ({ page }) => {
  // Clean up via API
  if (page.testUserId) {
    await apiContext.delete(`/users/${page.testUserId}`);
  }
});

test('user can update profile', async ({ page }) => {
  await page.goto('/profile');
  await page.fill('[name="bio"]', 'Updated bio');
  await page.click('button:has-text("Save")');
  
  await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});

This approach is faster and cleaner. Your tests don't spend time clicking through signup forms; they directly create what they need and clean up immediately. It's also more resilient—API changes are easier to update than fixing 50 tests that click buttons.

Strategy 3: Dedicated databases per worker

For integration tests, some teams run a separate database instance per worker. This completely eliminates data contention:

typescript
// playwright.config.ts
export default defineConfig({
  workers: 4,
  use: {
    baseURL: ({ workerIndex }) => `http://localhost:3000?db=test_worker_${workerIndex}`,
  },
  webServer: [
    {
      command: 'npm run dev:worker-0',
      port: 3000,
      reuseExistingServer: false,
    },
    // ... repeat for worker 1, 2, 3
  ],
});

This is overkill for most projects but works if you're running heavy integration tests or acceptance tests that genuinely need isolated databases. The cost is higher setup complexity and more memory usage.

Strategy 4: Transaction rollback

If your backend supports it, wrap each test's database changes in a transaction and roll back after the test. This is fast and simple:

typescript
test.beforeEach(async ({ request }) => {
  // Signal to backend: start a transaction for this test
  await request.post('http://localhost:3000/api/test-transaction/start');
});

test.afterEach(async ({ request }) => {
  // Roll back
  await request.post('http://localhost:3000/api/test-transaction/rollback');
});

test('checkout flow', async ({ page }) => {
  // All database changes during this test are rolled back
  await page.goto('/cart');
  await page.click('button:has-text("Checkout")');
  // ...
});

This requires backend support but avoids the overhead of creating and destroying test users repeatedly.

Tune workers in CI

CI environments have different constraints than local machines. A GitHub Actions runner has 2 cores by default, but you're paying for every minute. You need to balance parallelization against cost and time.

Most CI systems report available cores. GitHub Actions with a standard runner has 2 cores. Docker runners vary. Always verify your actual environment:

typescript
// Print CPU count in CI
console.log('Available CPUs:', require('os').cpus().length);

Then tune your config or set it dynamically:

typescript
// playwright.config.ts
import os from 'os';

const cpuCount = os.cpus().length;
const isCI = !!process.env.CI;

export default defineConfig({
  workers: isCI ? Math.max(1, cpuCount - 1) : cpuCount,
  timeout: 30000,
  use: {
    baseURL: 'http://localhost:3000',
  },
});

On a 2-core CI runner, this uses 1 worker. On an 8-core runner, it uses 7. You avoid overwhelming the system while still parallelizing when possible.

A practical tip: in CI, set a longer timeout for tests. Network latency is higher, and shared CI resources can slow things down. Local tests might timeout at 10 seconds, but CI needs 30:

typescript
export default defineConfig({
  timeout: process.env.CI ? 30000 : 10000,
});

Also consider CI-specific resource constraints. If your webServer takes time to start, increase the timeout there too:

typescript
webServer: {
  command: 'npm run start',
  port: 3000,
  timeout: process.env.CI ? 120000 : 60000,
},

Common mistakes

  • Assuming tests are independent when they're not: Tests that create or modify the same resource (same user ID, same product SKU) will fail in parallel. Always generate unique test data or use API setup/teardown.
  • Using hardcoded test data across tests: A test that signs in as admin@test.com works fine sequentially. In parallel, two tests fight over the same account. Use unique identifiers or skip signing in via UI and use API authentication tokens instead.
  • Ignoring timeouts in CI: Tests that pass locally but flake in CI often have timeout issues exacerbated by parallelization and shared CI resources. Always increase timeouts for CI environments.
  • Setting workers too high: More workers doesn't always mean faster execution. With too many workers, context switching and memory pressure actually slow things down. Benchmark your setup; often 4-8 workers is optimal even on high-core machines.
  • Not cleaning up after tests: Tests that don't clean up test data cause subsequent test runs to fail. Use test.afterEach or test.afterAll consistently, even if you think data doesn't matter.

Key takeaways

  • Playwright parallelizes files by default. You get speedup with zero configuration in most cases.
  • Enable fullyParallel only for tests that truly don't depend on each other and don't share application state.
  • Data isolation is the hard part. Use unique identifiers, API-based setup/teardown, or transaction rollback to prevent tests from interfering.
  • In CI, tune worker count based on actual CPU availability. Use dynamic configuration to adapt to different runners.
  • Increase timeouts in CI. Parallelization and shared resources both increase variance in test execution time.
  • Test your parallelization strategy locally before deploying to CI. Run the suite 5-10 times to catch intermittent failures caused by data contention.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles