Сервис формирует CSV‑отчёт. Запрос POST /reports сразу возвращает 202 Accepted, автотест проверяет код ответа и все вроде бы ок, но через несколько дней выясняется, что часть отчётов не создаётся, потому что HTTP‑обработчик принимает запрос нормально, но фоновый обработчик падает уже после ответа клиенту.

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

Соберём такой тест для API с polling, то есть периодическим опросом статуса.

Пример будет на JavaScript и обычном fetch, подход не зависит от фреймворка.

Что именно подтвердил сервер

Согласно RFC 9110, 202 Accepted означает, что запрос принят для обработки, но обработка ещё не завершена. В итоге она может закончиться успешно, завершиться ошибкой или вообще не быть выполнена.

Поэтому такая проверка доказывает только ответ начального эндпоинта:

const response = await fetch(`${apiUrl}/reports`, {
  method: "POST",
  body: JSON.stringify(requestBody)
});

assert.equal(response.status, 202);

Проверка response.ok доказывает ещё меньше: для fetch любой код от 200 до 299 считается успешным.

HTTP не пришлёт по тому же запросу второй статус, когда фоновая работа завершится. Для результата нужен отдельный эндпоинт состояния или другой канал. Здесь рассматриваем polling.

Единой схемы дальнейшего опроса нет. Один API возвращает 200 OK и состояние в теле, другой отвечает 202 до завершения, а затем возвращает 200 или 303 See Other. Google и Yandex используют объект длительной операции с признаком done. Тест должен опираться на контракт конкретного API, а не на предполагаемую универсальную последовательность кодов.

Сначала фиксируем контракт операции

Пусть POST /reports принимает период отчёта и заголовок Idempotency-Key. Сервер проверяет запрос, регистрирует операцию и отвечает:

HTTP/1.1 202 Accepted
Location: /operations/op-8421
Retry-After: 2

{
  "operationId": "op-8421",
  "status": "queued"
}

В нашем примере GET /operations/op-8421 возвращает 200 OK и одно из состояний: queued, running, succeeded, failed или canceled. Последние три терминальные. При succeeded в ответе появляется ссылка на готовый отчёт, при failed возвращается код ошибки.

До автоматизации нужно определить следующее.

Требование

Зачем оно тесту

После какого шага возвращается 202

Код не говорит, успела ли система надёжно сохранить задание

Где получать состояние

Нужен Location, ID в теле или другой однозначный указатель

Какие состояния терминальные

Без этого polling может закончиться слишком рано или не закончиться вовсе

Как часто и как долго опрашивать

Нужны интервал и общий предел ожидания

Когда доступен итоговый ресурс

Нужно понимать, что именно обещает succeeded

Можно ли повторить исходный запрос

После сетевого сбоя клиент может не знать, был ли POST принят

Особенно важна граница приёма. В нашем контракте 202 означает, что операция зарегистрирована и система взяла ответственность довести её до терминального состояния. Если сервер отвечает раньше, после перезапуска возможен потерянный отчёт при формально корректном 202. Без явного требования такое поведение нельзя однозначно назвать дефектом.

Ожидаем состояние, а не заданное число секунд

Одиночная пауза sleep(5000) одновременно нестабильна и медленна. Если операция иногда занимает шесть секунд, тест падает. Если обычно она завершается за 300 миллисекунд, почти всё время прогона уходит на ожидание.

Вместо этого нужен waiter, то есть функция, которая опрашивает состояние до терминального значения и ограничивает общее время ожидания.

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

function parseRetryAfter(value) {
  if (!value) return null;

  const seconds = Number(value);
  if (Number.isFinite(seconds) && seconds >= 0) {
    return seconds * 1000;
  }

  const date = Date.parse(value);
  return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}

async function waitForOperation(operationUrl, options = {}) {
  const timeoutMs = options.timeoutMs ?? 60_000;
  const defaultDelayMs = options.defaultDelayMs ?? 500;
  const deadline = Date.now() + timeoutMs;
  const history = [];
  let lastOperation = null;
  let delayMs = options.initialDelayMs ?? 0;

  while (Date.now() < deadline) {
    await sleep(Math.min(delayMs, Math.max(0, deadline - Date.now())));
    if (Date.now() >= deadline) break;

    const response = await fetch(operationUrl);
    if (!response.ok) {
      throw new Error(`Status request failed: HTTP ${response.status}`);
    }

    const operation = await response.json();
    lastOperation = operation;
    const status = operation.status;
    history.push({ at: new Date().toISOString(), status });

    if (status === "succeeded") return operation;

    if (status === "failed" || status === "canceled") {
      throw new Error(
        `Operation ${status}: ${JSON.stringify(operation.error ?? {})}`
      );
    }

    if (status !== "queued" && status !== "running") {
      throw new Error(`Unknown operation status: ${status}`);
    }

    delayMs =
      parseRetryAfter(response.headers.get("retry-after"))
      ?? defaultDelayMs;
  }

  throw new Error(
    `Operation timeout. Last response: ${JSON.stringify(lastOperation)}. `
    + `History: ${JSON.stringify(history)}`
  );
}

По RFC 9110, Retry-After может содержать число секунд или HTTP‑date. Но его применение вместе с 202 должно быть описано конкретным API. Если сервис разрешает только целое число секунд, тесту достаточно поддержать этот вариант.

Перед вызовом waiter тест проверяет 202, извлекает Location и преобразует его в абсолютный URL. Значение Retry-After из первого ответа можно передать как initialDelayMs, чтобы не отправлять запрос статуса раньше рекомендованного времени.

