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.
After this you will be able to
- install Playwright Test, configure
playwright.config.tswithwebServerand run tests in Chromium, Firefox, WebKit and an installed Edge - write an e2e test for a lead form with
getByRoleandgetByLabeland web-first assertions, and explain whyexpect(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
Know beforehand
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
- What an e2e test checks and what we will build
- Installation and the app under test
- Locators: roles and labels instead of CSS classes
- Auto-waiting and web-first assertions
- Fixtures and sign-in via storageState
- Report and trace: investigating a failure
- Running in CI on GitHub Actions
- Common 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.
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:
npm init -y
npm install -D @playwright/test
npx playwright install
npx playwright --versionnpx playwright install downloads Chromium, Firefox and WebKit into a shared per-user cacheOn 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.
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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}`));<!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><label> elements give fields their names, role="alert" and role="status" are message regionsconst 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";
}
});<!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>sid cookieNow the configuration. Runner options live at the top level, browser and page options in use, browser sets in projects.
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,
},
});webServerrunsnode server.mjsand waits untilurlresponds.baseURLlets tests callpage.goto("/login").fullyParallelandworkers— 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
setupproject signs in before the other tests (details below),dependenciesdefines the order. default: false(new in 1.64) keeps a project in the config but does not run it without an explicit--project=edge. Theedgeproject uses the installed Microsoft Edge throughchannel: "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:
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();
});- 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
requestevent and verifies that with an invalid phone no API request is sent at all. - The third one replaces the API response via
page.routeandroute.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:
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.");
});$ 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)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:
- 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.");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.
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 };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
requestfixture is Playwright's HTTP client; it uses the samebaseURL, so the/api/leadspath 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.
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 });
});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$/);
});
});{
"cookies": [
{
"name": "sid",
"value": "f2489922-86c3-4ed6-921a-8c6522adc678",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": []
}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:
$ 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)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.--headedjust shows the browser window.npx playwright show-reportopens 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.
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- GitHub Actions always sets
CI=true, and the config switches itself:test.onlyis forbidden, two retries, one worker, an HTML report,reuseExistingServer: false. npx playwright install --with-depsalso installs the browsers' system dependencies on Linux. An alternative is to run the job in the official Docker imagemcr.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
globalTimeoutin the config rather than the job'stimeout-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.
$ 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)CI=1 — one worker, all 16 tests passed, playwright-report createdWhen 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 ofawait 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. reuseExistingServerattached to another app on the same port.playwright/.auth/and reports ended up in the repository or in public access.toHaveScreenshotbaselines 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.