Даже с чистым кодом тесты могут падать с завидной нерегулярностью. Раз в десять прогонов, чаще в CI, чем локально. Не потому что CI какой-то особенный, а потому что там может быть меньше CPU, выше latency между сервисами и куча параллельных процессов. Асинхронные проблемы никуда не деваются и локально, но более мощная машина и быстрая сеть их маскируют. Стоит условиям стать чуть хуже, и тайминги разъезжаются: фронтенд уже отрисовал результат, бэкенд ещё обрабатывает запрос, а транзакция в базу ещё не закоммичена. Тест идёт проверять и не находит там того, что ожидает.

Три темы, которые дают стабильность на больших числах:

  1. Почему waitForTimeout — это не решение, и чем его заменить.

  2. Идемпотентность — страховка от дублей при сетевых сбоях.

  3. Моки: от page.route до Contract Testing и защиты от «лживых моков».

И отдельно будет разобрана гигиена данных, потому что тесты, которые не убирают за собой, рано или поздно убивают окружение.

Хватит угадывать время. Принцип Наблюдателя

Самая распространённая причина флаккинеса выглядит вот так:

Примеры упрощены для наглядности. Фокус на идее, не на архитектуре.

await page.getByText('Оформить заказ').click();
await page.waitForTimeout(5000);
const order = await api.getOrder(id);
expect(order.status).toBe('PAID');

Логика понятна: «бэкенд обычно отвечает за пару секунд, подождём три на всякий случай». Проблема в слове «обычно». В нормальных условиях это работает. В CI под нагрузкой бэкенд ответит за 5001 мс, и тест упадёт. Не потому что что-то сломалось, просто не повезло с таймингом.

Решением будет не угадывать, а спрашивать. Вместо статического ожидания используем expect.poll: он опрашивает систему с заданным интервалом, пока не получит нужный результат или не выйдет таймаут.

await expect.poll(async () => {
  const order = await api.getOrder(id);
  return order.status;
}, {
  message: 'Ожидаем, что статус заказа станет PAID',
  timeout: 30000,
}).toBe('PAID');

expect.poll появился в Playwright 1.25. Если вы на более старой версии, это хороший повод обновиться.

Про интервалы: вручную прописывать intervals: [1000, 2000, 5000] в большинстве случаев избыточно. Playwright сам подбирает разумные паузы между попытками. Лучше задайте общий таймаут через test.setTimeout(60000) для медленных сценариев, не трогайте интервалы без конкретной причины.

Ловушка networkidle

Многие используют waitUntil: 'networkidle' как универсальное ожидание готовности страницы. Идея понятна: ждём, пока сеть успокоится. Но работает это плохо.

Playwright считает сеть «тихой», если в течение 500 мс не было ни одного запроса. Теперь представьте, что на странице висит виджет онлайн-поддержки или счётчик аналитики, который шлёт пинги раз в 400 мс. Тест будет висеть бесконечно и упадёт по глобальному таймауту. А вы будете смотреть в логи и не будете понимать, что вообще произошло.

Правило простое: ждите конкретный элемент или ответ API, а не «тишину в сети».

expect.toPass: когда нужно повторить не проверку, а действие

expect.poll крутит один ассёрт. Но иногда нужно повторить целый блок: нажать кнопку, подождать реакцию UI, проверить результат. Для этого есть expect.toPass:

await expect(async () => {
  await page.getByRole('button', { name: 'Обновить' }).click();
  await expect(page.getByText('Статус: Готов')).toBeVisible();
}).toPass({
  intervals: [1000, 2000, 5000],
  timeout: 15000
});

Здесь интервалы уже имеют смысл, т.к. мы управляем тем, как часто повторяем пользовательское действие, а не внутренний опрос.

Идемпотентность — страховка от сетевого шума

В распределённых системах сеть иногда моргает. Playwright при сбое автоматически повторяет запрос. Но что если первый запрос до бэкенда дошёл, заказ создался, а ответ до теста — нет? При повторной попытке вы получите либо 400 Order already exists, либо, что хуже, два созданных заказа и непонятный статус в следующей проверке.

Стандартное решение — Idempotency-Key: уникальный заголовок, по которому бэкенд понимает, что это повтор уже обработанного запроса, и возвращает прежний результат вместо создания нового.

Если в тесте несколько POST-запросов, один статичный ключ не подойдёт. Бэкенд решит, что второй запрос (создать заказ) — это дубль первого (создать пользователя). Правильнее генерировать ключ из контекста самого запроса:

// api/infrastructure/Idempotency.ts
import { createHash } from 'crypto';

export function generateIdempotencyKey(method: string, url: string, data: any): string {
  const payload = `${method}:${url}:${JSON.stringify(data)}`;
  return createHash('sha256').update(payload).digest('hex').slice(0, 16);
}

// api/clients/BaseApiClient.ts
export abstract class BaseApiClient {
  protected async post(url: string, data?: any) {
    const key = generateIdempotencyKey('POST', url, data);
    return await this.request.post(url, {
      data,
      headers: { 'X-Idempotency-Key': key }
    });
  }
}

Теперь каждый уникальный запрос получает уникальный ключ автоматически. Один сетевой сбой в CI больше не приводит к дублям и ложным падениям.

Эволюция моков: от page.route до контрактов

Когда говорят «замокать запрос», обычно имеют в виду page.route. Это нормально для изоляции UI, но есть ситуации, когда этого недостаточно.

Уровень 1: Native Mocks (page.route)

Подходит, когда нужно проверить поведение фронта в изоляции. Например, как UI реагирует на ошибку 500 или на пустой список.

await page.route('**/api/orders', route => {
  route.fulfill({
    status: 500,
    body: JSON.stringify({ error: 'Internal Server Error' })
  });
});

