Перейти к содержимому

24. E2E тестирование (Playwright)

End-to-End (E2E) тестирование проверяет твоё приложение глазами реального пользователя — открывает браузер, кликает кнопки, заполняет формы и проверяет, что всё работает от начала до конца. Playwright — современный инструмент от Microsoft, который делает это быстро, надёжно и с кучей полезных фишек. 🚀


Unit-тесты проверяют отдельные функции, интеграционные — связи между компонентами, а E2E-тесты — весь пользовательский сценарий целиком:

Unit тест: [функция] → ✅
Integration: [компонент A] + [компонент B] → ✅
E2E тест: [браузер] → открыть → кликнуть → заполнить → отправить → ✅

Это последний рубеж защиты. Именно E2E-тесты ловят баги, которые возникают только когда всё работает вместе.


Окно терминала
npm init playwright@latest
# Выбери:
# ✔ Where to put your end-to-end tests? › tests
# ✔ Add a GitHub Actions workflow? › Yes
# ✔ Install Playwright browsers? › Yes

После установки появятся файлы:

playwright.config.ts ← главный конфиг
tests/
example.spec.ts ← пример теста
tests-examples/
demo-todo-app.spec.ts ← подробный пример

import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// Параллельный запуск тестов
fullyParallel: true,
// Запрет случайного пуша с .only
forbidOnly: !!process.env.CI,
// Повторы при падении в CI
retries: process.env.CI ? 2 : 0,
// Количество воркеров
workers: process.env.CI ? 1 : undefined,
// Репортер
reporter: 'html',
use: {
// Базовый URL приложения
baseURL: 'http://localhost:5173',
// Записывать скриншоты при падении
screenshot: 'only-on-failure',
// Записывать видео при падении
video: 'retain-on-failure',
// Трейс для отладки
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Мобильные
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
// Запустить dev-сервер перед тестами
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});

tests/navigation.spec.ts
import { test, expect } from '@playwright/test';
test('главная страница загружается', async ({ page }) => {
// Перейти на главную
await page.goto('/');
// Проверить заголовок
await expect(page).toHaveTitle(/Vue App/);
// Проверить наличие элемента
await expect(page.getByRole('heading', { name: 'Добро пожаловать' })).toBeVisible();
});
test('навигация между страницами', async ({ page }) => {
await page.goto('/');
// Кликнуть по ссылке "О нас"
await page.getByRole('link', { name: 'О нас' }).click();
// Проверить, что URL изменился
await expect(page).toHaveURL('/about');
// Проверить контент новой страницы
await expect(page.getByRole('heading', { name: 'О нас' })).toBeVisible();
});

Page Object Model — паттерн, который делает тесты читаемыми и поддерживаемыми. Вместо прямых селекторов в тестах — объекты, инкапсулирующие взаимодействие со страницей.

tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Пароль');
this.submitButton = page.getByRole('button', { name: 'Войти' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
tests/pages/DashboardPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly welcomeMessage: Locator;
readonly logoutButton: Locator;
constructor(page: Page) {
this.page = page;
this.welcomeMessage = page.getByTestId('welcome-message');
this.logoutButton = page.getByRole('button', { name: 'Выйти' });
}
async expectLoggedIn(userName: string) {
await expect(this.welcomeMessage).toContainText(userName);
}
}
tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
test.describe('Аутентификация', () => {
test('успешный вход', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
await expect(page).toHaveURL('/dashboard');
await dashboardPage.expectLoggedIn('Иван');
});
test('неверный пароль', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'wrongpassword');
await loginPage.expectError('Неверный email или пароль');
await expect(page).toHaveURL('/login');
});
});

Лучшая практика — помечать элементы специальными атрибутами для тестов. Они не зависят от CSS-классов или структуры DOM.

<!-- В Vue-компоненте -->
<template>
<div class="todo-app">
<input
data-testid="todo-input"
v-model="newTodo"
placeholder="Новая задача..."
@keyup.enter="addTodo"
/>
<button data-testid="add-todo-btn" @click="addTodo">
Добавить
</button>
<ul data-testid="todo-list">
<li
v-for="todo in todos"
:key="todo.id"
:data-testid="`todo-item-${todo.id}`"
>
<input
type="checkbox"
:data-testid="`todo-checkbox-${todo.id}`"
:checked="todo.done"
@change="toggleTodo(todo.id)"
/>
<span :data-testid="`todo-text-${todo.id}`">{{ todo.text }}</span>
<button :data-testid="`delete-todo-${todo.id}`" @click="deleteTodo(todo.id)">
</button>
</li>
</ul>
<footer data-testid="todo-footer">
<span data-testid="todo-count">{{ remaining }} задач осталось</span>
</footer>
</div>
</template>
tests/todo.spec.ts
test('управление задачами', async ({ page }) => {
await page.goto('/todos');
// Добавить задачу
await page.getByTestId('todo-input').fill('Купить молоко');
await page.getByTestId('add-todo-btn').click();
// Проверить, что задача добавилась
await expect(page.getByTestId('todo-list')).toContainText('Купить молоко');
// Проверить счётчик
await expect(page.getByTestId('todo-count')).toContainText('1 задач');
// Отметить выполненной
await page.getByTestId('todo-checkbox-1').check();
// Удалить задачу
await page.getByTestId('delete-todo-1').click();
await expect(page.getByTestId('todo-list')).not.toContainText('Купить молоко');
});

