Статус 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: запросы, мутации и обработка ошибок