How to buildOctober 11, 202613 min read
DevOps, Testing & SecurityStep 10 of 12

E2E tests with Playwright

In short

An e2e (end-to-end) test launches a real browser, opens the running application and walks through a scenario the way a person would: fills in a form, clicks a button and checks the result on screen. In Playwright Test such a test relies on role- and label-based locators, auto-waiting before actions and web-first assertions that retry until the condition holds. Failures are investigated with a trace — a recording of every step with the DOM, network and console.

TutorialLevel: IntermediateE2E TestingPractice: ~1.5–3 h

After this you will be able to

  • install Playwright Test, configure playwright.config.ts with webServer and run tests in Chromium, Firefox, WebKit and an installed Edge
  • write an e2e test for a lead form with getByRole and getByLabel and web-first assertions, and explain why expect(await locator.textContent()) makes a flaky test
  • move test data setup into a fixture and sign in once via storageState
  • find the cause of a failure in a trace and run the suite in GitHub Actions with the report uploaded as an artifact

Verified with

Playwright Test 1.64Chromium 156, Firefox 157, WebKit 27.2 (сборки Playwright 1.64)Microsoft Edge 155 (channel msedge)Node.js 24 LTS (24.18)TypeScript 7.0

Checked against docs: October 11, 2026

Contents
  1. 01What an e2e test checks and what we will build
  2. 02Installation and the app under test
  3. 03Locators: roles and labels instead of CSS classes
  4. 04Auto-waiting and web-first assertions
  5. 05Fixtures and sign-in via storageState
  6. 06Report and trace: investigating a failure
  7. 07Running in CI on GitHub Actions
  8. 08Common mistakes and exercises

What an e2e test checks and what we will build

A unit test checks a function, a component test checks a component in a simulated DOM. An e2e test starts the whole application and drives a real browser: server, HTML, JavaScript, cookies, network and layout work together. That is why it catches what lower levels miss: the form does not submit because of a typo in the API URL, a banner covers the button, the cookie is not set after sign-in. The price is speed and fragility: every test opens a browser and waits for the server.

Unit

What runs
One function in Node.js
What it catches
Calculation and validation errors

Component

What runs
A component in jsdom
What it catches
UI behaviour without a server

E2E

What runs
The app + a real browser
What it catches
Breakage at the seams: network, cookies, routes, layout

Hence the testing pyramid rule: e2e tests cover key scenarios — a lead request, sign-in, payment — while every branch of logic is checked more cheaply at lower levels. Playwright Test is a test runner with assertions (expect), test isolation, parallel runs and debugging tools built in. According to the docs it works with Chromium, Firefox and WebKit on Windows, Linux and macOS, locally and in CI, headed or headless.

webServer starts the appBrowser opens the pageLocators and actionsWeb-first assertionsReport and trace
What happens when you run `npx playwright test`

Installation and the app under test

A new project is easiest to start with npm init playwright@latest: it asks for the language (TypeScript by default), the tests folder, offers a GitHub Actions workflow and downloads browsers. In an existing project you install Playwright manually:

bash
npm init -y
npm install -D @playwright/test
npx playwright install
npx playwright --version
Installing into an existing project. npx playwright install downloads Chromium, Firefox and WebKit into a shared per-user cache

On our machine the last command printed Version 1.64.0. Requirements from the docs: Node.js 22, 24 or 26, Windows 11+, macOS 14+, Debian 12/13 or Ubuntu 22.04/24.04/26.04. Playwright browsers are special builds tied to the package version: after upgrading @playwright/test, run npx playwright install again.

We test an app without frameworks so the whole mechanism stays visible. A node:http server serves the form, accepts requests and handles cookie-based sign-in. The response to a request is delayed by 500 ms on purpose — real backends behave like that, and naive tests break exactly on this delay.

server.mjs
// Demo app for e2e tests, not production code: sessions and leads live in memory.
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";

