47. E2E тесты: Cypress/Playwright
🎭 E2E тестирование: Cypress и Playwright
Заголовок раздела «🎭 E2E тестирование: Cypress и Playwright»Unit-тесты проверяют функции, компонентные тесты — UI. A E2E тесты проверяют всё вместе: как реальный пользователь в реальном браузере. Это самый дорогой, но и самый ценный уровень тестирования 🏆
Cypress vs Playwright для Angular
Заголовок раздела «Cypress vs Playwright для Angular»| Cypress | Playwright | |
|---|---|---|
| Скорость | Медленнее | Быстрее |
| Отладка | Лучше (GUI) | Хорошо (Trace) |
| Браузеры | Chrome/Firefox/Edge | Все + WebKit (Safari) |
| Мобильные устройства | Нет | Да |
| Auto-wait | Да | Да |
| API тестирование | Ограниченно | Да |
| Популярность в Angular | Высокая | Растёт |
| Документация | Отличная | Отличная |
Cypress — установка и конфигурация
Заголовок раздела «Cypress — установка и конфигурация»ng add @cypress/schematicnpm install --save-dev cypressnpx cypress openimport { defineConfig } from 'cypress';
export default defineConfig({ e2e: { baseUrl: 'http://localhost:4200', specPattern: 'cypress/e2e/**/*.cy.ts', supportFile: 'cypress/support/e2e.ts', video: false, screenshotOnRunFailure: true, viewportWidth: 1280, viewportHeight: 720, defaultCommandTimeout: 10000, retries: { runMode: 2, // В CI: 2 попытки openMode: 0, // В GUI: без повторов }, }, component: { devServer: { framework: 'angular', bundler: 'webpack', }, },});Page Object Pattern
Заголовок раздела «Page Object Pattern»Page Object — это класс, инкапсулирующий взаимодействие со страницей. Если меняется UI — правишь только Page Object, а не все тесты:
export class LoginPage { visit() { cy.visit('/login'); }
// Используем data-testid — не зависит от классов и текста fillEmail(email: string) { cy.getByTestId('email-input').clear().type(email); return this; // Fluent API }
fillPassword(password: string) { cy.getByTestId('password-input').clear().type(password); return this; }
submit() { cy.getByTestId('submit-btn').click(); return this; }
expectError(message: string) { cy.getByTestId('error-message').should('contain', message); return this; }
expectRedirectTo(url: string) { cy.url().should('include', url); return this; }}
// cypress/pages/users.page.tsexport class UsersPage { visit() { cy.visit('/users'); }
searchFor(query: string) { cy.getByTestId('search-input').type(query); return this; }
getUserRows() { return cy.getByTestId('user-row'); }
clickDeleteUser(name: string) { cy.contains('[data-testid="user-row"]', name) .find('[data-testid="delete-btn"]') .click(); return this; }
confirmDelete() { cy.getByTestId('confirm-dialog').within(() => { cy.getByTestId('confirm-btn').click(); }); return this; }}data-testid конвенция
Заголовок раздела «data-testid конвенция»Важнейшее правило: никогда не выбирай элементы по CSS-классам или тексту в тестах. Используй data-testid:
<!-- ✅ Правильно — стабильные селекторы --><button data-testid="submit-btn" type="submit">Войти</button><input data-testid="email-input" type="email" /><div data-testid="error-message" *ngIf="error">{{ error }}</div><tr data-testid="user-row" *ngFor="let user of users">...</tr>
<!-- ❌ Неправильно — хрупкие селекторы --><button class="btn btn-primary">Войти</button><!-- cy.get('.btn.btn-primary') — ломается при рефакторинге -->Добавь кастомную команду для удобства:
Cypress.Commands.add('getByTestId', (testId: string) => { return cy.get(`[data-testid="${testId}"]`);});
// Типизация для TypeScriptdeclare global { namespace Cypress { interface Chainable { getByTestId(testId: string): Chainable<JQuery<HTMLElement>>; } }}Полный пример Cypress теста
Заголовок раздела «Полный пример Cypress теста»import { LoginPage } from '../pages/login.page';import { UsersPage } from '../pages/users.page';
describe('Users CRUD', () => { const login = new LoginPage(); const users = new UsersPage();
beforeEach(() => { // Логинимся через API (быстрее чем через UI каждый раз) cy.request('POST', '/api/auth/login', { password: 'password' }).then(response => { localStorage.setItem('access_token', response.body.token); });
users.visit(); });
it('should display users list', () => { users.getUserRows().should('have.length.greaterThan', 0); });
it('should filter users by search query', () => { cy.intercept('GET', '/api/users?q=Яша').as('searchRequest');
users.searchFor('Яша');
cy.wait('@searchRequest'); users.getUserRows().should('have.length', 1); cy.getByTestId('user-row').should('contain', 'Яша'); });
it('should delete user with confirmation', () => { users.getUserRows().its('length').then(initialCount => { users .clickDeleteUser('Боб') .confirmDelete();
users.getUserRows().should('have.length', initialCount - 1); cy.getByTestId('notification').should('contain', 'Пользователь удалён'); }); });});Перехват HTTP запросов (cy.intercept)
Заголовок раздела «Перехват HTTP запросов (cy.intercept)»it('should show loading state', () => { // Задерживаем ответ для проверки лоадера cy.intercept('GET', '/api/users', req => { req.reply(res => { res.setDelay(1000); }); }).as('loadUsers');
cy.visit('/users');
// Лоадер должен быть виден пока идёт запрос cy.getByTestId('loading-spinner').should('be.visible');
cy.wait('@loadUsers');
cy.getByTestId('loading-spinner').should('not.exist');});
it('should handle API error gracefully', () => { cy.intercept('GET', '/api/users', { statusCode: 500, body: { message: 'Internal Server Error' } }).as('failedRequest');
cy.visit('/users'); cy.wait('@failedRequest');
cy.getByTestId('error-message').should('be.visible'); cy.getByTestId('retry-button').should('be.visible');});Playwright — установка
Заголовок раздела «Playwright — установка»npm init playwright@latest# или для Angular:npm install --save-dev @playwright/testnpx playwright installimport { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: './e2e', timeout: 30_000, retries: process.env.CI ? 2 : 0, use: { baseURL: 'http://localhost:4200', trace: 'on-first-retry', // Записывает трейс при ошибке screenshot: 'only-on-failure', video: 'off', }, projects: [ { name: 'chromium', use: devices['Desktop Chrome'] }, { name: 'firefox', use: devices['Desktop Firefox'] }, { name: 'mobile', use: devices['Pixel 5'] }, ], webServer: { command: 'ng serve', url: 'http://localhost:4200', reuseExistingServer: !process.env.CI, },});Playwright тест
Заголовок раздела «Playwright тест»import { test, expect } from '@playwright/test';
test.describe('Users Page', () => { test.beforeEach(async ({ page }) => { // Устанавливаем токен напрямую await page.context().addCookies([{ name: 'access_token', value: 'test-token', domain: 'localhost', path: '/', }]);
await page.goto('/users'); });
test('should search users', async ({ page }) => { const searchInput = page.getByTestId('search-input'); await searchInput.fill('Яша');
await expect(page.getByTestId('user-row')).toHaveCount(1); await expect(page.getByTestId('user-row').first()).toContainText('Яша'); });
test('should show visual regression snapshot', async ({ page }) => { await expect(page).toHaveScreenshot('users-page.png', { maxDiffPixels: 100, }); });});CI интеграция (GitHub Actions)
Заголовок раздела «CI интеграция (GitHub Actions)»name: E2E Tests
on: [push, pull_request]
jobs: cypress: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: { node-version: '20' }
- run: npm ci
- name: Build Angular app run: npm run build
- name: Run Cypress E2E uses: cypress-io/github-action@v6 with: start: npx serve -s dist wait-on: 'http://localhost:3000' browser: chrome record: true env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}