У метода POST /orders есть ограничение: quantity должно быть целым числом от 1 до 100. В негативном тесте передаём quantity: 0, получаем 422 Unprocessable Content, тест проходит. Позже выясняется, что с существующим товаром сервис спокойно создаёт заказ с нулевым количеством.

Причина оказалась в подготовке данных. Вместе с quantity тест подставлял случайный productId, которого не было в базе. Сервис возвращал 422 из-за отсутствующего товара, а до проверки количества мог вообще не доходить. Тест был зелёным, но подтверждал только то, что запрос по какой-то причине отклонили.

Такое часто встречается в негативных проверках API. Мы меняем несколько полей, ожидаем любой 4xx и считаем, что нужная валидация работает. На деле запрос может остановить API Gateway, JSON-парсер, просроченный токен или другое бизнес-правило. Ниже разберём, как собрать тест, который проверяет конкретное ограничение, ожидаемую ошибку и отсутствие нежелательных изменений в системе.

Невалидный запрос лучше собирать из валидного

Пустая строка, null или дополнительное поле не являются ошибкой сами по себе. Сначала в требованиях должно появиться правило, которое запрещает такое значение. Часть правил можно описать прямо в OpenAPI:

type: object
required:
  - productId
  - quantity
  - currency
additionalProperties: false
properties:
  productId:
    type: string
    format: uuid
  quantity:
    type: integer
    minimum: 1
    maximum: 100
  currency:
    type: string
    enum: [RUB, EUR]

У этой небольшой схемы уже есть несколько деталей, которые легко пропустить. Поле, перечисленное в properties, не становится обязательным без required. Неизвестные поля по умолчанию разрешены, пока явно не указано additionalProperties: false. Поэтому тест «API должен отклонить лишнее поле» ничего не проверяет, если такого ограничения нет в схеме или отдельных требованиях.

С format ситуация ещё интереснее. В JSON Schema 2020-12 это ключевое слово по умолчанию содержит информацию о формате, но не обязательно включает саму проверку. То есть format: uuid не гарантирует, что используемая библиотека отклонит строку 123. Это зависит от версии схемы и настроек валидатора. В OpenAPI 3.2 вариант JSON Schema можно задать через jsonSchemaDialect, а для отдельной схемы через $schema. Подробности есть в OpenAPI 3.2 и документации JSON Schema.

При этом OpenAPI всё равно не опишет все бизнес-условия. По схеме productId может быть корректным UUID, но самого товара нет, он снят с продажи или закончился на складе. Поэтому валидность запроса зависит не только от JSON, но и от состояния тестового окружения и прав пользователя.

Практически это означает следующее: сначала нужен запрос, который успешно проходит при текущих данных. Для проверки quantity товар должен существовать, быть доступным для заказа и иметь достаточный остаток. Токен должен быть действующим, а остальные поля должны соответствовать схеме.

В одном тесте лучше нарушать одно правило

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

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

Нужно понимать, какая проверка отклонила запрос

До бизнес-логики запрос обычно проходит прокси или API Gateway, проверку HTTP-заголовков, разбор JSON, проверку по схеме, аутентификацию и авторизацию. Только после этого сервис проверяет состояние товара и начинает создавать заказ.

Ошибки на этих этапах означают разное. 415 Unsupported Media Type говорит, что метод не поддерживает переданный формат. 422 Unprocessable Content подходит для содержимого с корректным форматом и синтаксисом, которое сервер не может обработать. 409 Conflict означает конфликт с текущим состоянием ресурса. 412 Precondition Failed используется, когда не выполнилось условие из заголовка запроса, например If-Match. Точные правила выбора между 400, 409 и 422 должны быть закреплены в контракте конкретного API, но проверка «пришёл любой 4xx» эти различия теряет. Значения кодов описаны в RFC 9110.

Например, такой запрос не проверяет ограничение для quantity:

POST /orders HTTP/1.1
Content-Type: text/plain

{"productId":"01c3...","quantity":0,"currency":"RUB"}

Если метод принимает только application/json, запрос отклонят ещё до разбора тела. Тест с ожиданием 4xx пройдёт, хотя проверка количества не запускалась. То же происходит с просроченным токеном: вместо бизнес-правила тест проверяет аутентификацию.

Порядок проверок при этом не всегда должен быть виден клиенту. Сервис может проверять права раньше существования ресурса или возвращать 404 вместо 403, чтобы не раскрывать сам факт его наличия. RFC 9110 допускает такое использование 404. Поэтому нельзя просто потребовать, чтобы API всегда сначала сообщал об ошибках в теле запроса.

Понять причину отказа помогает стабильный код ошибки. В интеграционных тестах дополнительно можно передавать идентификатор запроса и находить по нему трассировку. Привязываться к конкретному тексту в логах не стоит: он меняется независимо от публичного поведения API.

Одного кода ответа недостаточно

Даже точная проверка status === 422 остаётся слабой, если одним статусом обозначены разные ошибки. В нашем примере 422 может относиться и к отсутствующему товару, и к нулевому количеству. Чтобы различить их, у ответа должен быть стабильный формат.

Один из вариантов такого формата описан в RFC 9457 и называется Problem Details. Для типа проблемы используется поле type, а для ошибок конкретных полей можно добавить свой массив errors:

{
  "type": "https://api.example.com/problems/request-validation",
  "title": "Request validation failed",
  "status": 422,
  "errors": [
    {
      "code": "QUANTITY_OUT_OF_RANGE",
      "pointer": "/quantity",
      "minimum": 1,
      "maximum": 100
    }
  ]
}

В тесте стоит проверить HTTP-статус, Content-Type, схему ответа, type, код ошибки и поле, к которому она относится. Если status продублирован в теле, он должен совпадать со статусом HTTP-ответа.

