Тест-сьюты которые нормально работают на 50 тестах часто разваливаются на 200: тесты зависящие друг от друга не могут запускаться параллельно, захардкоженные задержки делают сьют медленным и при этом флакующим, а повторяющиеся локаторы разбросанные по файлам означают что одно изменение UI ломает десятки тестов. Практики которые этому препятствуют стабильны: понятные имена тестов, строгая изоляция состояния, замена waitForTimeout на ожидание элементов, и Page Object Model внедрённый в нужный момент. Здесь разобрана каждая практика с конкретными примерами на Playwright и обоснованием каждого решения.

Называй тесты как предложения, а не как код

Имя теста: первое что читаешь когда что-то падает в CI в 2 часа ночи. Оно должно точно говорить что сломалось без открытия файла.

Плохо:

test('login test', async ({ page }) => { ... });
test('test1', async ({ page }) => { ... });
test('checkTable', async ({ page }) => { ... });

Хорошо:

test('user can log in with valid credentials', async ({ page }) => { ... });
test('login fails with incorrect password', async ({ page }) => { ... });
test('travel items table shows 5 rows after login', async ({ page }) => { ... });

Паттерн: [кто] может/не может [сделать что] [при каком условии]. Пиши так чтобы нетехнический человек читающий вывод CI понял что упало.

describe-блоки работают так же:

test.describe('Login', () => {
  test('succeeds with valid credentials', async ({ page }) => { ... });
  test('fails with wrong password', async ({ page }) => { ... });
  test('fails with empty email', async ({ page }) => { ... });
});

Одно утверждение на тест: идеал, а не правило

Встречается совет «одно утверждение на тест». Реальное правило другое: одна логическая концепция на тест.

Тест который логинится и проверяет заголовок страницы нормален: это части одного флоу. Тест который логинится, проверяет заголовок, редактирует запись, проверяет обновление и потом разлогинивается делает слишком много. Когда он упадёт, непонятно какая часть сломалась.

Хорошо. Одна концепция, несколько связанных утверждений:

test('login redirects to dashboard with correct header', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();

  await expect(page).toHaveURL(/dashboard/);
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
  await expect(page.getByText('My Travel Items')).toBeVisible();
});

Держи тесты независимыми друг от друга

Тесты зависящие друг от друга: ловушка. Если тест 3 работает только когда тест 2 уже выполнился, нельзя запускать тесты параллельно, нельзя запустить один тест изолированно, и когда тест 2 ломается получаешь каскад падений которые сложно диагностировать.

Каждый тест должен сам настраивать своё состояние и убирать за собой.

Каждый тест которому нужен залогиненный пользователь должен сам выполнять логин, или использовать storageState чтобы сохранить auth-куку и переиспользовать её без повтора UI-флоу.

// Плохо: зависит от того что предыдущий тест залогинился
test('can see travel items', async ({ page }) => {
  // предполагает что уже залогинены, падает при запуске отдельно
  await expect(page.getByText('My Travel Items')).toBeVisible();
});

// Хорошо: настраивает своё состояние
test('can see travel items', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();
  await expect(page.getByText('My Travel Items')).toBeVisible();
});

Никогда не коммить test.only в код. Он тихо отключает все остальные тесты в файле. Если test.only попадёт в мастер, CI будет проходить с 1 тестом вместо 50 и никто не заметит пока что-то не сломается в продакшне.

Используй Page Object Model когда файлы разрастаются

Когда тестовый файл переваливает за 200 строк и каждый тест повторяет одинаковые getByLabel('Username').fill(...), пора вводить Page Object Model (POM).

POM переносит взаимодействия со страницей в отдельный класс. Тесты вызывают методы этого класса вместо прямых команд Playwright. Когда форма логина меняется, обновляешь один класс вместо каждого теста который касается логина.

// pages/LoginPage.ts
import { Page } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('https://lab.becomeqa.com');
    await this.page.getByRole('button', { name: 'Login' }).click();
  }

  async login(username: string, password: string) {
    await this.page.getByLabel('Username').fill(username);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Submit' }).click();
  }
}

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('user can log in', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@becomeqa.com', 'testpass123');

  await expect(page.getByText('My Travel Items')).toBeVisible();
});

Когда UI логина меняется, правишь LoginPage.ts и все тесты остаются зелёными.

Не спеши с POM. Сначала пиши тесты без него. Когда ловишь себя на копировании одних и тех же 5 строк в третий раз: это сигнал.

Не используй захардкоженные задержки

page.waitForTimeout(3000) заставляет Playwright ждать ровно 3 секунды вне зависимости от состояния экрана. Тест становится медленным на быстрых машинах и при этом флакует на медленных CI-раннерах.

