← Back to Blog

Practical Tips and Best Practices for Writing Reliable Playwright Tests

Practical Tips and Best Practices for Writing Reliable Playwright Tests

Playwright makes it easy to get an end-to-end test running. The harder part is keeping a growing test suite fast, deterministic, readable, and useful when something fails.

Reliable Playwright tests wait for observable conditions instead of arbitrary amounts of time. They describe user-visible behavior, separate setup from verification, and provide enough context for a developer—or a coding agent—to understand a failure. Playwright’s locators, web-first assertions, storage state, network routing, and API fixtures support these patterns directly.

Here are several practical techniques that can improve the quality of a Playwright test suite.

Give complex locators a human-readable description

A locator can be technically correct while still being difficult to understand in a trace or test report.

Playwright’s locator.describe() method attaches a human-readable description to an existing locator. The description appears in reports and the Trace Viewer, making it easier to understand what the test was trying to interact with. It does not change how the element is located, and it does not define an assertion.

TypeScript

const testSection = page
  .getByRole("article")
  .filter({
    has: page.getByRole("heading", {
      name: "Test Section",
    }),
  })
  .describe("Test Section article");

await expect(testSection).toBeVisible();

Without the description, a failure may primarily show the locator implementation.

The description should add context, not compensate for a fragile locator. Start with resilient, user-facing selectors such as roles, labels, text, or explicit test IDs. Then use describe() to communicate the locator’s purpose.

You can also add a custom message to an assertion when the business expectation needs explanation:

TypeScript

await expect(
  testSection,
  "the Test Section article should be available to readers",
).toBeVisible();

Custom assertion messages appear in Playwright’s reports for both passing and failing assertions. They complement locator.describe(): the locator description explains what the element is, while the assertion message explains why the expectation matters.

Snapshot the contract, not the implementation

Snapshot testing can reduce the amount of assertion code needed for a complex interface, but snapshots become noisy when they capture implementation details that are not part of the feature’s contract.

For a single value or outcome, a targeted assertion is usually clearest:

TypeScript

await expect(
  page.getByRole("heading", { name: "Account settings" }),
).toBeVisible();

For a section containing several related controls or pieces of content, an ARIA snapshot can be useful:

TypeScript

const settingsForm = page.getByRole("form").filter({
  has: page.getByRole("heading", {
    name: "Account settings",
  }),
});

await expect(settingsForm).toMatchAriaSnapshot(`
  - heading "Account settings" [level=2]
  - textbox "Display name"
  - textbox "Email address"
  - button "Save changes"
`);

ARIA snapshots compare the accessible representation of the page rather than its HTML markup. CSS classes and non-semantic wrapper elements generally do not appear in that representation, so the test can survive many styling and layout refactors while still detecting changes to headings, controls, labels, and other user-facing structure.

ARIA snapshots support partial matching. By default, the specified children must appear in the correct order, but the snapshot does not need to include every accessible child. Names, attributes, or states can also be omitted when they are not part of the contract. Regular expressions can handle dynamic content.

For example:

TypeScript

await expect(page.getByRole("main")).toMatchAriaSnapshot(`
  - heading /Search results for .+/ [level=1]
  - button "Sort results"
  - list
`);

When visual appearance itself is the contract, use a screenshot assertion instead:

TypeScript

await expect(page.getByTestId("pricing-card")).toHaveScreenshot(
  "pricing-card.png",
);

Use targeted assertions for specific behavior, ARIA snapshots for accessible content and structure, and screenshot assertions for visual appearance. Generic toMatchSnapshot() is also available for stable strings and buffers, but it should not replace a more descriptive web-first assertion when one exists.

Snapshots should always be reviewed before they are accepted. Playwright can update them with:

Bash

npx playwright test --update-snapshots

That command should mean “I reviewed this change and the new result is correct,” not simply “make the test pass.”

Reuse authentication state instead of logging in repeatedly

Logging in through the UI before every test slows down the suite and adds another possible source of failure to tests that are not actually testing authentication.

When generating tests, Codegen can save the browser’s authenticated storage state:

Bash

npx playwright codegen http://localhost:3001 \
  --save-storage=playwright/.auth/user.json

After the browser opens, complete the login flow and close the browser. Playwright will save cookies, localStorage, and IndexedDB state to the specified file.

You can then start another Codegen session with that state loaded:

Bash

npx playwright codegen \
  --load-storage=playwright/.auth/user.json \
  http://localhost:3001

This allows Codegen to start in an authenticated state without recording the login flow again. The --load-storage option is a Codegen option; it is not how authentication state is loaded by npx playwright test.

For the test runner, configure storageState instead:

TypeScript

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

const authFile = "playwright/.auth/user.json";

export default defineConfig({
  use: {
    baseURL: "http://localhost:3001",
  },

  projects: [
    {
      name: "setup",
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: authFile,
      },
      dependencies: ["setup"],
    },
  ],
});

The setup project can perform the login once and save the resulting state:

TypeScript

// tests/auth.setup.ts
import { expect, test as setup } from "@playwright/test";

const authFile = "playwright/.auth/user.json";