tests/form.spec.ts
test('заполнение и отправка формы регистрации', async ({ page }) => {
await page.goto('/register');
// Заполняем поля
await page.getByLabel('Имя').fill('Иван Петров');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Пароль').fill('SecurePass123!');
await page.getByLabel('Подтверждение пароля').fill('SecurePass123!');
// Выбор из select
await page.getByLabel('Страна').selectOption('RU');
// Чекбокс
await page.getByLabel('Согласен с условиями').check();
// Radio button
await page.getByRole('radio', { name: 'Физическое лицо' }).check();
// Загрузка файла
await page.getByLabel('Аватар').setInputFiles('tests/fixtures/avatar.jpg');
// Отправить форму
await page.getByRole('button', { name: 'Зарегистрироваться' }).click();
// Проверить успех
await expect(page.getByText('Регистрация прошла успешно!')).toBeVisible();
await expect(page).toHaveURL('/welcome');
});
test('валидация формы', async ({ page }) => {
await page.goto('/register');
// Отправить пустую форму
await page.getByRole('button', { name: 'Зарегистрироваться' }).click();
// Проверить ошибки валидации
await expect(page.getByText('Имя обязательно')).toBeVisible();
await expect(page.getByText('Введите корректный email')).toBeVisible();
// Проверить aria-invalid
await expect(page.getByLabel('Email')).toHaveAttribute('aria-invalid', 'true');
});

Playwright умеет делать скриншоты и сравнивать их с эталонами — отлично для проверки внешнего вида:

tests/visual.spec.ts
import { test, expect } from '@playwright/test';
test('главная страница выглядит правильно', async ({ page }) => {
await page.goto('/');
// Скриншот всей страницы
await expect(page).toHaveScreenshot('homepage.png');
});
test('модальное окно', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Открыть модалку' }).click();
// Ждём анимацию
await page.waitForTimeout(300);
// Скриншот только модалки
const modal = page.getByRole('dialog');
await expect(modal).toHaveScreenshot('modal.png');
});
test('мобильный вид', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-mobile.png', {
// Допуск 1% пикселей
maxDiffPixelRatio: 0.01,
});
});
Окно терминала
# Обновить эталонные скриншоты
npx playwright test --update-snapshots

tests/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
// Расширяем базовые фикстуры
export const test = base.extend<{
loginPage: LoginPage;
authenticatedPage: void;
}>({
// Фикстура - объект страницы
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
// Фикстура - авторизованная сессия
authenticatedPage: async ({ page }, use) => {
// Логинимся через API (быстрее чем через UI)
await page.request.post('/api/auth/login', {
data: { email: '[email protected]', password: 'password' },
});
await use();
},
});
tests/dashboard.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';
// Хуки для группы тестов
test.describe('Dashboard', () => {
// Выполнится перед каждым тестом в группе
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard');
});
test('показывает статистику', async ({ page, authenticatedPage }) => {
await expect(page.getByTestId('stats-widget')).toBeVisible();
});
});

tests/api-mock.spec.ts
test('показывает данные из API', async ({ page }) => {
// Перехватить API-запрос и вернуть мок
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Иван', role: 'admin' },
{ id: 2, name: 'Мария', role: 'user' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('Иван')).toBeVisible();
await expect(page.getByText('Мария')).toBeVisible();
});
test('обрабатывает ошибку API', async ({ page }) => {
// Симулировать ошибку сервера
await page.route('**/api/users', async (route) => {
await route.fulfill({ status: 500 });
});
await page.goto('/users');
await expect(page.getByTestId('error-message')).toContainText(
'Ошибка загрузки данных'
);
});

Окно терминала
# Запустить все тесты
npx playwright test
# Запустить конкретный файл
npx playwright test tests/auth.spec.ts
# Запустить в конкретном браузере
npx playwright test --project=chromium
# Запустить тесты с совпадением в названии
npx playwright test -g "форма"
# Открыть интерактивный UI
npx playwright test --ui
# Отладка конкретного теста
npx playwright test --debug tests/auth.spec.ts
# HTML-репорт
npx playwright show-report

.github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
env:
CI: true
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

1. Используй роли и метки вместо CSS-селекторов:

// ❌ Плохо — ломается при изменении CSS
page.locator('.btn-primary.submit-form')
// ✅ Хорошо — устойчиво к рефакторингу
page.getByRole('button', { name: 'Отправить' })
page.getByLabel('Email')
page.getByTestId('submit-btn')

2. Избегай жёстких таймаутов:

// ❌ Плохо — ненадёжно
await page.waitForTimeout(2000);
// ✅ Хорошо — ждём конкретного состояния
await page.waitForSelector('[data-testid="loader"]', { state: 'hidden' });
await expect(page.getByText('Загружено')).toBeVisible();

3. Изолируй тесты:

// Каждый тест создаёт свои данные и не зависит от других
test.beforeEach(async ({ page }) => {
// Очистить состояние через API
await page.request.post('/api/test/reset');
});

4. Тестируй доступность:

test('доступность форм', async ({ page }) => {
await page.goto('/contact');
// Проверить наличие меток у полей
await expect(page.getByLabel('Имя')).toBeVisible();
// Проверить keyboard navigation
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email')).toBeFocused();
});

ВозможностьОписание
БраузерыChromium, Firefox, WebKit
МобильныеЭмуляция устройств
СетьПерехват и мок запросов
СкриншотыВизуальное тестирование
ТрейсингЗапись и воспроизведение
ПараллельностьВстроенная поддержка
TypeScriptПолная поддержка
CI/CDGitHub Actions, GitLab CI

E2E-тесты с Playwright — это уверенность в том, что приложение работает как единое целое. Начни с критических пользовательских сценариев: логин, основной флоу, оформление заказа — и постепенно расширяй покрытие. 🎯