E2E-тест (end-to-end, сквозной) запускает настоящий браузер, открывает работающее приложение и проходит сценарий так, как его прошёл бы человек: заполняет форму, нажимает кнопку и проверяет результат на экране. В Playwright Test такой тест строится на локаторах по ролям и подписям, авто-ожиданиях перед действиями и web-first проверках, которые повторяются, пока условие не выполнится. Причину падения ищут по trace — записи каждого шага с DOM, сетью и консолью.
После материала вы сможете
- установить Playwright Test, настроить
playwright.config.tsсwebServerи запускать тесты в Chromium, Firefox, WebKit и установленном Edge - написать e2e-тест формы заявки на
getByRoleиgetByLabelс web-first проверками и объяснить, почемуexpect(await locator.textContent())даёт нестабильный тест - вынести подготовку данных в фикстуру и входить в приложение один раз через
storageState - найти причину падения по trace и запустить набор в GitHub Actions с отчётом в артефактах
Что знать заранее
Проверено на
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
Сверено с документацией: 11 октября 2026 г.
Содержание
Что проверяет e2e-тест и что мы построим
Unit-тест проверяет функцию, компонентный тест — компонент в искусственном DOM. E2E-тест поднимает приложение целиком и управляет настоящим браузером: сервер, HTML, JavaScript, cookie, сеть и вёрстка работают вместе. Поэтому он ловит то, что не видят нижние уровни: форма не отправляется из-за опечатки в URL API, кнопку перекрывает баннер, после входа не ставится cookie. Цена — скорость и хрупкость: каждый тест открывает браузер и ждёт сервер.
Unit
- Что запускается
- Одна функция в Node.js
- Что ловит
- Ошибки расчёта и валидации
Компонентный
- Что запускается
- Компонент в jsdom
- Что ловит
- Поведение интерфейса без сервера
E2E
- Что запускается
- Приложение + настоящий браузер
- Что ловит
- Поломки на стыках: сеть, cookie, маршруты, вёрстка
Отсюда правило из пирамиды тестирования: e2e-тестами покрывают ключевые сценарии — заявку, вход, оплату, — а все ветки логики проверяют дешевле, на нижних уровнях. Playwright Test — тест-раннер, в который уже встроены проверки (expect), изоляция тестов, параллельный запуск и инструменты отладки. По документации он работает с Chromium, Firefox и WebKit на Windows, Linux и macOS, локально и в CI, с окном браузера и без него.
Установка и приложение под тестом
Новый проект проще начать командой npm init playwright@latest: она спросит язык (TypeScript по умолчанию), папку тестов, предложит добавить workflow для GitHub Actions и скачает браузеры. В существующий проект Playwright ставят вручную:
npm init -y
npm install -D @playwright/test
npx playwright install
npx playwright --versionnpx playwright install скачивает Chromium, Firefox и WebKit в общий кеш пользователяНа нашей машине последняя команда вывела Version 1.64.0. Требования из документации: Node.js 22, 24 или 26, Windows 11+, macOS 14+, Debian 12/13 или Ubuntu 22.04/24.04/26.04. Браузеры Playwright — это специальные сборки, привязанные к версии пакета: после обновления @playwright/test команду npx playwright install запускают снова.
Тестировать будем приложение без фреймворков, чтобы весь механизм был на виду. Сервер на node:http отдаёт форму, принимает заявки и обслуживает вход по cookie. Ответ на заявку искусственно задержан на 500 мс — так ведёт себя настоящий бэкенд, и именно на этой задержке ломаются наивные тесты.
// 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> связывают поля с именами, role="alert" и role="status" — области для сообщений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";
}
});<!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Теперь конфигурация. Параметры раннера лежат на верхнем уровне, параметры браузера и страницы — в use, наборы браузеров — в 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,
},
});webServerзапускаетnode server.mjsи ждёт, покаurlначнёт отвечать.baseURLпозволяет писать в тестахpage.goto("/login").fullyParallelиworkers— тесты идут параллельно в нескольких процессах-воркерах; в CI документация советует один воркер ради стабильности.retries— в CI упавший тест перезапускается до двух раз;trace: "on-first-retry"записывает trace на первом повторе.screenshot: "only-on-failure"прикладывает к упавшему тесту снимок страницы.- Проект
setupвыполняет вход до остальных тестов (подробно — ниже),dependenciesзадаёт порядок. default: false(появилось в 1.64) оставляет проект в конфиге, но не запускает его без явного--project=edge. Проектedgeиспользует установленный Microsoft Edge черезchannel: "msedge"— Playwright не скачивает брендовые Chrome и Edge сам.
Локаторы: роли и подписи вместо CSS-классов
Локатор — это способ найти элемент в любой момент, а не ссылка на конкретный DOM-узел. Playwright ищет элемент заново перед каждым действием, поэтому перерисовка страницы между действиями тесту не мешает. Документация рекомендует в первую очередь локаторы по тому, что видит пользователь и вспомогательные технологии.
getByRole
- Когда использовать
- Кнопки, ссылки, заголовки, чекбоксы, списки — почти всё интерактивное
- Пример
getByRole("button", { name: "Send request" })
getByLabel
- Когда использовать
- Поля формы с подписью
- Пример
getByLabel("Phone")
getByText
- Когда использовать
- Неинтерактивный текст: абзацы, плашки
- Пример
getByText("Signed in as …")
getByPlaceholder
- Когда использовать
- Поле без подписи, но с подсказкой
- Пример
getByPlaceholder("+77011234567")
getByTestId
- Когда использовать
- Когда у элемента нет устойчивой роли и текста
- Пример
getByTestId("price")
Роль у многих тегов неявная: <button> — это button, <h1> — heading, <ul> — list, <p role="status"> — status. Имя (name) — доступное имя элемента: текст кнопки или подпись поля. По умолчанию имя сравнивается без учёта регистра и по подстроке: { name: "send" } найдёт и «Send request». Для точного совпадения есть exact: true. Тест формы заявки:
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();
});- Первый тест проверяет и промежуточное состояние: пока сервер отвечает, кнопка называется «Sending…» и заблокирована. Без авто-ожиданий поймать такое состояние было бы трудно.
- Второй тест слушает событие
requestи убеждается, что при неверном телефоне запрос в API не ушёл вовсе. - Третий подменяет ответ API через
page.routeиroute.fulfill— так проверяют ветку ошибки, не ломая настоящий сервер. Тем же способом документация советует изолироваться от сторонних сервисов, которые вы не контролируете.
Авто-ожидания и web-first проверки
Перед действием Playwright проверяет, что с элементом можно работать (actionability). Для click() это: локатор указывает ровно на один элемент, элемент видим, стабилен (не анимируется), получает события (его не перекрывает другой элемент) и не заблокирован. Для fill() — видим, не заблокирован и редактируем. Если условия не выполнились за отведённое время, действие падает с TimeoutError. Явные «подождать 2 секунды» поэтому не нужны: документация называет page.waitForTimeout() инструментом только для отладки и прямо говорит, что тесты с ожиданием по таймеру нестабильны.
Второй механизм — web-first проверки: await expect(locator).toHaveText(…), toBeVisible(), toBeDisabled(), toHaveURL() и другие. Они перезапрашивают элемент и повторяют проверку, пока условие не выполнится или не истечёт таймаут — по умолчанию 5 секунд. Обычные проверки значений (toBe, toEqual) не повторяются. Посмотрим, что будет, если смешать их с чтением DOM. Этот тест написан неправильно намеренно:
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() вернул пустую строку: после клика запрос ещё летит (сервер отвечает через 500 мс), и сообщения на странице пока нет. Обратите внимание: при запуске одного файла сначала выполнился проект setup — зависимости проектов запускаются всегда, отключить их можно флагом --no-deps. Исправление — одна строка:
- 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.");Нестабильно
expect(await locator.textContent()).toBe(…)expect(await locator.isVisible()).toBe(true)await page.waitForTimeout(2000)перед проверкой
Устойчиво
await expect(locator).toHaveText(…)await expect(locator).toBeVisible()- Проверка конечного состояния, которое видит пользователь
Скриншоты в Playwright работают двумя способами. Опция screenshot: "only-on-failure" из нашего конфига просто прикладывает снимок к упавшему тесту — это помощь в отладке. Проверка await expect(locator).toHaveScreenshot() — уже визуальный тест: первый запуск сохраняет эталон и падает, следующие сравнивают снимок с эталоном. Эталоны зависят от браузера и ОС (у нас файл назывался lead-form-chromium-win32.png), поэтому их генерируют в том же окружении, где идут проверки, — обычно в Linux-контейнере CI.
Фикстуры и вход через storageState
Фикстура — это то, что тест получает в аргументах: page, context, browser, request, browserName уже встроены. Каждый тест получает свою изолированную страницу в отдельном контексте браузера — со своими cookie и localStorage. Собственные фикстуры создают через test.extend(). Их преимущества перед beforeEach/afterEach по документации: подготовка и уборка в одном месте, фикстура создаётся только для тех тестов, которые её запросили, и её можно переиспользовать в разных файлах.
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: создаёт заявку через API до теста и удаляет после- Всё до
await use(lead)— подготовка, всё после — уборка. Уборка выполняется и тогда, когда тест упал. - Фикстура
request— HTTP-клиент Playwright; он использует тот жеbaseURL, поэтому достаточно пути/api/leads. - Подготовка через API быстрее и надёжнее, чем заполнение формы в каждом тесте: форму уже проверяет отдельный тест.
- Имя заявки уникально (
randomUUID), потому что тесты идут параллельно в трёх браузерах и видят общий список.
Теперь авторизация. Входить через форму в каждом тесте медленно. Документация рекомендует войти один раз в отдельном проекте setup, сохранить состояние браузера в файл и стартовать нужные тесты уже авторизованными. Состояние (storageState) — это cookie и данные localStorage контекста.
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": []
}Один общий аккаунт подходит, пока тесты не меняют серверное состояние друг у друга. Если один тест меняет настройки, а другой их проверяет, документация советует отдельный аккаунт на каждый параллельный воркер — через фикстуру с областью worker. Полный прогон всех тестов:
$ 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)Отчёт и trace: разбираем падение
Trace — архив trace.zip с записью теста: каждое действие с локатором и длительностью, снимки DOM до и после действия, кадры экрана, сетевые запросы, консоль и строка исходного кода. Когда записывать trace, задаёт опция trace в конфиге или флаг --trace:
on-first-retry
- Что делает
- Пишет trace только при первом повторе упавшего теста
- Когда использовать
- CI — рекомендация документации
retain-on-failure
- Что делает
- Пишет для всех, хранит только у упавших
- Когда использовать
- Локально или без повторов
on
- Что делает
- Пишет для каждого теста
- Когда использовать
- Разовая отладка: режим тяжёлый
off
- Что делает
- Не пишет
- Когда использовать
- По умолчанию
Наш упавший тест запускали с --trace retain-on-failure, и в выводе Playwright сам подсказал команду npx playwright show-trace …\trace.zip. Она открывает Trace Viewer локально; тот же архив можно загрузить на trace.playwright.dev. Мы распаковали архив и проверили, что в нём: шаги Navigate → Fill → Click → Get text content → Expect «toBe», снимки DOM, кадры экрана и журнал сети. В сети видно главное: GET / и GET /app.js получили 200, а POST /api/leads на момент проверки ещё не получил ответа. Это и есть причина падения — проверка выполнилась раньше, чем ответил сервер.
- Рядом с trace лежит
error-context.md— текст ошибки и снимок страницы в виде дерева доступности. По нему мы и поняли, что в истории с портом 3000 открылась чужая страница «404». npx playwright test --ui— UI-режим: наблюдение за файлами, шаги в реальном времени и «путешествие во времени» по trace.npx playwright test --debug— Playwright Inspector: пошаговое выполнение в видимом браузере.--headedпросто показывает окно браузера.npx playwright show-reportоткрывает HTML-отчёт с фильтрами по браузерам и статусам; локально он открывается сам, только если были падения.npx playwright codegen <url>записывает действия и подбирает локаторы — удобно, чтобы получить черновик и поправить его вручную.
Запуск в CI на GitHub Actions
Как устроены workflow, события и задачи, разобрано в уроке о CI/CD на GitHub Actions. Здесь — только то, что нужно для e2e: установить зависимости, поставить браузеры вместе с системными библиотеками Linux, запустить тесты и сохранить отчёт, даже если тесты упали.
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 всегда выставляет переменную
CI=true, и конфиг переключается сам: запретtest.only, два повтора, один воркер, HTML-отчёт,reuseExistingServer: false. npx playwright install --with-depsна Linux доставляет и системные зависимости браузеров. Альтернатива — запускать задачу в официальном Docker-образеmcr.microsoft.com/playwright:v1.64.0-noble, его тег должен совпадать с версией пакета.if: ${{ !cancelled() }}загружает отчёт и при упавших тестах — именно тогда он нужен. Trace упавших тестов попадает в отчёт.- Документация советует ограничивать время прогона через
globalTimeoutв конфиге, а не черезtimeout-minutesзадачи: тогда Playwright останавливается сам и успевает сохранить отчёт. - Мажорные версии actions (
checkout,setup-node,upload-artifact— v7) актуальны на дату проверки урока; в примерах документации Playwright могут стоять предыдущие.
$ 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 — один воркер, все 16 тестов прошли, создан playwright-reportКогда тестов станет много, их делят между несколькими машинами флагом --shard=1/3 и сливают отчёты. Для поиска тестов, которые случайно зависят друг от друга, в 1.64 появился флаг --shuffle: он перемешивает порядок и печатает seed, чтобы повторить тот же порядок.
Частые ошибки и упражнения
- Проверки вида
expect(await locator.isVisible()).toBe(true)вместоawait expect(locator).toBeVisible(). - Локаторы по CSS-классам и XPath — тест ломается от любой правки вёрстки.
page.waitForTimeout()вместо ожидания конкретного состояния.- Тесты зависят друг от друга или от общих данных — готовьте данные фикстурами, проверяйте порядок через
--shuffle. - Тестируются чужие сайты и сервисы — подменяйте их ответы через
page.route. reuseExistingServerподключился к чужому приложению на том же порту.playwright/.auth/и отчёты попали в репозиторий или в открытый доступ.- Эталоны
toHaveScreenshotсняты на Windows, а сравниваются в Linux-контейнере CI.
Следующий шаг — подключить e2e-набор к общему пайплайну проверок pull request и держать тесты по ролям в одном ритме с работой над доступностью интерфейса.
Как проверено4 · пройдено1 · частичносверено 11 октября 2026 г.
- запущено, работает
server.mjs, public/index.html, public/app.js, public/login.html (приложение под тестом)
node 24.18 во временном проекте scratchpad/kb/verify/e2e-playwright; запускается через webServer
- запущено, работает
playwright.config.ts, tests/lead-form.spec.ts, tests/fixtures.ts, tests/auth.setup.ts, tests/account.spec.ts
@playwright/test 1.64.0, tsc 7.0.2 --noEmit без ошибок; npx playwright test — 16 passed (setup + 5×Chromium/Firefox/WebKit); --project=edge (channel msedge, Edge 155.0.4283.45) — 6 passed
- запущено, работает
tests/no-wait.spec.ts (намеренно падающий) + исправление diff
плохая версия падает (Received ""), trace.zip создан и распакован: шаги, снимки, сеть (POST /api/leads без ответа на момент проверки); исправленная версия проходит. Trace Viewer GUI (show-trace) не открывался — содержимое проверено по архиву
- запущено, работает
Упражнения: переименованная кнопка, dblclick, toHaveScreenshot
кнопка Submit → timeout на click с Call log; dblclick-тест проходит (1 запрос); toHaveScreenshot: первый прогон пишет lead-form-chromium-win32.png и падает, второй проходит, после смены h1 — 323 px различий
- частично
.github/workflows/e2e.yml
YAML разобран парсером yaml; команды workflow (npm ci → npx playwright test с CI=1) выполнены локально: 1 воркер, 16 passed, создан playwright-report. На GitHub Actions workflow не запускался (без репозитория и push)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.