setup("authenticate", async ({ page }) => {
  const email = process.env.E2E_EMAIL;
  const password = process.env.E2E_PASSWORD;

  if (!email || !password) {
    throw new Error("E2E_EMAIL and E2E_PASSWORD must be configured");
  }

  await page.goto("/login");

  await page.getByLabel("Email").fill(email);
  await page.getByLabel("Password").fill(password);
  await page.getByRole("button", { name: "Sign in" }).click();

  // Wait for an authenticated state, not merely the click.
  await expect(
    page.getByRole("button", { name: "Account menu" }),
  ).toBeVisible();

  await page.context().storageState({
    path: authFile,
  });
});

Playwright recommends setup-project authentication for tests that can safely share an account. Tests that mutate server-side account data may need a separate account per parallel worker to avoid interfering with one another.

Authentication files must be treated as secrets. They can contain cookies or headers that allow someone to impersonate the test user, so the authentication directory should be added to .gitignore:

gitignore

playwright/.auth/

Also note that Playwright storage state does not automatically persist sessionStorage. Applications that store authentication exclusively in sessionStorage require a separate save-and-restore strategy.

Record and replay API traffic with HAR files

HAR stands for HTTP Archive. A HAR file records information about network requests and responses, including URLs, methods, headers, cookies, response bodies, and timing information.

Playwright can use a HAR file as a network fixture. During replay, matching browser requests are intercepted and fulfilled from the recorded responses instead of reaching the real API. This can make tests faster and more deterministic, particularly when working with third-party services, unstable development APIs, or complex test data.

You can record a user flow, authenticated storage, and selected API traffic in the same Codegen session:

Bash

npx playwright codegen http://localhost:3001 \
  --load-storage=playwright/.auth/user.json \
  --save-har=playwright/.recordings/test-api.har \
  --save-har-glob="https://api.test.com/**"

The glob is quoted so that the shell does not attempt to expand it before Playwright receives it. Both --save-har and --save-har-glob are supported by the Playwright browser and Codegen CLI options.

A test can then replay the recording:

TypeScript

import { test, expect } from "@playwright/test";

const updateHar = process.env.UPDATE_HAR === "true";

test.use({
  serviceWorkers: "block",
  storageState: "playwright/.auth/user.json",
});

test("searches recorded quotes", async ({ page }) => {
  await page.routeFromHAR("playwright/.recordings/test-api.har", {
    url: "https://api.test.com/**",
    update: updateHar,

    // Fail when the app makes an unexpected API request.
    notFound: "abort",
  });

  await page.goto("/quotes");

  await page.getByRole("textbox", { name: "Search quotes" }).fill("test");

  const result = page.getByText("test", {
    exact: true,
  });

  await expect(result).toBeVisible();
  await result.click();
});

When update is false—or omitted—matching requests are served from the HAR. When update is true, Playwright sends the requests to the real network and updates the HAR with the responses. The updated file is written when the browser context closes.

Use an explicit string comparison for the environment variable:

TypeScript

const updateHar = process.env.UPDATE_HAR === "true";

Avoid this:

TypeScript

const updateHar = !!process.env.UPDATE_HAR;

Environment variables are strings. As a result, both "true" and "false" are truthy:

TypeScript

Boolean("true"); // true
Boolean("false"); // also true

The HAR can be deliberately refreshed with:

Bash

UPDATE_HAR=true \
  npx playwright test tests/quotes.spec.ts --workers=1

Using a single worker while updating prevents multiple tests from writing to the same HAR file concurrently. Normal local and CI test runs should leave UPDATE_HAR unset so that tests replay the committed recording.

There are a few important HAR details to understand:

HAR matching uses the URL and HTTP method. POST requests also match against the request payload. If no matching entry exists, Playwright aborts the request by default unless notFound: 'fallback' is used. Requests handled by a service worker cannot be fulfilled through routeFromHAR(), which is why serviceWorkers: 'block' is often necessary. Relative HAR paths are resolved from the current working directory, so a path such as playwright/.recordings/test-api.har is usually appropriate; /playwright/... would refer to an absolute path from the root of the file system.

HAR files can contain authorization headers, cookies, user data, and response bodies. Inspect and sanitize them before committing them to source control.

Avoid waitForTimeout()

One of the most common sources of flaky tests is waiting for an arbitrary amount of time:

TypeScript

await page.click("button.add-todo");
await page.waitForTimeout(3000);
await expect(page.getByText("Added new todo")).toBeVisible();

This test has two problems.

When the application responds in 200 milliseconds, the test still waits three seconds. When the application responds in 3.1 seconds, the test fails even though it is working correctly.

Playwright explicitly discourages using page.waitForTimeout() in production tests. Locator actions wait for elements to become actionable, and web-first assertions retry until the expected state appears or the configured timeout expires.

The same test can usually be written as:

TypeScript

await page.getByRole("button", { name: "Add Todo" }).click();

await expect(page.getByText("Added new todo")).toBeVisible();

The toBeVisible() assertion waits only as long as necessary.

It is also important to distinguish web-first locator assertions from one-time generic assertions:

TypeScript

const message = page.getByText("Added new todo");

