Статус 200 с неправильными названиями полей это всё равно баг, и большинство базовых API тест-сьютов его пропускает потому что проверяет только статус. В масштабе надёжность сьюта обеспечивают: аутентификация через фикстуры (инжектируй токены, не повторяй код логина в каждом тесте), валидация схемы по реальным типам полей и обязательным полям, и полное CRUD-покрытие с проверкой что удаление реальное, а не просто ответ 204. Здесь разобран каждый паттерн с полными примерами на Playwright.
Фикстура request vs page.request
Два способа делать API-вызовы в Playwright:
// 1. Фикстура request: без браузера, чистый API
test('API test', async ({ request }) => {
const response = await request.get('/api/users');
});
// 2. page.request: разделяет куки с браузерной страницей
test('API after browser login', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
// Этот запрос использует сессию браузера (куки установленные при логине)
const response = await page.request.get('/api/user/profile');
});Используй фикстуру request для чистых API-тестов. Используй page.request когда нужна аутентифицированная сессия браузера.
Паттерны аутентификации
Аутентификация по токену
test.describe('Authenticated API tests', () => {
let authToken: string;
test.beforeAll(async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: 'admin@test.com',
password: 'AdminPass1',
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
authToken = body.token;
});
test('get users list', async ({ request }) => {
const response = await request.get('/api/users', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(200);
});
test('create user', async ({ request }) => {
const response = await request.post('/api/users', {
data: { email: 'new@test.com', password: 'Pass1', role: 'member' },
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(201);
});
});Аутентификация через фикстуры
Лучше чем beforeAll: фикстуры чище и управляют teardown:
// fixtures/index.ts
export const test = base.extend<{ authToken: string }>({
authToken: async ({ request }, use) => {
const response = await request.post('/api/auth/login', {
data: { email: 'admin@test.com', password: 'AdminPass1' },
});
const { token } = await response.json();
await use(token);
// Teardown не нужен для токенов: они истекают или отзываются естественным образом
},
});Валидация схемы
Проверяй структуру ответа, а не только коды статуса:
import Ajv from 'ajv';
const ajv = new Ajv();
const userSchema = {
type: 'object',
required: ['id', 'email', 'role', 'createdAt'],
properties: {
id: { type: 'number' },
email: { type: 'string', format: 'email' },
role: { type: 'string', enum: ['admin', 'member', 'viewer'] },
createdAt: { type: 'string' },
name: { type: 'string' },
},
additionalProperties: false,
};
test('user response matches schema', async ({ request, authToken }) => {
const response = await request.get('/api/users/1', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
const validate = ajv.compile(userSchema);
const valid = validate(body);
if (!valid) {
throw new Error(`Schema validation failed: ${JSON.stringify(validate.errors)}`);
}
expect(valid).toBe(true);
});Без библиотеки: ручные проверки:
function validateUserSchema(body: unknown) {
const user = body as Record<string, unknown>;
expect(typeof user.id).toBe('number');
expect(typeof user.email).toBe('string');
expect(user.email).toMatch(/@/);
expect(['admin', 'member', 'viewer']).toContain(user.role);
expect(typeof user.createdAt).toBe('string');
expect(() => new Date(user.createdAt as string)).not.toThrow();
}
test('user schema validation', async ({ request, authToken }) => {
const response = await request.get('/api/users/1', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
validateUserSchema(body);
});CRUD-жизненный цикл теста
Тестируй полный цикл: создание, чтение, обновление, удаление:
test.describe('User CRUD', () => {
const authHeaders = () => ({
Authorization: `Bearer ${authToken}`,
});
let createdUserId: number;
const userData = {
email: `crud_test_${Date.now()}@test.com`,
password: 'ValidPass1',
name: 'CRUD Test User',
role: 'member',
};
test('CREATE: POST /api/users', async ({ request }) => {
const response = await request.post('/api/users', {
data: userData,
headers: authHeaders(),
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.email).toBe(userData.email);
expect(body.id).toBeDefined();
expect(body.password).toBeUndefined(); // Пароль никогда не должен возвращаться
createdUserId = body.id;
});
test('READ: GET /api/users/:id', async ({ request }) => {
const response = await request.get(`/api/users/${createdUserId}`, {
headers: authHeaders(),
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.id).toBe(createdUserId);
expect(body.email).toBe(userData.email);
});
test('UPDATE: PUT /api/users/:id', async ({ request }) => {
const response = await request.put(`/api/users/${createdUserId}`, {
data: { name: 'Updated Name' },
headers: authHeaders(),
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.name).toBe('Updated Name');
expect(body.email).toBe(userData.email); // Email не изменился
});
test('DELETE: DELETE /api/users/:id', async ({ request }) => {
const response = await request.delete(`/api/users/${createdUserId}`, {
headers: authHeaders(),
});
expect(response.status()).toBe(204); // Нет содержимого
});
test('VERIFY DELETED: GET returns 404', async ({ request }) => {
const response = await request.get(`/api/users/${createdUserId}`, {
headers: authHeaders(),
});
expect(response.status()).toBe(404);
});
});Тестирование ошибочных кейсов
Не ограничивайся только happy path:
test.describe('Error handling', () => {
test('401: missing auth token', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.status()).toBe(401);
const body = await response.json();
expect(body.error).toBeDefined();
});
test('403: insufficient permissions', async ({ request, memberToken }) => {
// Обычный пользователь пытается получить доступ к admin-эндпоинту
const response = await request.get('/api/admin/logs', {
headers: { Authorization: `Bearer ${memberToken}` },
});
expect(response.status()).toBe(403);
});
test('404: non-existent resource', async ({ request, authToken }) => {
const response = await request.get('/api/users/999999', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(404);
const body = await response.json();
expect(body.message).toContain('not found');
});
test('400: invalid request body', async ({ request, authToken }) => {
const response = await request.post('/api/users', {
data: { email: 'not-an-email', password: '123' }, // Невалидные данные
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.errors).toBeDefined();
expect(body.errors).toBeInstanceOf(Array);
});
test('409: duplicate resource', async ({ request, authToken }) => {
const userData = { email: 'duplicate@test.com', password: 'ValidPass1' };
// Создаём первый
await request.post('/api/users', {
data: userData,
headers: { Authorization: `Bearer ${authToken}` },
});
// Создаём дубликат
const response = await request.post('/api/users', {
data: userData,
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(409);
});
});Тестирование пагинации
test.describe('Pagination', () => {
test('returns correct page size', async ({ request, authToken }) => {
const response = await request.get('/api/users?page=1&limit=10', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
expect(body.data).toHaveLength(10);
expect(body.page).toBe(1);
expect(body.limit).toBe(10);
expect(typeof body.total).toBe('number');
expect(typeof body.totalPages).toBe('number');
});
test('last page has fewer items', async ({ request, authToken }) => {
// Сначала получаем общее количество
const firstResp = await request.get('/api/users?page=1&limit=10', {
headers: { Authorization: `Bearer ${authToken}` },
});
const { total, totalPages } = await firstResp.json();
// Получаем последнюю страницу
const lastResp = await request.get(`/api/users?page=${totalPages}&limit=10`, {
headers: { Authorization: `Bearer ${authToken}` },
});
const lastPage = await lastResp.json();
const expectedLastPageCount = total % 10 || 10;
expect(lastPage.data).toHaveLength(expectedLastPageCount);
});
test('page beyond total returns empty', async ({ request, authToken }) => {
const response = await request.get('/api/users?page=9999&limit=10', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
expect(body.data).toHaveLength(0);
});
});Комбинирование API-настройки с UI-тестами
Самый мощный паттерн: используй API для настройки состояния, затем проверяй через UI:
test('newly created user appears in admin panel', async ({ page, request, authToken }) => {
// 1. Создаём пользователя через API (быстро, надёжно)
const userData = {
email: `new_${Date.now()}@test.com`,
password: 'ValidPass1',
name: 'New API User',
role: 'member',
};
const createResp = await request.post('/api/users', {
data: userData,
headers: { Authorization: `Bearer ${authToken}` },
});
const { id } = await createResp.json();
// 2. Проверяем через UI (что реально видят пользователи)
await page.goto('/login');
await page.fill('[data-testid="email"]', 'admin@test.com');
await page.fill('[data-testid="password"]', 'AdminPass1');
await page.click('[data-testid="submit"]');
await page.goto('/admin/users');
await page.fill('[data-testid="search"]', userData.email);
await expect(page.getByTestId('user-row').first()).toContainText(userData.name);
// 3. Очищаем через API (быстро, надёжно)
await request.delete(`/api/users/${id}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
});Ассёрты времени ответа
test('list endpoint responds within SLA', async ({ request, authToken }) => {
const startTime = Date.now();
const response = await request.get('/api/users?limit=100', {
headers: { Authorization: `Bearer ${authToken}` },
});
const duration = Date.now() - startTime;
expect(response.status()).toBe(200);
expect(duration).toBeLessThan(500); // SLA 500мс
});Итог
Паттерны которые держат в масштабе:
1. Используй фикстуры для токенов аутентификации: инжектируй, не повторяй код логина
2. Валидируй схему: статус 200 с неправильными полями это всё равно баг
3. Тестируй CRUD по порядку: создание, чтение, обновление, удаление как жизненный цикл
4. Тестируй ошибочные кейсы: 401, 403, 404, 400, 409 не менее важны чем 200
5. Используй API для настройки/очистки в UI-тестах: быстрее и надёжнее чем через UI
6. Изолируй данные: уникальный email на тест, удаление после
Комбинация API-тестов (быстрые, тщательные) и UI-тестов (реалистичные пользовательские пути) даёт наилучшее покрытие с максимальной уверенностью.
→ See also: API-тестирование с Playwright APIRequestContext (без Postman) | Авторизация в API-тестах: API-ключи, Bearer-токены, OAuth2, JWT | Тестирование GraphQL API с Playwright: запросы, мутации и обработка ошибок