Тест-сьюты которые нормально работают на 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