const PORT = Number(process.env.PORT ?? 4173);
const DEMO_USER = { email: "manager@example.com", password: process.env.DEMO_PASSWORD ?? "demo-password" };
const leads = new Map();
const sessions = new Map();

const send = (res, status, body, headers = {}) => {
  res.writeHead(status, headers);
  res.end(body);
};
const readBody = async (req) => {
  let data = "";
  for await (const chunk of req) data += chunk;
  return data;
};
const escapeHtml = (s) =>
  s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const sessionOf = (req) => sessions.get(/(?:^|; )sid=([^;]+)/.exec(req.headers.cookie ?? "")?.[1]);

createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);

  if (req.method === "GET" && ["/", "/login", "/app.js"].includes(url.pathname)) {
    const file = { "/": "index.html", "/login": "login.html", "/app.js": "app.js" }[url.pathname];
    const type = file.endsWith(".js") ? "text/javascript" : "text/html; charset=utf-8";
    return send(res, 200, await readFile(new URL(`./public/${file}`, import.meta.url)), { "content-type": type });
  }

  if (req.method === "POST" && url.pathname === "/api/leads") {
    const { name = "", phone = "" } = JSON.parse((await readBody(req)) || "{}");
    if (!/^\+7\d{10}$/.test(phone)) return send(res, 400, JSON.stringify({ error: "invalid_phone" }));
    const lead = { id: randomUUID(), name: String(name).slice(0, 100), phone };
    leads.set(lead.id, lead);
    await new Promise((r) => setTimeout(r, 500)); // simulate a slow backend
    return send(res, 201, JSON.stringify(lead), { "content-type": "application/json" });
  }

  if (req.method === "DELETE" && url.pathname.startsWith("/api/leads/")) {
    leads.delete(url.pathname.slice("/api/leads/".length));
    return send(res, 204);
  }

  if (req.method === "POST" && url.pathname === "/login") {
    const form = new URLSearchParams(await readBody(req));
    if (form.get("email") !== DEMO_USER.email || form.get("password") !== DEMO_USER.password) {
      return send(res, 303, "", { location: "/login?error=1" });
    }
    const sid = randomUUID();
    sessions.set(sid, DEMO_USER.email);
    return send(res, 303, "", { location: "/account", "set-cookie": `sid=${sid}; HttpOnly; SameSite=Lax; Path=/` });
  }

  if (req.method === "GET" && url.pathname === "/account") {
    const email = sessionOf(req);
    if (!email) return send(res, 303, "", { location: "/login" });
    const items = [...leads.values()].map((l) => `<li>${escapeHtml(l.name)} — ${l.phone}</li>`).join("");
    return send(
      res,
      200,
      `<!doctype html><html lang="en"><title>Requests</title><main><h1>Requests</h1><p>Signed in as ${escapeHtml(email)}</p><ul aria-label="Requests">${items}</ul></main></html>`,
      { "content-type": "text/html; charset=utf-8" },
    );
  }

  send(res, 404, "Not found");
}).listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`));
Demo server: requests and sessions live in memory, the password is compared as plain text. That is acceptable only in a demo — real projects hash passwords and keep sessions in a database or Redis
public/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Request a call</title>
  </head>
  <body>
    <main>
      <h1>Request a call</h1>
      <form id="lead-form" novalidate>
        <label>Name <input name="name" autocomplete="name" /></label>
        <label>Phone <input name="phone" type="tel" placeholder="+77011234567" /></label>
        <button type="submit">Send request</button>
      </form>
      <p role="alert" id="error"></p>
      <p role="status" id="result"></p>
    </main>
    <script type="module" src="/app.js"></script>
  </body>
</html>
The form: <label> elements give fields their names, role="alert" and role="status" are message regions
public/app.js
const form = document.querySelector("#lead-form");
const button = form.querySelector("button");
const error = document.querySelector("#error");
const result = document.querySelector("#result");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  error.textContent = "";
  result.textContent = "";
  const data = Object.fromEntries(new FormData(form));
  if (!/^\+7\d{10}$/.test(data.phone)) {
    error.textContent = "Enter the phone as +7 and 10 digits";
    return;
  }
  button.disabled = true;
  button.textContent = "Sending…";
  try {
    const res = await fetch("/api/leads", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    form.reset();
    result.textContent = "Thanks! We will call you back.";
  } catch {
    error.textContent = "Something went wrong. Please try again.";
  } finally {
    button.disabled = false;
    button.textContent = "Send request";
  }
});
Client logic: phone validation, disabling the button during the request, success and error messages
public/login.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Sign in</title>
  </head>
  <body>
    <main>
      <h1>Sign in</h1>
      <form method="post" action="/login">
        <label>Email <input name="email" type="email" autocomplete="username" /></label>
        <label>Password <input name="password" type="password" autocomplete="current-password" /></label>
        <button type="submit">Sign in</button>
      </form>
    </main>
  </body>
</html>
The sign-in page is a plain HTML form; the server responds with a redirect and sets the sid cookie

Now the configuration. Runner options live at the top level, browser and page options in use, browser sets in projects.

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

export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  globalTimeout: 10 * 60 * 1000,
  reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
  use: {
    baseURL: "http://localhost:4173",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
  projects: [
    { name: "setup", testMatch: /.*\.setup\.ts/ },
    { name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"] },
    { name: "firefox", use: { ...devices["Desktop Firefox"] }, dependencies: ["setup"] },
    { name: "webkit", use: { ...devices["Desktop Safari"] }, dependencies: ["setup"] },
    {
      name: "edge",
      use: { ...devices["Desktop Edge"], channel: "msedge" },
      dependencies: ["setup"],
      default: false,
    },
  ],
  webServer: {
    command: "node server.mjs",
    url: "http://localhost:4173",
    reuseExistingServer: !process.env.CI,
  },
});
Config: three browsers by default, Edge on demand, the server starts automatically
  • webServer runs node server.mjs and waits until url responds. baseURL lets tests call page.goto("/login").
  • fullyParallel and workers — tests run in parallel in several worker processes; for CI the docs recommend a single worker for stability.
  • retries — in CI a failed test is retried up to twice; trace: "on-first-retry" records a trace on the first retry.
  • screenshot: "only-on-failure" attaches a page screenshot to a failed test.
  • The setup project signs in before the other tests (details below), dependencies defines the order.
  • default: false (new in 1.64) keeps a project in the config but does not run it without an explicit --project=edge. The edge project uses the installed Microsoft Edge through channel: "msedge" — Playwright does not download branded Chrome and Edge on its own.

Locators: roles and labels instead of CSS classes

A locator is a way to find an element at any moment, not a reference to a specific DOM node. Playwright resolves the element again before every action, so re-rendering between actions does not affect the test. The docs recommend prioritising locators based on what users and assistive technology perceive.

getByRole

When to use
Buttons, links, headings, checkboxes, lists — almost everything interactive
Example
getByRole("button", { name: "Send request" })

getByLabel

When to use
Form fields with a label
Example
getByLabel("Phone")

getByText

When to use
Non-interactive text: paragraphs, badges
Example
getByText("Signed in as …")

getByPlaceholder

When to use
A field without a label but with a placeholder
Example
getByPlaceholder("+77011234567")

getByTestId

When to use
When an element has no stable role or text
Example
getByTestId("price")

Many tags have an implicit role: <button> is button, <h1> is heading, <ul> is list, <p role="status"> is status. The name is the element's accessible name: the button text or the field label. By default the name is matched case-insensitively and as a substring: { name: "send" } also finds “Send request”. Use exact: true for an exact match. The lead form test:

tests/lead-form.spec.ts
import { expect, test } from "@playwright/test";

test.beforeEach(async ({ page }) => {
  await page.goto("/");
});

test("sends a request and shows a confirmation", async ({ page }) => {
  await page.getByLabel("Name").fill("Aigerim");
  await page.getByLabel("Phone").fill("+77011234567");
  await page.getByRole("button", { name: "Send request" }).click();

  // While the server responds (~0.5 s), the button is disabled and renamed.
  await expect(page.getByRole("button", { name: "Sending…" })).toBeDisabled();
  await expect(page.getByRole("status")).toHaveText("Thanks! We will call you back.");
  await expect(page.getByLabel("Phone")).toBeEmpty();
});

test("rejects an invalid phone without calling the API", async ({ page }) => {
  const apiCalls: string[] = [];
  page.on("request", (request) => {
    if (request.url().endsWith("/api/leads")) apiCalls.push(request.url());
  });

  await page.getByLabel("Phone").fill("8701");
  await page.getByRole("button", { name: "Send request" }).click();

  await expect(page.getByRole("alert")).toHaveText("Enter the phone as +7 and 10 digits");
  expect(apiCalls).toHaveLength(0);
});

test("shows an error when the server fails", async ({ page }) => {
  await page.route("**/api/leads", (route) => route.fulfill({ status: 500, body: "{}" }));

  await page.getByLabel("Phone").fill("+77011234567");
  await page.getByRole("button", { name: "Send request" }).click();

  await expect(page.getByRole("alert")).toHaveText("Something went wrong. Please try again.");
  await expect(page.getByRole("button", { name: "Send request" })).toBeEnabled();
});
Three form scenarios: success, validation error and server failure
  • The first test also checks the intermediate state: while the server responds, the button reads “Sending…” and is disabled. Without auto-waiting such a state would be hard to catch.
  • The second test listens to the request event and verifies that with an invalid phone no API request is sent at all.
  • The third one replaces the API response via page.route and route.fulfill — this is how you test the error branch without breaking the real server. The docs recommend the same technique to isolate tests from third-party services you do not control.

Auto-waiting and web-first assertions

Before an action Playwright checks that the element is actionable. For click() this means: the locator resolves to exactly one element, the element is visible, stable (not animating), receives events (is not covered by another element) and is enabled. For fill() — visible, enabled and editable. If the checks do not pass within the timeout, the action fails with a TimeoutError. That is why explicit “wait 2 seconds” calls are unnecessary: the docs call page.waitForTimeout() a debugging-only tool and state plainly that tests waiting on a timer are flaky.

The second mechanism is web-first assertions: await expect(locator).toHaveText(…), toBeVisible(), toBeDisabled(), toHaveURL() and others. They re-query the element and retry until the condition holds or the timeout expires — 5 seconds by default. Plain value matchers (toBe, toEqual) do not retry. Let's see what happens when you mix them with reading the DOM. This test is wrong on purpose:

tests/no-wait.spec.ts
import { expect, test } from "@playwright/test";

// Deliberately wrong: reads the text right after the click, without waiting.
test("reads the status without waiting", async ({ page }) => {
  await page.goto("/");
  await page.getByLabel("Phone").fill("+77011234567");
  await page.getByRole("button", { name: "Send request" }).click();

  expect(await page.getByRole("status").textContent()).toBe("Thanks! We will call you back.");
});
Anti-example: the text is read once, right after the click
terminal
$ npx playwright test tests/no-wait.spec.ts --project=chromium --trace retain-on-failure

Running 2 tests using 1 worker

  ok 1 [setup] › tests\auth.setup.ts:5:6 › sign in as manager (257ms)
  x  2 [chromium] › tests\no-wait.spec.ts:4:5 › reads the status without waiting (230ms)


  1) [chromium] › tests\no-wait.spec.ts:4:5 › reads the status without waiting ─────────────────────

    Error: expect(received).toBe(expected) // Object.is equality

    Expected: "Thanks! We will call you back."
    Received: ""

       7 |   await page.getByRole("button", { name: "Send request" }).click();
       8 |
    >  9 |   expect(await page.getByRole("status").textContent()).toBe("Thanks! We will call you back.");
         |                                                        ^
      10 | });
      11 |
        at …\tests\no-wait.spec.ts:9:56

    attachment #1: screenshot (image/png) ──────────────────────────────────────────────────────────
    test-results\no-wait-reads-the-status-without-waiting-chromium\test-failed-1.png
    ────────────────────────────────────────────────────────────────────────────────────────────────

    Error Context: test-results\no-wait-reads-the-status-without-waiting-chromium\error-context.md

    attachment #3: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results\no-wait-reads-the-status-without-waiting-chromium\trace.zip
    Usage:

        npx playwright show-trace test-results\no-wait-reads-the-status-without-waiting-chromium\trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  1 failed
    [chromium] › tests\no-wait.spec.ts:4:5 › reads the status without waiting ──────────────────────
  1 passed (4.1s)
Real output: the test failed, with a screenshot, error context and trace attached

textContent() returned an empty string: after the click the request is still in flight (the server answers in 500 ms), and there is no message on the page yet. Note that running a single file still ran the setup project first — project dependencies always run; you can skip them with --no-deps. The fix is one line:

tests/no-wait.spec.ts
-  expect(await page.getByRole("status").textContent()).toBe("Thanks! We will call you back.");
+  await expect(page.getByRole("status")).toHaveText("Thanks! We will call you back.");
A web-first assertion waits for the text for up to 5 seconds; after the fix the test passes

Flaky

  • expect(await locator.textContent()).toBe(…)
  • expect(await locator.isVisible()).toBe(true)
  • await page.waitForTimeout(2000) before an assertion

Resilient

  • await expect(locator).toHaveText(…)
  • await expect(locator).toBeVisible()
  • Asserting the final state the user sees

Screenshots work in two ways in Playwright. The screenshot: "only-on-failure" option from our config just attaches a picture to a failed test — a debugging aid. The await expect(locator).toHaveScreenshot() assertion is a visual test: the first run saves a baseline and fails, later runs compare against it. Baselines depend on the browser and OS (ours was named lead-form-chromium-win32.png), so they are generated in the same environment where they are checked — usually a Linux CI container.

Fixtures and sign-in via storageState

A fixture is what a test receives in its arguments: page, context, browser, request and browserName are built in. Each test gets its own isolated page in a separate browser context — with its own cookies and localStorage. Custom fixtures are created with test.extend(). Their advantages over beforeEach/afterEach, per the docs: setup and teardown live in one place, a fixture is created only for tests that request it, and it can be reused across files.

tests/fixtures.ts
import { randomUUID } from "node:crypto";
import { test as base, expect } from "@playwright/test";

type Lead = { id: string; name: string; phone: string };

export const test = base.extend<{ lead: Lead }>({
  lead: async ({ request }, use) => {
    // Setup: create the lead through the API, not by clicking through the form.
    const response = await request.post("/api/leads", {
      data: { name: `Test lead ${randomUUID().slice(0, 8)}`, phone: "+77010000001" },
    });
    await expect(response).toBeOK();
    const lead = (await response.json()) as Lead;

    await use(lead);

    // Teardown runs even if the test failed.
    await request.delete(`/api/leads/${lead.id}`);
  },
});

export { expect };
The lead fixture creates a request through the API before the test and deletes it afterwards
  • Everything before await use(lead) is setup, everything after is teardown. Teardown runs even when the test fails.
  • The request fixture is Playwright's HTTP client; it uses the same baseURL, so the /api/leads path is enough.
  • Setting up through the API is faster and more reliable than filling in the form in every test: a separate test already covers the form.
  • The lead name is unique (randomUUID) because tests run in parallel in three browsers and see a shared list.

Now authentication. Signing in through the form in every test is slow. The docs recommend signing in once in a separate setup project, saving the browser state to a file and starting the relevant tests already authenticated. The state (storageState) is the context's cookies and localStorage data.

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

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

setup("sign in as manager", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("manager@example.com");
  await page.getByLabel("Password").fill(process.env.DEMO_PASSWORD ?? "demo-password");
  await page.getByRole("button", { name: "Sign in" }).click();

  // Wait for the final page: by then the session cookie is set.
  await expect(page.getByRole("heading", { name: "Requests" })).toBeVisible();
  await page.context().storageState({ path: authFile });
});
The setup project: sign in through the form and save the browser state
tests/account.spec.ts
import { expect, test } from "./fixtures";

test.use({ storageState: "playwright/.auth/manager.json" });

test("manager sees a new request in the account", async ({ page, lead }) => {
  await page.goto("/account");

  await expect(page.getByText("Signed in as manager@example.com")).toBeVisible();
  await expect(page.getByRole("list", { name: "Requests" })).toContainText(lead.name);
});

test.describe("without a session", () => {
  test.use({ storageState: { cookies: [], origins: [] } });

  test("redirects to the sign-in page", async ({ page }) => {
    await page.goto("/account");
    await expect(page).toHaveURL(/\/login$/);
  });
});
Account tests: with the saved session and with an empty state
playwright/.auth/manager.json
{
  "cookies": [
    {
      "name": "sid",
      "value": "f2489922-86c3-4ed6-921a-8c6522adc678",
      "domain": "localhost",
      "path": "/",
      "expires": -1,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    }
  ],
  "origins": []
}
What was saved after sign-in: an HttpOnly session cookie (a random value from our run)

One shared account works as long as tests do not change each other's server-side state. If one test changes settings and another checks them, the docs recommend a separate account per parallel worker — via a worker-scoped fixture. The full run of all tests:

terminal
$ npx playwright test

Running 16 tests using 14 workers

  ok  1 [setup] › tests\auth.setup.ts:5:6 › sign in as manager (258ms)
  ok  3 [chromium] › tests\account.spec.ts:15:7 › without a session › redirects to the sign-in page (554ms)
  ok 11 [webkit] › tests\account.spec.ts:15:7 › without a session › redirects to the sign-in page (2.0s)
  ok  4 [chromium] › tests\lead-form.spec.ts:18:5 › rejects an invalid phone without calling the API (669ms)
  ok  6 [chromium] › tests\lead-form.spec.ts:31:5 › shows an error when the server fails (679ms)
  ok 15 [webkit] › tests\lead-form.spec.ts:18:5 › rejects an invalid phone without calling the API (2.1s)
  ok 12 [webkit] › tests\account.spec.ts:5:5 › manager sees a new request in the account (2.4s)
  ok  2 [chromium] › tests\account.spec.ts:5:5 › manager sees a new request in the account (1.3s)
  ok 14 [webkit] › tests\lead-form.spec.ts:7:5 › sends a request and shows a confirmation (2.8s)
  ok  5 [chromium] › tests\lead-form.spec.ts:7:5 › sends a request and shows a confirmation (1.5s)
  ok 16 [webkit] › tests\lead-form.spec.ts:31:5 › shows an error when the server fails (993ms)
  ok  7 [firefox] › tests\account.spec.ts:5:5 › manager sees a new request in the account (3.9s)
  ok 10 [firefox] › tests\lead-form.spec.ts:18:5 › rejects an invalid phone without calling the API (3.4s)
  ok 13 [firefox] › tests\lead-form.spec.ts:31:5 › shows an error when the server fails (3.1s)
  ok  9 [firefox] › tests\account.spec.ts:15:7 › without a session › redirects to the sign-in page (2.5s)
  ok  8 [firefox] › tests\lead-form.spec.ts:7:5 › sends a request and shows a confirmation (3.7s)

  16 passed (13.4s)
Real output: 16 tests — setup plus 5 tests each in Chromium, Firefox and WebKit

Report and trace: investigating a failure

A trace is a trace.zip archive with a recording of the test: every action with its locator and duration, DOM snapshots before and after each action, screencast frames, network requests, console output and the source line. When to record it is set by the trace config option or the --trace flag:

on-first-retry

What it does
Records a trace only on the first retry of a failed test
When to use
CI — the docs' recommendation

retain-on-failure

What it does
Records for every test, keeps only failed ones
When to use
Locally or without retries

on

What it does
Records for every test
When to use
One-off debugging: the mode is heavy

off

What it does
Does not record
When to use
Default

We ran our failing test with --trace retain-on-failure, and Playwright itself suggested npx playwright show-trace …\trace.zip in the output. The command opens Trace Viewer locally; the same archive can be loaded at trace.playwright.dev. We unpacked the archive and checked its contents: the steps Navigate → Fill → Click → Get text content → Expect “toBe”, DOM snapshots, screencast frames and the network log. The network log shows the key fact: GET / and GET /app.js got 200, while POST /api/leads had no response yet at the moment of the assertion. That is the cause of the failure — the assertion ran before the server answered.

  • Next to the trace there is error-context.md — the error text and a page snapshot as an accessibility tree. That is how we saw the foreign “404” page in the port 3000 story.
  • npx playwright test --ui — UI mode: file watching, live steps and “time travel” through the trace.
  • npx playwright test --debug — Playwright Inspector: step-by-step execution in a visible browser. --headed just shows the browser window.
  • npx playwright show-report opens the HTML report with filters by browser and status; locally it opens automatically only when something failed.
  • npx playwright codegen <url> records actions and picks locators — handy for a first draft that you then edit by hand.

Running in CI on GitHub Actions

How workflows, events and jobs work is covered in the lesson on CI/CD with GitHub Actions. Here we cover only what e2e needs: install dependencies, install browsers together with Linux system libraries, run the tests and keep the report even when tests fail.

.github/workflows/e2e.yml
name: E2E tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14
Workflow: checks on every pull request and push to main
  • GitHub Actions always sets CI=true, and the config switches itself: test.only is forbidden, two retries, one worker, an HTML report, reuseExistingServer: false.
  • npx playwright install --with-deps also installs the browsers' system dependencies on Linux. An alternative is to run the job in the official Docker image mcr.microsoft.com/playwright:v1.64.0-noble; its tag must match the package version.
  • if: ${{ !cancelled() }} uploads the report even when tests fail — exactly when you need it. Traces of failed tests end up in the report.
  • The docs recommend limiting run time with globalTimeout in the config rather than the job's timeout-minutes: Playwright then stops by itself and still writes the report.
  • The major versions of the actions (checkout, setup-node, upload-artifact — v7) are current as of this lesson's check date; Playwright's own examples may show earlier ones.
terminal
$ CI=1 npx playwright test

Running 16 tests using 1 worker

  ok  1 [setup] › tests\auth.setup.ts:5:6 › sign in as manager (276ms)
  ok  2 [chromium] › tests\account.spec.ts:5:5 › manager sees a new request in the account (779ms)
  ok  3 [chromium] › tests\account.spec.ts:15:7 › without a session › redirects to the sign-in page (152ms)
  …
  ok 16 [webkit] › tests\lead-form.spec.ts:31:5 › shows an error when the server fails (655ms)

  16 passed (19.0s)
A local check of the same logic: a run with CI=1 — one worker, all 16 tests passed, playwright-report created

When the suite grows, split it across several machines with --shard=1/3 and merge the reports. To find tests that accidentally depend on each other, 1.64 added the --shuffle flag: it randomises the order and prints a seed so you can reproduce it.

Common mistakes and exercises

  • Assertions like expect(await locator.isVisible()).toBe(true) instead of await expect(locator).toBeVisible().
  • Locators based on CSS classes and XPath — the test breaks on any markup change.
  • page.waitForTimeout() instead of waiting for a specific state.
  • Tests depend on each other or on shared data — prepare data with fixtures, check ordering with --shuffle.
  • Third-party sites and services are tested directly — replace their responses with page.route.
  • reuseExistingServer attached to another app on the same port.
  • playwright/.auth/ and reports ended up in the repository or in public access.
  • toHaveScreenshot baselines were taken on Windows but compared in a Linux CI container.

Next step: plug the e2e suite into the shared pull request pipeline and keep role-based tests in step with the accessibility work on the interface.

How this was verified4 · passed1 · partialchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.