await page.goto('/orders');
await expect(page.getByText('Что-то пошло не так')).toBeVisible();

Важный нюанс: page.route перехватывает трафик внутри браузера. Если ваш тест делает прямые API-вызовы через фикстуру request (то есть server-side, минуя браузер) — page.route их не увидит. Для таких запросов нужны отдельные врапперы или моки на уровне сети.

Уровень 2: Infra Mocks (WireMock / Castlemock)

Нужны, когда бэкенд в процессе теста обращается к внешним сервисам: платёжной системе, SMS-шлюзу, курьерской службе. Тест не должен зависеть от их аптайма.

Просто поднимите WireMock в docker-compose рядом с тестовым окружением:

# docker-compose.yml
services:
  wiremock:
    image: wiremock/wiremock:3.3.1
    ports:
      - "8080:8080"
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings
// wiremock/mappings/payment.json
{
  "request": {
    "method": "POST",
    "url": "/v1/payments"
  },
  "response": {
    "status": 200,
    "jsonBody": {
      "payment_id": "pay_test_123",
      "status": "succeeded"
    }
  }
}

Теперь ваш бэкенд в тестах ходит не в реальный эквайринг, а в локальный WireMock. Тест стабилен вне зависимости от внешнего мира.

Уровень 3: Contract Testing: защита от лживых моков

Главная проблема с моками, что они могут врать. Тесты зелёные, в проде всё упало. Потому что бэкенд три недели назад переименовал order_id в orderId, а мок никто не обновил.

Это называется Lying Mock, и от него не спасает ни аккуратный код, ни код-ревью.

Спасает Consumer-Driven Contract Testing (CDC). Идея простая: тест (consumer) явно описывает, какой запрос он отправляет и какой ответ ожидает. Это описание фиксируется как контракт — JSON-файл. Бэкенд (provider) берёт этот контракт и проверяет на своём реальном коде, что он ему соответствует.

Выглядит это примерно так (на примере Pact):

// consumer: tests/contracts/order.pact.spec.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'frontend-tests',
  provider: 'order-service',
  dir: './pacts',
});

describe('Order API contract', () => {
  it('возвращает заказ по id', async () => {
    await provider
      .given('заказ ord_123 существует')
      .uponReceiving('GET /orders/ord_123')
      .withRequest({ method: 'GET', path: '/orders/ord_123' })
      .willRespondWith({
        status: 200,
        body: {
          order_id: MatchersV3.string('ord_123'),
          status: MatchersV3.string('PAID'),
        },
      })
      .executeTest(async (mockServer) => {
        const order = await fetchOrder(mockServer.url, 'ord_123');
        expect(order.status).toBe('PAID');
      });
  });
});

После прогона в папке ./pacts появится JSON-контракт. Его публикуют в Pact Broker (или просто кладут в репозиторий бэкенда), и при каждой сборке бэкенд прогоняет его против своего кода:

# на стороне бэкенда (provider verification)
pact-provider-verifier \
  --provider-base-url http://localhost:8080 \
  --pact-broker-url https://your-pact-broker \
  --provider order-service

Если бэкенд переименует поле, верификация упадёт прямо в его пайплайне, до мержа в main.

Гигиена данных

Тесты, которые создают пользователей, заказы и транзакции, должны за собой убирать. Иначе тестовая база превращается в свалку, которая через месяц начинает влиять на производительность и результаты тестов.

Стандартный подход afterAll(() => api.deleteUser(id)) — ненадёжен. Если тест упал посередине или свалился сам раннер, колбэк не выполнится, и мусор останется.

Вот три подхода, которые работают надёжнее:

TTL (Time To Live)

Добавьте в тестовые сущности поле expires_at и заполняйте его при создании:

// при создании тестового пользователя
await api.createUser({
  email: `test_${Date.now()}@example.com`,
  expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // +24 часа
});

В PostgreSQL фоновая очистка через pg_cron:

-- удаляем просроченные тестовые данные каждый час
SELECT cron.schedule('cleanup-test-data', '0 * * * *', $$
  DELETE FROM users WHERE expires_at < NOW() AND is_test = true;
  DELETE FROM orders WHERE expires_at < NOW() AND is_test = true;
$$);

В MongoDB и Redis TTL поддерживается нативно через индекс на поле expires_at и expireAfterSeconds: 0.

Cleanup Queue

При создании каждой сущности кладём её ID в очередь:

// базовый клиент
protected async post(url: string, data?: any) {
  const response = await this.request.post(url, { data });
  const body = await response.json();
  
  if (body.id) {
    cleanupQueue.push({ url, id: body.id });
  }
  return response;
}

// в глобальном teardown
for (const item of cleanupQueue) {
  await api.delete(`${item.url}/${item.id}`);
}

Даже если отдельный тест упал, глобальный teardown пройдёт по всей очереди и подчистит за ним.

Партиционирование (для высоконагруженных проектов)

Если тестов много и они работают постоянно, даже очередь с TTL может не справляться. Тогда имеет смысл партиционировать тестовые таблицы по дате:

CREATE TABLE orders_test (
  id UUID,
  created_at TIMESTAMP,
  ...
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_test_2024_06 
  PARTITION OF orders_test
  FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');

Дроп старой партиции — мгновенная операция, в отличие от DELETE по миллиону строк.


Стабильность тестов на масштабе зависит не только от того, как написан тест, но и от того, как устроены бэкенд (идемпотентность, контракты) и инфраструктура (очистка данных). Код теста — верхушка айсберга.

В следующей части зафиксируем правила в ESLint: настроим автоматический контроль архитектуры и составим список запрещённых приёмов, за которые в нормальной команде бьют по рукам.


Полезные ссылки