24. E2E тестирование (Playwright)
🎭 Урок 25: E2E-тестирование с Playwright
Заголовок раздела «🎭 Урок 25: E2E-тестирование с Playwright»End-to-End (E2E) тестирование проверяет твоё приложение глазами реального пользователя — открывает браузер, кликает кнопки, заполняет формы и проверяет, что всё работает от начала до конца. Playwright — современный инструмент от Microsoft, который делает это быстро, надёжно и с кучей полезных фишек. 🚀
🤔 Зачем E2E-тесты?
Заголовок раздела «🤔 Зачем E2E-тесты?»Unit-тесты проверяют отдельные функции, интеграционные — связи между компонентами, а E2E-тесты — весь пользовательский сценарий целиком:
Unit тест: [функция] → ✅Integration: [компонент A] + [компонент B] → ✅E2E тест: [браузер] → открыть → кликнуть → заполнить → отправить → ✅Это последний рубеж защиты. Именно E2E-тесты ловят баги, которые возникают только когда всё работает вместе.
⚙️ Установка Playwright
Заголовок раздела «⚙️ Установка Playwright»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 ← подробный пример📋 Конфигурация playwright.config.ts
Заголовок раздела «📋 Конфигурация playwright.config.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, },});🧪 Первый тест
Заголовок раздела «🧪 Первый тест»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 (POM)
Заголовок раздела «🏗️ Page Object Model (POM)»Page Object Model — паттерн, который делает тесты читаемыми и поддерживаемыми. Вместо прямых селекторов в тестах — объекты, инкапсулирующие взаимодействие со страницей.
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); }}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); }}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 expect(page).toHaveURL('/dashboard'); await dashboardPage.expectLoggedIn('Иван'); });
test('неверный пароль', async ({ page }) => { const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.expectError('Неверный email или пароль'); await expect(page).toHaveURL('/login'); });});🎯 Атрибуты data-testid
Заголовок раздела «🎯 Атрибуты data-testid»Лучшая практика — помечать элементы специальными атрибутами для тестов. Они не зависят от 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>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('Купить молоко');});📝 Тестирование форм
Заголовок раздела «📝 Тестирование форм»test('заполнение и отправка формы регистрации', async ({ page }) => { await page.goto('/register');
// Заполняем поля await page.getByLabel('Имя').fill('Иван Петров'); 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 умеет делать скриншоты и сравнивать их с эталонами — отлично для проверки внешнего вида:
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🔧 Фикстуры и хуки
Заголовок раздела «🔧 Фикстуры и хуки»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', { }); await use(); },});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(); });});🌐 API-запросы и моки
Заголовок раздела «🌐 API-запросы и моки»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 "форма"
# Открыть интерактивный UInpx playwright test --ui
# Отладка конкретного тестаnpx playwright test --debug tests/auth.spec.ts
# HTML-репортnpx playwright show-report🤖 CI/CD интеграция (GitHub Actions)
Заголовок раздела «🤖 CI/CD интеграция (GitHub Actions)»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-селекторов:
// ❌ Плохо — ломается при изменении CSSpage.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();});📊 Итог: что умеет Playwright
Заголовок раздела «📊 Итог: что умеет Playwright»| Возможность | Описание |
|---|---|
| Браузеры | Chromium, Firefox, WebKit |
| Мобильные | Эмуляция устройств |
| Сеть | Перехват и мок запросов |
| Скриншоты | Визуальное тестирование |
| Трейсинг | Запись и воспроизведение |
| Параллельность | Встроенная поддержка |
| TypeScript | Полная поддержка |
| CI/CD | GitHub Actions, GitLab CI |
E2E-тесты с Playwright — это уверенность в том, что приложение работает как единое целое. Начни с критических пользовательских сценариев: логин, основной флоу, оформление заказа — и постепенно расширяй покрытие. 🎯