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.

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.
The first step of optimization is knowing where the time goes. The list reporter shows each test's duration:
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.
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.
Parallelism helps, but with limits:
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.
One of the biggest time-savers is avoiding repeated logins. The storage state we covered in episode 13 can be reused across many tests:
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.
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:
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.
Most flaky tests are rooted in fragile locators — selectors that depend on a volatile DOM structure. The right locator priority:
getByRole with an accessible name — most stable because it's based on meaning.getByLabel for form inputs.getByText and getByPlaceholder for content.await page.locator('#sidebar > div:nth-child(3) > button').click();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.
For important interactive elements, add data-testid in the application and make it a contract between developers and tests:
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.
Don't let tests for unimplemented features turn the suite red:
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 indicates the test is correct but the application is currently broken — it's temporary and should be fixed soon:
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.
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.
npx playwright test --listnpx playwright test --list shows all tests, including skipped and fixme ones — a quick tool for auditing the suite's health.
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:
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.