Тест не требует увидеть последовательность queued, затем running, затем succeeded. Быстрая операция может завершиться между двумя запросами.

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

Succeeded ещё не означает правильный результат

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

const result = await fetch(
  new URL(operation.result.url, apiUrl)
);

assert.equal(result.status, 200);
assert.match(
  result.headers.get("content-type") ?? "",
  /^text\/csv(?:;|$)/
);

const csv = await result.text();
assert.match(csv, /order-1001/);
assert.doesNotMatch(csv, /order-1002/);

Проверка только наличия файла пропустит неверный период, отсутствующие строки, дубликаты или данные другого пользователя.

Нужно также определить момент фиксации данных. Отчёт может строиться по состоянию на момент запуска или включать изменения, появившиеся во время обработки. Без такого требования ожидаемое содержимое будет зависеть от скорости фонового обработчика.

  • Если операция уже имеет статус succeeded, а ссылка на файл отвечает 404, не стоит молча добавлять ещё один polling. Сначала нужно проверить контракт.

  • Если succeeded обещает готовый ресурс, перед нами дефект.

  • Если публикация результата выполняется отдельно, API должно описать это промежуточное состояние или допустимую задержку.

Разделяем ошибку операции и ошибку polling

Эндпоинт состояния может вернуть 200 OK, а в теле сообщить status: failed и код SOURCE_UNAVAILABLE. На уровне HTTP запрос выполнен корректно, но сама операция завершилась ошибкой. Поэтому waiter анализирует тело ответа и не ограничивается response.ok. Формат ошибки может быть собственным или основываться на RFC 9457 Problem Details.

Возможна обратная ситуация. Операция продолжает выполняться, но запрос состояния получает 429, 502 или разрыв соединения. Это ошибка polling, а не терминальный failed. Повторять ли запрос, зависит от контракта и настроек HTTP‑клиента. 401, 403 и неизвестный 404 обычно должны сразу завершать тест.

Для негативного сценария недостаточно отправить невалидное тело. Такой запрос обычно отклоняется синхронно с 4xx. Чтобы проверить отложенный failed, нужен сбой после 202: управляемая тестовая зависимость, фикстура или внедрение отказа на стенде. Проверяем, что операция дошла до failed, вернула документированный код и не оставила недопустимый частичный результат.

При зависании waiter должен завершиться по общему пределу ожидания и вывести operationId, последний ответ и историю состояний. Без этих данных обычный timeout after 60 seconds почти бесполезен.

Проверяем повтор исходного запроса

Сервер мог принять POST, но соединение оборвалось до получения 202. Клиент не знает, безопасно ли повторить запрос: первый вызов мог не дойти до сервера или уже запустить отчёт.

Если API поддерживает Idempotency-Key, повтор обрабатывается по его правилам. Например, Yandex Cloud возвращает объект уже созданной операции. Другой провайдер может вернуть первый ответ или код конфликта. Само название заголовка не гарантирует конкретное поведение.

Для нашего контракта нужны четыре проверки:

  • два запроса с одинаковым ключом и телом относятся к одному operationId;

  • одновременные запросы с одинаковым ключом не создают две операции;

  • тот же ключ с другим телом отклоняется документированным 4xx;

  • в результате создан один отчёт и один набор побочных эффектов.

Последний пункт важнее совпадения ответов. Дедупликация запросов и однократность бизнес‑эффекта находятся на разных уровнях. API может вернуть один operationId, но фоновый обработчик из‑за внутреннего повтора выполнит действие дважды.

Если идемпотентность не обещана, вспомогательная функция не должна незаметно повторять исходный POST. Новый запуск способен создать второй результат.

Как не получить нестабильный тест в CI

Используйте небольшой детерминированный набор данных. Не проверяйте точную длительность и не требуйте увидеть каждое промежуточное состояние. Для функционального теста задайте timeout с запасом, а проверку SLA вынесите отдельно.

Интервал опроса берите из Retry-After или документации. Для временных ошибок подходят backoff и общий лимит времени, но только для запросов, которые безопасно повторять. Связь повторов с идемпотентностью разобрана в рекомендациях Google Cloud.

Каждому тесту нужны собственные данные и ключ идемпотентности. В отчёте сохраняйте operationId, историю статусов и последнее полученное тело. Эти данные позволяют отличить медленный обработчик от потерянного задания и неправильного контракта.

Полный успешный сценарий выглядит так:

Запустить операцию
  -> проверить ответ 202
  -> получить идентификатор операции
  -> опрашивать статус до терминального состояния
  -> при succeeded получить итоговый ресурс
  -> проверить бизнес-данные и побочные эффекты
  -> отдельно проверить безопасный повтор запуска

Этот маршрут применим к API, которое после 202 предоставляет наблюдаемый ресурс операции. Если эндпоинта состояния, обратного вызова или другого канала результата нет, внешний тест способен доказать только приём запроса. Проверить завершение через публичный контракт в таком случае невозможно.

Если после 202 Accepted автотест всё ещё не отвечает на главный вопрос — завершилась ли операция успешно и получен ли корректный результат, — значит, пора глубже разобраться с подходами к автоматизации сложных сценариев.

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

  • 17 сентября, 20:00. «RabbitMQ в Production: Transactional Outbox, идемпотентность и DLQ в ASP.NET Core». Записаться

  • 22 сентября, 20:00. «Playwright JS: как быстро начать писать автотесты?». Записаться

Полный список бесплатных уроков сентября собраны в дайджесте.

Комментарии (0)