Playwright автоматически ждёт элементы перед взаимодействием с ними. Когда реально нужно подождать что-то конкретное, жди именно это:

// Плохо
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Save' }).click();

// Хорошо: Playwright ждёт автоматически перед кликом
await page.getByRole('button', { name: 'Save' }).click();

// Хорошо: ждём появления конкретного элемента
await page.waitForSelector('[data-testid="success-toast"]');

// Хорошо: ждём завершения сетевого запроса
await page.waitForResponse(resp => resp.url().includes('/api/items'));

waitForTimeout допустим только при локальной отладке чтобы замедлить выполнение и посмотреть что происходит. В коммитнутом тестовом коде ему не место.

Используй переменные окружения для credentials и URL

Хардкодить credentials и базовые URL в тестах создаёт две проблемы: они попадают в историю git, и при изменении нужно перелопатить все тестовые файлы.

Храни их в файле .env и загружай через конфиг Playwright:

// .env (никогда не коммить этот файл)
BASE_URL=https://lab.becomeqa.com
TEST_USER=admin@becomeqa.com
TEST_PASS=testpass123

// playwright.config.ts
export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || 'https://lab.becomeqa.com',
  },
});

// tests/login.spec.ts
test('user can log in', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill(process.env.TEST_USER!);
  await page.getByLabel('Password').fill(process.env.TEST_PASS!);
  await page.getByRole('button', { name: 'Submit' }).click();

  await expect(page.getByText('My Travel Items')).toBeVisible();
});

Добавь .env в .gitignore. В CI задай переменные окружения в конфиге пайплайна.

Структурируй папку тестов до того как она разрастётся

Структура папок которая работает для 10 тестов разваливается на 100. Настрой её заранее:

tests/
  auth/
    login.spec.ts
    logout.spec.ts
  items/
    items-list.spec.ts
    items-crud.spec.ts
  api/
    items-api.spec.ts
pages/
  LoginPage.ts
  ItemsPage.ts
fixtures/
  auth.fixture.ts

Группируй по фиче, а не по типу. tests/auth/ лучше чем tests/ui/ потому что когда что-то в auth ломается, сразу знаешь где искать.

Используй npx playwright test tests/auth/ чтобы запускать только тесты в конкретной папке. Удобно когда работаешь над отдельной фичей и не хочешь ждать весь сьют.

Пиши тесты которые документируют намерение

Тест читается как документация: имена переменных должны описывать что они содержат, тестовые данные должны выглядеть реалистично, и комментарии нужны только для неочевидной настройки.

// Сложно понять
const u = 'admin@becomeqa.com';
const p = 'testpass123';
await page.getByLabel('Username').fill(u);

// Понятно
const adminEmail = 'admin@becomeqa.com';
const adminPassword = 'testpass123';
await page.getByLabel('Username').fill(adminEmail);

Небольшая разница в коде, большая разница в читабельности через полгода.

Запускай полный сьют перед мержем

Тесты без CI: это пожелания, не тесты. Подключи Playwright-тесты к CI чтобы они запускались автоматически на каждый пул-реквест.

Минимум что должен делать твой CI: установить зависимости, установить браузеры, запустить сьют, и загрузить HTML-отчёт если тесты упали:

# .github/workflows/tests.yml (упрощённо)
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test

Тесты проходят в CI: мержишь. Падают: чинишь до мержа. Вот и весь контракт.

FAQ

Сколько тестов в одном файле считается слишком много

Около 300–400 строк, когда прокрутка чтобы найти тест начинает раздражать. В этот момент разбивай по фичам.

Нужно ли тестировать каждый граничный случай

Нет. Тестируй happy path, наиболее частый путь ошибки, и граничные случаи которые уже вызывали реальные баги. Цель: уверенность, а не 100% покрытие ради покрытия.

Тесты проходят локально но падают в CI. Что чаще всего не так

Три наиболее частые причины: захардкоженный localhost URL, пропущенный await, или гонка состояний которая скрыта потому что твоя машина быстрее CI-раннера. Смотри вывод trace viewer из CI: он показывает точно где сломалось.

Когда использовать beforeEach а когда фикстуру

beforeEach для простой настройки специфичной для одного тестового файла. Фикстуры для настройки переиспользуемой в нескольких файлах (например залогиненная страница или предзаполненные тестовые данные). → See also: Page Object Model в Playwright: от хаоса к поддерживаемым тестам | Фикстуры Playwright: от встроенных до кастомных | Отладка нестабильных тестов: практическое руководство | Изоляция тестов: почему каждый тест Playwright должен быть stateless