А вот сравнивать целиком title, detail и instance обычно не нужно. detail предназначен для понятного человеку описания и может меняться или переводиться. RFC 9457 прямо рекомендует клиентам не извлекать из него данные. Для автоматической проверки нужен отдельный стабильный код, а не поиск фразы «quantity must be greater than zero».

После ошибки нужно проверить состояние системы

Корректный ответ ещё не означает, что операция действительно остановилась вовремя. Сервис мог сначала вставить заказ, затем попытаться зарезервировать товар и только после этого обнаружить неправильное количество. Клиент получил 422, но в базе остался черновик. В событийной системе транзакция могла откатиться, а событие OrderCreated уже уйти в брокер.

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

Если сервис использует паттерн transactional outbox, то есть записывает исходящие события в отдельную таблицу в одной транзакции с бизнес-данными, в интеграционном тесте можно проверить отсутствие записи в outbox по идентификатору запроса. Это надёжнее, чем ждать сообщение из брокера. Паттерн и границы его гарантий разобраны в Azure Architecture Center.

Проверка «за две секунды сообщение не пришло» говорит только о том, что мы не увидели его за две секунды. Она не исключает отложенную публикацию. Если система тестируется только снаружи, нужно либо зафиксировать это ограничение, либо определить в требованиях максимальное время появления события.

Собираем тест на конкретное правило

Теперь можно переписать исходный тест. Ниже псевдокод без привязки к конкретному фреймворку:

test("order is rejected when quantity is below the minimum", async () => {
  const requestId = randomUuid();
  const product = await fixtures.activeProduct({ stock: 20 });
  const request = validCreateOrder({
    productId: product.id,
    quantity: 1,
    currency: "RUB"
  });

  expect(validateAgainstSchema(request)).toEqual([]);

  const invalidRequest = { ...request, quantity: 0 };
  expect(validateAgainstSchema(invalidRequest)).toEqual([
    expect.objectContaining({
      instancePath: "/quantity",
      keyword: "minimum"
    })
  ]);

  const stockBefore = await inventory.stock(product.id);
  const response = await api.post("/orders", invalidRequest, {
    headers: { "X-Request-ID": requestId }
  });

  expect(response.status).toBe(422);
  expect(mediaType(response)).toBe("application/problem+json");
  expect(response.body).toMatchSchema("RequestValidationProblem");
  expect(response.body.type)
    .toBe("https://api.example.com/problems/request-validation");
  expect(response.body.status).toBe(response.status);
  expect(response.body.errors).toContainEqual(
    expect.objectContaining({
      code: "QUANTITY_OUT_OF_RANGE",
      pointer: "/quantity"
    })
  );

  expect(await orders.findByRequestId(requestId)).toBeNull();
  expect(await inventory.stock(product.id)).toBe(stockBefore);
  expect(await outbox.find({
    correlationId: requestId,
    type: "OrderCreated"
  })).toEqual([]);
});

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

Прямой доступ к базе и outbox уместен в компонентных и интеграционных тестах, где мы проверяем поведение одного сервиса. В сквозном тесте состояние лучше проверять через публичный API и отдельного тестового потребителя сообщений. Один большой тест не должен одновременно доказывать корректность HTTP-контракта, транзакции внутри сервиса и доставки через всю распределённую систему.

Автогенерация помогает с данными, но не знает бизнес-контекст

Негативные сценарии всё чаще генерируются из OpenAPI автоматически. Инструмент может проверить значения рядом с minimum и maximum, удалить обязательное поле, подставить другой тип и сократить найденный пример до минимального набора данных, на котором воспроизводится ошибка. Такой подход обычно называют генерацией тестов по свойствам схемы, или property-based testing.

Однако генератор решает только задачу подготовки входных данных. Ожидаемый результат всё равно нужно задавать отдельно. Это хорошо видно на примере Schemathesis. Проверка negative_data_rejection отвечает только на вопрос, принял ли API данные, нарушающие схему. 5xx отслеживает отдельная проверка not_a_server_error, соответствие статуса OpenAPI проверяет status_code_conformance, а тело и Content-Type проверяются ещё двумя правилами. Их можно настраивать под конкретный API. Текущее поведение описано в документации Schemathesis.

Автоматически сгенерированный сценарий не узнает сам, должен ли запрос создать запись в outbox, изменить остаток или занять ключ идемпотентности. Поэтому генерация хорошо расширяет контрактные проверки и фаззинг, но важные бизнес-правила всё равно требуют отдельных тестов с подготовленным состоянием.

Что проверить перед добавлением негативного теста

  • Какое конкретное правило нарушено и где оно зафиксировано?

  • Все ли остальные поля, данные и права пользователя остаются валидными?

  • Может ли запрос быть отклонён раньше нужной проверки?

  • Проверяются ли точный статус, структура ответа и стабильный код ошибки?

  • Может ли тест пройти при 500 или при ошибке другого поля?

  • Какие бизнес-данные и события не должны появиться после отказа?

Количество null, пустых строк и значений за границей диапазона само по себе ничего не говорит о качестве негативных тестов. Полезный сценарий показывает, какое правило сработало, какой ответ получил клиент и какие действия сервис не выполнил. Если тест может пройти из-за просроченного токена, другого невалидного поля или внутреннего исключения, его зелёный статус всё ещё мало что доказывает.

В статье разобрали, почему негативный тест может пройти и при этом ничего не подтвердить: ошибка может возникнуть на другом этапе, а система — изменить состояние данных. Закрепить тему на практике можно на бесплатных уроках по автоматизации тестирования: посмотреть, как устроены современные инструменты, задать вопросы экспертам и понять, какие подходы можно применять в работе.

  • 3 сентября, 20:00. «UI и API тестирование с Java и Playwright». Записаться

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

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