// Retries until the locator becomes visible.
await expect(message).toBeVisible();

// Performs a one-time visibility check and then
// applies a non-retrying generic assertion.
expect(await message.isVisible()).toBe(true);

Prefer the first form when the page changes asynchronously. Playwright’s generic matchers do not automatically retry, while its asynchronous page and locator matchers do.

Wait for network requests without creating a race

Sometimes the network request itself is part of what you need to verify. Playwright provides page.waitForRequest() and page.waitForResponse() for these cases.

The important pattern is to begin waiting before performing the action that triggers the request:

TypeScript

const responsePromise = page.waitForResponse((response) => {
  const url = new URL(response.url());

  return (
    url.pathname === "/api/todos" && response.request().method() === "POST"
  );
});

await page.getByRole("button", { name: "Add Todo" }).click();

const response = await responsePromise;

expect(response.status()).toBe(201);

await expect(page.getByText("Added new todo")).toBeVisible();

Do not write this:

TypeScript

await page.getByRole("button", { name: "Add Todo" }).click();

const response = await page.waitForResponse("/api/todos");

The request could begin and finish before waitForResponse() is registered.

The same pattern applies to waitForRequest():

TypeScript

const requestPromise = page.waitForRequest(
  (request) =>
    request.url().endsWith("/api/todos") && request.method() === "POST",
);

await page.getByRole("button", { name: "Add Todo" }).click();

const request = await requestPromise;

expect(request.postDataJSON()).toMatchObject({
  title: "New todo",
});

Playwright’s documentation similarly starts the event-waiting promise before the triggering action.

Do not wait for a network response merely because the UI is asynchronous. When a user-visible assertion fully describes success, the assertion is often enough. Use network waits when the request, response code, payload, or timing is itself part of the behavior being tested.

Increase the smallest timeout that solves the problem

A slow assertion in CI does not necessarily mean the entire test suite needs a larger global timeout.

Playwright has separate timeout categories. Auto-retrying assertions have their own expect timeout, which is separate from the total test timeout. The default expect timeout is five seconds unless it has been changed in the configuration.

For a single slow assertion, set the timeout directly:

TypeScript

await expect(page.getByTestId("submission-status")).toHaveText("Submitted", {
  timeout: 10_000,
});

When several assertions in one flow need the same behavior, create a configured expect instance:

TypeScript

const eventually = expect.configure({
  timeout: 10_000,
});

await eventually(page.getByTestId("submission-status")).toHaveText("Submitted");

await eventually(
  page.getByRole("button", {
    name: "View submission",
  }),
).toBeEnabled();

expect.configure() creates an expect instance with local defaults such as timeout or soft. It does not modify the global expect configuration.

This keeps the exception close to the reason for it. A reviewer can see exactly which operation is expected to take longer instead of discovering that every assertion in the application now waits 30 seconds.

Before raising any timeout, determine what signal is actually slow. A timeout increase should accommodate legitimate application behavior, not hide an incorrect locator, missing event, race condition, or broken backend.

Set up state through the API and verify it through the UI

Many end-to-end tests need the application to be in a specific state before the behavior under test can begin.

Suppose you want to verify a post-detail page. Creating the post through the UI may require navigating through several screens, filling a form, uploading data, and waiting for redirects. Those actions make the test slower and obscure its real purpose.

Instead, create the record through the API and verify it through the browser:

TypeScript

import { randomUUID } from "node:crypto";
import { test, expect } from "@playwright/test";

test("renders a newly created post", async ({ page, request }) => {
  const title = `Playwright post ${randomUUID()}`;

  const createResponse = await request.post("/api/posts", {
    data: {
      title,
      author: "e2e-user",
    },
  });

  await expect(createResponse).toBeOK();

  const post = (await createResponse.json()) as {
    id: string;
    slug: string;
  };

  try {
    await page.goto(`/posts/${post.slug}`);

    await expect(
      page.getByRole("heading", {
        name: title,
      }),
    ).toBeVisible();
  } finally {
    await request.delete(`/api/posts/${post.id}`);
  }
});

This test uses the API for arrangement and cleanup, but it still verifies the feature through the user interface.

Playwright’s built-in request fixture is an APIRequestContext. It respects test configuration such as baseURL, extraHTTPHeaders, and proxy settings, which avoids repeating API configuration throughout the suite. It is useful for creating test records, authenticating users, seeding state, and checking server-side postconditions.

Do not assume that the standalone request fixture automatically shares cookies created by browser interactions during the current test. When an API call must use the browser context’s current cookie jar, use page.request or page.context().request:

TypeScript

await page.goto("/login");
// Complete UI authentication...

const response = await page.request.post("/api/posts", {
  data: {
    title: "Authenticated post",
  },
});

page.request and browserContext.request share cookies with the corresponding browser context. A standalone context created through request.newContext() has isolated cookie storage.

API setup should remain narrowly focused on arranging the test. Do not use it to bypass the behavior you actually intend to verify. A login-page test should still log in through the page. A post-detail test usually does not need to test the post-creation form first.

Use unique test data and clean it up afterward so parallel tests remain isolated.