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

47. E2E тесты: Cypress/Playwright

Unit-тесты проверяют функции, компонентные тесты — UI. A E2E тесты проверяют всё вместе: как реальный пользователь в реальном браузере. Это самый дорогой, но и самый ценный уровень тестирования 🏆


CypressPlaywright
СкоростьМедленнееБыстрее
ОтладкаЛучше (GUI)Хорошо (Trace)
БраузерыChrome/Firefox/EdgeВсе + WebKit (Safari)
Мобильные устройстваНетДа
Auto-waitДаДа
API тестированиеОграниченноДа
Популярность в AngularВысокаяРастёт
ДокументацияОтличнаяОтличная

Окно терминала
ng add @cypress/schematic
npm install --save-dev cypress
npx cypress open
cypress.config.ts
import { 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 — это класс, инкапсулирующий взаимодействие со страницей. Если меняется UI — правишь только Page Object, а не все тесты:

cypress/pages/login.page.ts
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.ts
export 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;
}
}

Важнейшее правило: никогда не выбирай элементы по 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/support/commands.ts
Cypress.Commands.add('getByTestId', (testId: string) => {
return cy.get(`[data-testid="${testId}"]`);
});
// Типизация для TypeScript
declare global {
namespace Cypress {
interface Chainable {
getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
}
}
}

cypress/e2e/users/users-crud.cy.ts
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', 'Пользователь удалён');
});
});
});

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');
});

Окно терминала
npm init playwright@latest
# или для Angular:
npm install --save-dev @playwright/test
npx playwright install
playwright.config.ts
import { 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,
},
});

e2e/users.spec.ts
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,
});
});
});

.github/workflows/e2e.yml
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 }}