Learn Playwright - Speed & Reliability Optimization
Episode 15 of 23

Learn Playwright - Speed & Reliability Optimization

This episode covers optimizing test runtime and parallelism, reusing contexts and fixtures with selective retries, reducing flaky tests with stable locators, and using test.fixme and test.skip strategically.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

A slow test suite makes the team avoid it; a flaky test suite makes the team distrust it. This episode 15 addresses both problems at once: how to make the suite faster and how to make it more reliable — two qualities that must go together.

Speed is achieved with the right parallelism, resource reuse, and avoiding repetitive work. Reliability is achieved with stable locators and an honest skip strategy. You'll learn to measure and optimize both systematically.

Optimizing Test Runtime and Parallelism

Measuring Time per Test

The first step of optimization is knowing where the time goes. The list reporter shows each test's duration:

See duration per test
npx playwright test --reporter=list

--reporter=list displays each test's duration. Identify the slowest tests — they're the prime optimization candidates. Often, a handful of tests account for most of the total runtime.

Finding Hotspots with Traces

For slow tests, open the trace and look at the Network and Timing sections. Common culprits are long waitForTimeout calls, unnecessary navigation, or slow requests. Replace sleeps with waiting for conditions, as we covered in episode 5.

Setting Parallelism Wisely

Parallelism helps, but with limits:

JSLimit workers based on resources
export default defineConfig({
  workers: process.env.CI ? 4 : undefined,
});

workers: process.env.CI ? 4 : undefined uses the default locally and limits to 4 workers in CI. Too many workers saturate the CPU and actually slow things down; find the optimal number by measuring.

Reusing Contexts, Fixtures, and Selective Retries

Storage State to Cut Login Time

One of the biggest time-savers is avoiding repeated logins. The storage state we covered in episode 13 can be reused across many tests:

JSSet up the login fixture once
import { test as base } from '@playwright/test';
 
export const test = base.extend({
  page: async ({ page }, use) => {
    await page.goto('/home');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await use(page);
  },
});

The custom page fixture runs the setup (here, login) for every test that asks for it. Set up once, used by many tests — significantly reducing total runtime.

Selective Retries, Not Uniform Retries

Retrying every test wastes time on tests that are genuinely stable. Instead, give retries only to tests known to be flaky due to external factors:

JSSelective retries
test('payment with an external provider', async ({ page }) => {
  test.info().annotations.push({
    type: 'issue',
    description: 'Often times out because of a third-party provider',
  });
  test.setTimeout(60000);
});

test.setTimeout(60000) gives a specific test a longer execution window without affecting other tests. Combine it with project-level retries to balance speed and reliability.

Reducing Flaky Tests with Stable Locators

Stable Locator Principles

Most flaky tests are rooted in fragile locators — selectors that depend on a volatile DOM structure. The right locator priority:

  1. getByRole with an accessible name — most stable because it's based on meaning.
  2. getByLabel for form inputs.
  3. getByText and getByPlaceholder for content.
  4. CSS or XPath as a last resort.
JSAvoid fragile CSS
await page.locator('#sidebar > div:nth-child(3) > button').click();
JSMore stable with role
await page.getByRole('button', { name: 'Save' }).click();

A locator based on getByRole('button', { name: 'Save' }) doesn't break when the markup changes, as long as its accessible name is preserved.

Test IDs as a Contract

For important interactive elements, add data-testid in the application and make it a contract between developers and tests:

JSLocator with data-testid
await page.getByTestId('submit-order').click();

getByTestId('submit-order') looks for the data-testid attribute by default. Test IDs are intentionally stable — visual changes don't affect them, only a testid rename does.

Using test.fixme and test.skip Strategically

test.skip for Features That Don't Exist Yet

Don't let tests for unimplemented features turn the suite red:

JSSkip a test for a feature not yet available
test.skip('export data not yet available', async ({ page }) => {
  await page.goto('/reports');
  await page.getByRole('button', { name: 'Export' }).click();
});

test.skip(...) marks a test to be skipped, with a clear note in the report. This keeps the suite green while documenting that a contract isn't met yet.

test.fixme for Known Issues

test.fixme indicates the test is correct but the application is currently broken — it's temporary and should be fixed soon:

JSMark a problematic test
test.fixme('logout after token expires', async ({ page }) => {
  await page.goto('/profile');
  await expect(page).toHaveURL(/login/);
});

test.fixme(...) marks a test as a known issue — skipped but clearly listed. The difference from skip: fixme is an alarm that must be resolved, not a permanent condition.

Avoiding Accumulating Skips

Skip and fixme are discipline tools, not shortcuts. Review them regularly: each sprint, audit how many tests are skipped and why. Tests that stay skipped without a clear reason are a sign that coverage is silently leaking.

See the test list and statuses
npx playwright test --list

npx playwright test --list shows all tests, including skipped and fixme ones — a quick tool for auditing the suite's health.

Closing

Episode 15 taught balanced optimization: measuring durations to find hotspots, using storage state and fixtures to cut repetitive setup, selective retries for tests that genuinely need them, stable locators to stamp out flakiness, and honest skip and fixme usage to keep the suite meaningful.

Key takeaways:

  • Measure per-test duration with the list reporter to find hotspots.
  • Storage state and fixtures significantly cut repetitive setup.
  • Prioritize getByRole and getByLabel for stable locators.
  • getByTestId creates a contract that visual changes don't break.
  • test.skip and test.fixme must be reviewed regularly.

In the next episode we'll discuss visual regression testing — capturing baseline screenshots, comparing visual diffs with toHaveScreenshot, tolerating layout drift with thresholding, and integration with visual regression tools.

Learn Playwright - Speed & Reliability Optimization | Learn Playwright