Как делать11 октября 2026 г.11 мин чтения
DevOps, тестирование и безопасностьШаг 10 из 12

E2E-тесты на Playwright

Коротко

E2E-тест (end-to-end, сквозной) запускает настоящий браузер, открывает работающее приложение и проходит сценарий так, как его прошёл бы человек: заполняет форму, нажимает кнопку и проверяет результат на экране. В Playwright Test такой тест строится на локаторах по ролям и подписям, авто-ожиданиях перед действиями и web-first проверках, которые повторяются, пока условие не выполнится. Причину падения ищут по trace — записи каждого шага с DOM, сетью и консолью.

Пошаговый урокУровень: СреднийE2E-тестированиеПрактика: ~1,5–3 ч

После материала вы сможете

  • установить 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 г.

Содержание
  1. 01Что проверяет e2e-тест и что мы построим
  2. 02Установка и приложение под тестом
  3. 03Локаторы: роли и подписи вместо CSS-классов
  4. 04Авто-ожидания и web-first проверки
  5. 05Фикстуры и вход через storageState
  6. 06Отчёт и trace: разбираем падение
  7. 07Запуск в CI на GitHub Actions
  8. 08Частые ошибки и упражнения

Что проверяет 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, с окном браузера и без него.

webServer поднимает приложениеБраузер открывает страницуЛокаторы и действияWeb-first проверкиОтчёт и trace
Что происходит при запуске `npx playwright test`

Установка и приложение под тестом

Новый проект проще начать командой npm init playwright@latest: она спросит язык (TypeScript по умолчанию), папку тестов, предложит добавить workflow для GitHub Actions и скачает браузеры. В существующий проект Playwright ставят вручную:

bash
npm init -y
npm install -D @playwright/test
npx playwright install
npx playwright --version
Установка в существующий проект. npx 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 мс — так ведёт себя настоящий бэкенд, и именно на этой задержке ломаются наивные тесты.

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}`));
Учебный сервер: заявки и сессии в памяти, пароль сравнивается открытым текстом. Так делать можно только в демо — в рабочем проекте пароли хешируют, а сессии хранят в БД или 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>
Форма: подписи <label> связывают поля с именами, role="alert" и role="status" — области для сообщений
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";
  }
});
Клиентская логика: проверка телефона, блокировка кнопки на время запроса, сообщения об успехе и ошибке
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>
Страница входа — обычная HTML-форма, сервер отвечает редиректом и ставит cookie sid

Теперь конфигурация. Параметры раннера лежат на верхнем уровне, параметры браузера и страницы — в use, наборы браузеров — в 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,
  },
});
Конфиг: три браузера по умолчанию, Edge — по запросу, сервер стартует автоматически
  • 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. Тест формы заявки:

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();
});
Три сценария формы: успех, ошибка валидации и сбой сервера
  • Первый тест проверяет и промежуточное состояние: пока сервер отвечает, кнопка называется «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. Этот тест написан неправильно намеренно:

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.");
});
Антипример: текст читается один раз, сразу после клика
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)
Реальный вывод: тест упал, к нему приложены скриншот, error-context и trace

textContent() вернул пустую строку: после клика запрос ещё летит (сервер отвечает через 500 мс), и сообщения на странице пока нет. Обратите внимание: при запуске одного файла сначала выполнился проект setup — зависимости проектов запускаются всегда, отключить их можно флагом --no-deps. Исправление — одна строка:

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.");
Web-first проверка ждёт текст до 5 секунд; после исправления тест проходит

Нестабильно

  • 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 по документации: подготовка и уборка в одном месте, фикстура создаётся только для тех тестов, которые её запросили, и её можно переиспользовать в разных файлах.

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 };
Фикстура lead: создаёт заявку через API до теста и удаляет после
  • Всё до await use(lead) — подготовка, всё после — уборка. Уборка выполняется и тогда, когда тест упал.
  • Фикстура request — HTTP-клиент Playwright; он использует тот же baseURL, поэтому достаточно пути /api/leads.
  • Подготовка через API быстрее и надёжнее, чем заполнение формы в каждом тесте: форму уже проверяет отдельный тест.
  • Имя заявки уникально (randomUUID), потому что тесты идут параллельно в трёх браузерах и видят общий список.

Теперь авторизация. Входить через форму в каждом тесте медленно. Документация рекомендует войти один раз в отдельном проекте setup, сохранить состояние браузера в файл и стартовать нужные тесты уже авторизованными. Состояние (storageState) — это cookie и данные localStorage контекста.

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 });
});
Проект setup: вход через форму и сохранение состояния браузера
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$/);
  });
});
Тесты кабинета: с сохранённой сессией и с пустым состоянием
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": []
}
Что сохранилось после входа: HttpOnly-cookie сессии (значение случайное, из нашего прогона)

Один общий аккаунт подходит, пока тесты не меняют серверное состояние друг у друга. Если один тест меняет настройки, а другой их проверяет, документация советует отдельный аккаунт на каждый параллельный воркер — через фикстуру с областью worker. Полный прогон всех тестов:

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)
Реальный вывод: 16 тестов — setup и по 5 тестов в Chromium, Firefox и WebKit

Отчёт и 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, запустить тесты и сохранить отчёт, даже если тесты упали.

.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: проверки на каждый pull request и push в main
  • 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 могут стоять предыдущие.
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)
Локальная проверка той же логики: прогон с 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.