Отладка API-автотестов на Java: от NullPointerException до стабильного CI

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

Анатомия падения: чтение логов и расшифровка Stack Trace

Анатомия падения: чтение логов и расшифровка Stack Trace

В отчете Allure горит красный статус, а в логах красуется пугающее: Cannot invoke "..." because the return value of "..." is null. Для начинающего автоматизатора это выглядит как тупик. Кажется, что тест просто «сломался». Но на самом деле, современная Java не просто говорит вам, что произошла ошибка — она дает точные GPS-координаты места, где код шагнул в пустоту.

Наша задача сегодня — научиться читать эти координаты. Мы разберем реальное падение теста AEQS-11 (получение списка талонов), поймем механику возникновения NullPointerException и разберемся, почему фреймворк пытался выполнить обреченное действие ровно пять раз.

Расшифровка приговора: что говорит лог?

Давайте посмотрим на ошибку из вашего отчета Allure под микроскопом. Начиная с 14-й версии, Java внедрила механизм Helpful NullPointerExceptions. Вместо безликого сообщения система выдает детальное предложение, состоящее из двух частей: Следствия и Причины.

Cannot invoke "ru.vtb.at.commons.api.services.fegs_backend_arm_aggregation.models.EventResponseData.getWaitCountLastTRM()" because the return value of "ru.vtb.at.commons.api.steps.BackendArmAggregationSteps.event(String, ru.vtb.at.commons.enums.EventType)" is null

Разрежем эту фразу пополам по слову because:

  1. Следствие (Cannot invoke...): Java говорит: «Я не могу вызвать метод getWaitCountLastTRM() у объекта типа EventResponseData».
  2. Причина (because...): «Потому что результат выполнения метода event(...) оказался равен null (пустоте)».

В Java нельзя попросить «ничто» сделать «что-то». Если переменная или результат метода указывает на null, попытка поставить точку и вызвать следующий метод неминуемо приведет к взрыву.

Проекция на код: где именно мы упали?

Теперь перенесем это понимание на ваш код. Ошибка произошла на шаге 2, внутри метода processTicket, который использует библиотеку Awaitility для ожидания нужного состояния.

await("Ожидаем процессинга талона")
    .atMost(10, SECONDS)
    .pollInterval(2, SECONDS)
    .execute(() -> event(session, eventType).getWaitCountLastTRM(), equalTo(ticketsLeft));

Смотрим на лямбда-выражение внутри execute: event(session, eventType).getWaitCountLastTRM().

Это классическая цепочка вызовов (method chaining). Выполнение идет слева направо:

  1. Сначала вызывается event(session, eventType). Этот метод делает GET-запрос к API /operator/event и должен вернуть объект EventResponseData.
  2. Затем у этого возвращенного объекта вызывается .getWaitCountLastTRM().

Лог Allure четко сказал нам: шаг 1 вернул null. Следовательно, на шаге 2 код попытался сделать null.getWaitCountLastTRM().

Анатомия ожидания: почему ошибка повторилась 5 раз?

Вы заметили странную деталь: шаг «Выполнить операцию с талоном» в отчете дублируется до 5 раз, и каждый раз возвращается null. Это не баг отчета, это прямое следствие того, как работает Awaitility.

Awaitility — это инструмент для тестирования асинхронных систем. Когда мы переводим талон в статус «На обслуживании» (ожидаем статус 4), бэкенду (backend-director или backend-arm-aggregation) нужно время на обработку. Состояние в базе данных меняется не мгновенно.

Разберем настройки вашего ожидания:

  • atMost(10, SECONDS) — максимальное время, которое тест готов ждать.
  • pollInterval(2, SECONDS) — интервал опроса (polling). Как часто тест будет «дергать» систему с вопросом «Уже готово?».

Как это выглядит в динамике:

  • 0 сек: Первый вызов event(). Возвращается null. Awaitility перехватывает ошибку, понимает, что условие не выполнено, и ждет.
  • 2 сек: Второй вызов. Снова null.
  • 4 сек: Третий вызов. Снова null.
  • 6 сек: Четвертый вызов. Снова null.
  • 8 сек: Пятый вызов. Снова null.
  • 10 сек: Время вышло (таймаут). Awaitility сдается и пробрасывает накопленную ошибку наружу. Тест падает.

Именно поэтому вы видите 5 попыток. Фреймворк честно пытался дождаться момента, когда API начнет отдавать корректные данные, но чуда не произошло.

Что делать дальше? Формируем гипотезы

Мы выяснили где и как упал тест. Вызов API /operator/event возвращает пустоту вместо ожидаемого объекта. Но мы пока не знаем почему.

Отладка — это процесс проверки гипотез. Вот основные подозреваемые:

  1. Проблема с авторизацией (session): Возможно, sessionId, полученный при старте сервиса для пользователя AUTO_TEST_1, невалиден, истек или не имеет прав на отделение 9111.
  2. Неверный контракт (eventType): Переданный START_PROCESS_TICKET может не поддерживаться текущей версией API, и сервер возвращает пустой ответ или ошибку 400/500, которую клиентский код (метод event) молча проглатывает и возвращает null.
  3. Рассинхронизация сервисов: Талон создан в backend-director, но информация о нем еще не доехала до backend-arm-aggregation, к которому мы обращаемся.

Где искать ответы (работа с документацией): Если вы оказались в такой ситуации, ваш следующий шаг — выйти за пределы Java-кода.

  • Swagger / OpenAPI: Откройте спецификацию сервиса backend-arm-aggregation. Найдите эндпоинт /operator/event. Посмотрите, какие обязательные параметры он требует и в каких случаях может вернуть пустой ответ.
  • Система логирования (Kibana / Splunk): Возьмите номер талона (который успешно создался на первом шаге) или sessionId и найдите логи бэкенда за время падения теста. Сервер наверняка оставил след, почему он не смог обработать запрос.
  • Требования (Confluence / Jira): Проверьте задачу AEQS-11. Возможно, логика работы вкладки «На обслуживании» изменилась, и теперь для получения количества талонов (waitCountLastTRM) нужно вызывать другой эндпоинт.

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

Жизненный цикл запроса: отладка взаимодействия между API-клиентом и бэкендом

Жизненный цикл запроса: отладка взаимодействия между API-клиентом и бэкендом

В предыдущей главе мы остановились на точке падения теста AEQS-11: метод event(session, eventType) вернул null, и попытка вызвать у этого «ничего» метод .getWaitCountLastTRM() привела к краху. Мы также выяснили, что благодаря Awaitility тест упорно делал 5 попыток получить данные, и все 5 раз получал пустоту.

Но метод event() — это не просто чтение переменной из памяти. Это граница между вашим Java-кодом и внешним миром. Чтобы понять, откуда взялся null, нам нужно вскрыть этот метод и посмотреть, как автотест общается с микросервисами.

Анатомия метода-моста

В API-автотестах методы вроде event(session, eventType) выполняют роль курьера. Их задача — взять Java-объекты, превратить их в сетевой запрос, отправить на сервер, дождаться ответа и превратить его обратно в понятный Java-объект.

Жизненный цикл этого процесса состоит из четырех этапов:

  1. Сериализация (сборка посылки): Клиент берет параметры sessionId и eventType и формирует HTTP-запрос. В нашем случае это GET /operator/event.
  2. Транспортировка: Запрос уходит по сети к микросервису backend-arm-aggregation.
  3. Обработка и ответ: Бэкенд проверяет базу данных, формирует ответ в формате JSON и отправляет его обратно со статус-кодом (например, 200 OK или 500 Internal Server Error).
  4. Десериализация (распаковка): Java-клиент (обычно с помощью библиотек вроде Jackson или Gson) берет полученный JSON и пытается «натянуть» его на структуру класса EventResponseData.

Если на выходе мы получаем null, значит, цепочка порвалась на этапе 3 или 4.

Три причины получить null из сети

Когда Java-клиент не может вернуть корректный объект EventResponseData, он (в зависимости от настроек фреймворка) может либо выбросить собственное исключение, либо тихо вернуть null. В нашем проекте реализован второй вариант.

Рассмотрим, почему десериализатор решает вернуть пустоту:

Причина Что пришло от бэкенда Почему клиент вернул null
Сетевая или серверная ошибка Статус HTTP400HTTP \geq 400 (например, 404 Not Found или 500 Server Error). Клиент ожидал JSON с данными о талоне, а получил HTML-страницу с ошибкой или системный JSON об аварии. Парсер не нашел нужных полей и сдался.
Пустое тело ответа Статус 200 OK или 204 No Content, но тело ответа абсолютно пустое. Парсеру физически не из чего собирать объект EventResponseData.
Нарушение контракта (Схема не совпадает) Статус 200 OK, JSON присутствует, но его структура изменилась. Бэкенд прислал {"status": "waiting"}, а Java-класс ждет поле waitCountLastTRM. Поля не совпали, объект не собрался.

Инсайт отладки: Ошибка NullPointerException в API-тестах в 90% случаев означает не баг в самом тесте, а то, что бэкенд ответил не так, как ожидал клиент.

Инструменты детектива: Allure и Swagger

Чтобы узнать, какая из трех причин сломала наш тест AEQS-11, нам нужно перехватить сырой HTTP-трафик. Мы не можем угадать ответ сервера, глядя только в Java-код.

Шаг 1: Читаем HTTP-логи в Allure

Фреймворки для тестирования (например, RestAssured) умеют логировать все исходящие запросы и входящие ответы. Откройте отчет Allure по упавшему тесту. Разверните шаг Ожидаем процессинга талона. Внутри вы увидите 5 вложенных вызовов (те самые попытки Awaitility). К каждому из них должны быть прикреплены Attachments (Вложения):

  • HTTP Request — здесь мы проверяем, правильно ли ушел sessionId.
  • HTTP Response — здесь кроется разгадка. Посмотрите на статус-код и тело ответа.

Шаг 2: Сверяемся с документацией (Swagger)

Допустим, в логах Allure вы увидели, что сервер отвечает статусом 200, но отдает JSON без поля waitCountLastTRM. Как понять, кто виноват: бэкенд (отдал не то) или автотест (ждет устаревшее поле)?

Здесь в игру вступает Swagger (OpenAPI) — интерактивная документация к API.

Что вам нужно сделать прямо сейчас в рабочей среде:

  1. Запросите у команды (разработчиков или аналитиков) ссылку на Swagger UI для сервиса backend-arm-aggregation.
  2. Найдите в списке эндпоинтов GET /operator/event.
  3. Откройте блок Responses (Ответы). Там описана эталонная модель ответа (Schema).
  4. Сравните эталонную модель из Swagger с тем JSON, который вы увидели в логах Allure, и с полями класса EventResponseData в вашем Java-коде.

Распутываем AEQS-11

Вернемся к нашей задаче. Тест запускает обработку талона (START_PROCESS_TICKET), а затем опрашивает /operator/event, ожидая, что количество талонов в очереди (waitCountLastTRM) станет равно нулю.

Если метод event() возвращает null, значит, на момент опроса бэкенд не отдает корректную информацию о событии. Это может происходить, если процесс обработки талона на стороне бэкенда запускается асинхронно и в первые секунды эндпойнт /operator/event просто не знает о новом статусе, возвращая ошибку 404 (событие не найдено) или пустой ответ.

Мы подошли к критической точке: мы знаем, как прочитать ответ бэкенда. Но что, если бэкенд отвечает правильно, просто слишком медленно, а наш клиент не умеет корректно обрабатывать промежуточные состояния? Это фундаментальная проблема асинхронных систем, которую мы решим на следующем этапе отладки.

Синхронизация состояний: работа с Awaitility и обработка асинхронных ответов

Синхронизация состояний: работа с Awaitility и обработка асинхронных ответов

В отчете Allure мы видим, что тест упал на первой же секунде ожидания. Библиотека Awaitility была настроена на 10 секунд поллинга (5 попыток), но упала сразу, выбросив NullPointerException. Почему механизм, созданный специально для упорного ожидания результата, сдался без боя при первой же неудаче?

Чтобы понять причину, нужно посмотреть на взаимодействие автотеста и бэкенда в динамике.

Асинхронный зазор

Когда тест вызывает метод запуска обработки талона (processTicket), он отправляет бэкенду команду-триггер. Бэкенд принимает команду, ставит задачу в очередь и немедленно возвращает успешный HTTP-статус (например, 202 Accepted).

Однако фактическая обработка талона — обновление таблиц в базе данных, расчет метрик, публикация событий в брокер сообщений — занимает время. Возникает асинхронный зазор — период времени Δt\Delta t, когда команда уже принята, но новое состояние системы еще не сформировано.

Именно в этот зазор попадает наш первый проверочный GET-запрос к эндпоинту /operator/event. Бэкенд еще не успел сгенерировать событие для сессии оператора. В зависимости от реализации API, он может вернуть пустой JSON {} или статус 404 Not Found. Как мы выяснили на этапе анализа жизненного цикла запроса, десериализатор превращает такой пустой или ошибочный ответ в null.

Ловушка необработанных исключений

Посмотрим на лямбда-выражение, которое мы передаем в Awaitility:

() -> event(session, eventType).getWaitCountLastTRM()

Awaitility вызывает эту функцию каждые 2 секунды. Логика библиотеки построена на оценке результата: если результат не совпадает с ожидаемым (например, количество талонов waitCount0waitCount \neq 0), библиотека засыпает и пробует снова.

Но если event(...) возвращает null, попытка вызвать метод .getWaitCountLastTRM() приводит к выбросу NullPointerException.

Для Awaitility несовпадение значений — это штатная ситуация, ради которой она работает. А вот выброшенное исключение (Exception) — это авария. По умолчанию Awaitility считает, что если внутри проверяемого кода произошла программная ошибка, продолжать поллинг бессмысленно. Она немедленно прерывает цикл ожидания и пробрасывает ошибку наверх, что приводит к падению теста.

Укрощение Awaitility: легализация промежуточных состояний

Чтобы тест пережил асинхронный зазор, нам нужно объяснить библиотеке Awaitility, что NullPointerException в первые секунды ожидания — это не баг кода, а легитимное промежуточное состояние системы.

Для этого в Awaitility предусмотрен механизм игнорирования исключений. Мы можем модифицировать наш шаг processTicket, добавив правило ignoreExceptions():

await("Ожидаем процессинга талона")
    .atMost(10, SECONDS)
    .pollInterval(2, SECONDS)
    .ignoreException(NullPointerException.class) // Указываем, какую ошибку прощать
    .until(() -> event(session, eventType).getWaitCountLastTRM(), equalTo(ticketsLeft));

Теперь алгоритм работает иначе:

  1. Секунда 0: Запрос отправлен. Ответ пуст. Выброшен NPE. Awaitility видит, что NPE в списке разрешенных, глотает ошибку и ждет.
  2. Секунда 2: Запрос отправлен. Событие уже создано, но талон еще в процессе, возвращается waitCount=1waitCount = 1. Ошибки нет, но условие 1=01 = 0 ложно. Awaitility ждет.
  3. Секунда 4: Запрос отправлен. Процесс завершен, возвращается waitCount=0waitCount = 0. Условие истинно. Awaitility успешно завершает работу, тест идет дальше.

Альтернативный путь: безопасная навигация

Игнорирование исключений на уровне конфигурации Awaitility — мощный, но опасный инструмент. Если мы допустим опечатку в переменной session, метод event() будет всегда возвращать null. Тест будет молча ждать 10 секунд, скрывая реальную причину (неверную сессию) за таймаутом.

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

await("Ожидаем процессинга талона")
    .atMost(10, SECONDS)
    .pollInterval(2, SECONDS)
    .until(() -> {
        EventResponseData data = event(session, eventType);
        if (data == null) {
            return -1; // Возвращаем заведомо невалидное значение
        }
        return data.getWaitCountLastTRM();
    }, equalTo(ticketsLeft));

В этом случае:

  • Мы не маскируем системные ошибки (NPE).
  • Мы явно описываем бизнес-логику: «Если данных еще нет, считаем, что количество талонов равно -1».
  • Условие 1=0-1 = 0 ложно, поэтому Awaitility штатно продолжит поллинг без выброса исключений.

Внедрив проверку на null или игнорирование исключений, мы решаем проблему падения на первой секунде. Тест честно отрабатывает положенные 10 секунд. Но что делать, если время вышло, тест упал по таймауту, а статус талона так и не изменился? Это означает, что проблема кроется не в синхронизации автотеста, а в самих данных или контрактах бэкенда.

Диагностика данных: проверка контрактов и состояния БД через Swagger и логи

Диагностика данных: проверка контрактов и состояния БД через Swagger и логи

Мы обезопасили код Awaitility от падения при встрече с пустотой, и теперь тест честно ждет свои t10t \leq 10 секунд. Но чуда не происходит: время выходит, и тест падает с ConditionTimeoutException. Код API-клиента работает идеально, он корректно опрашивает сервер, но ожидаемое значение waitCountLastTRM = 0 так и не появляется в ответе. Безопасность кода не заменяет корректность бизнес-логики. Если данные не приходят, значит, проблема затаилась глубже — на стороне бэкенда.

Иллюзия успешного запроса

В нашем сценарии тест выполняет два ключевых действия: отправляет команду на запуск обработки талона (START_PROCESS_TICKET), а затем опрашивает состояние системы.

Проблема автоматизированных тестов в том, что они скрывают промежуточные шаги. Если шаг processTicket возвращает ошибку по таймауту, мы инстинктивно смотрим на GET-запрос, который возвращает null. Но корень проблемы чаще всего кроется в предыдущем шаге — в POST-запросе, который должен был изменить состояние системы.

Откройте отчет Allure и посмотрите на запрос, инициирующий событие. Вы можете увидеть статус 200 OK. Кажется, что бэкенд принял команду. Однако статус 200 OK на уровне HTTP означает лишь то, что контроллер микросервиса получил JSON и смог его прочитать. Это не гарантирует, что бизнес-операция выполнилась успешно.

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

Ручная симуляция через Swagger

Автотест — это черный ящик. Когда он ломается, нужно переходить на ручное управление. Swagger позволяет нам стать на место автотеста и отправить точно такой же запрос, но с возможностью мгновенно увидеть результат.

Возьмите из Allure-отчета упавшего теста два параметра: sessionId (полученный в @BeforeMethod) и ticketNumber. Откройте Swagger микросервиса backend-arm-aggregation и найдите эндпоинт, отвечающий за отправку события.

Вставьте данные из лога и выполните запрос. Возможны два сценария:

  1. Явная ошибка контракта. Swagger возвращает 400 Bad Request или 409 Conflict с сообщением вроде "Operator workplace is not configured". Это означает, что наш блок @BeforeMethod устарел: бэкенд обновился, и теперь для начала работы с талоном недостаточно просто передать WINDOW_01.getId(), требуются дополнительные настройки пользователя.
  2. Тихий отказ (Silent failure). Swagger возвращает 200 OK, но последующий запрос состояния по-прежнему отдает старые данные. Бэкенд проглотил команду, но бизнес-логика прервалась где-то внутри.

Для расследования тихого отказа нам придется спуститься на уровень логов микросервиса.

Поиск иголки в стоге сена: Trace ID

Микросервисная архитектура (в нашем случае backend-director и backend-arm-aggregation) обрабатывает тысячи запросов в секунду. Если вы просто откроете Kibana (или другую систему логирования) и введете номер талона, вы рискуете ничего не найти, потому что логи сетевых балансировщиков и внутренних контроллеров могут не содержать бизнес-параметров.

Связующей нитью между вашим автотестом и недрами бэкенда выступает Trace ID (идентификатор трассировки).

Trace ID — это уникальный буквенно-цифровой код, который генерируется при первом входе запроса в систему и передается в заголовках (например, X-B3-TraceId или traceparent) через все микросервисы, участвующие в обработке.

В Allure-отчете в разделе Headers (заголовки) отправленного POST-запроса найдите этот идентификатор. Вставив его в строку поиска Kibana, вы получите изолированную историю жизни ровно одного вашего запроса.

Найдя логи по Trace ID, мы можем увидеть скрытые ошибки. Например, лог backend-arm-aggregation может содержать запись уровня WARN: Transition 1 -> 4 is invalid for ticket 9111-A01. Сервис не упал, он просто отклонил некорректный переход статуса и ничего не записал в базу.

База данных как истина в последней инстанции

Если логи показывают, что процесс прошел успешно (Event processed successfully), но GET-запрос в тесте всё равно не видит изменений, последней инстанцией становится база данных.

Сетевые ответы и логи отражают процесс, а база данных — фактическое состояние. Тест ожидает, что статус талона станет равен 4 (На обслуживании). Подключившись к БД backend-director, мы выполняем запрос:

SELECT status, wait_count
FROM tickets
WHERE ticket_number = '9111-A01';

Если статус в базе равен 2 (В ожидании), значит транзакция не была зафиксирована (commit). Если статус равен 4, но поле wait_count не обновилось до 0, мы нашли архитектурный нюанс.

В высоконагруженных системах агрегированные данные (такие как количество талонов в очереди waitCountLastTRM) часто рассчитываются асинхронно. Событие START_PROCESS_TICKET меняет статус талона мгновенно, но пересчет очереди может выполняться отдельным фоновым процессом (job) раз в несколько секунд или передаваться через брокер сообщений (Kafka).

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

Собрав воедино данные из Allure, логи по Trace ID и состояние БД, мы получаем полную картину: мы знаем, дошел ли запрос, как его обработал код бэкенда и как это отразилось на жестком диске сервера.

Стратегии исправления и стабилизации: от обработки null до настройки таймаутов

Стратегии исправления и стабилизации: от обработки null до настройки таймаутов

Тест, который проходит восемь раз из десяти, хуже, чем тест, который падает всегда. Мерцающие (flaky) тесты подрывают доверие к CI/CD: разработчики начинают игнорировать красные сборки, списывая их на «опять автоматика моргает». Мы уже выяснили, что причина падения задачи AEQS-11 кроется в асинхронном зазоре и фоновых процессах бэкенда. Теперь необходимо превратить эти знания в железобетонный код, который будет стабильно работать на любом окружении.

Синхронизация предусловий

Частая ошибка при написании API-тестов — фокусировка исключительно на целевом действии. В нашем случае это метод processTicket. Однако фундамент стабильности закладывается на этапе подготовки данных.

В блоке @BeforeMethod происходит авторизация пользователя и привязка его к рабочему месту WINDOW_01. Если бэкенд использует брокеры сообщений (например, Kafka или RabbitMQ) для обновления прав доступа, ответ 200 OK на запрос авторизации означает лишь то, что заявка на создание сессии принята. Фактическая привязка оператора к окну может занять еще несколько сотен миллисекунд.

Если основной тест стартует до того, как база данных обновит статус окна, вызов event(...) вернет пустой ответ, потому что с точки зрения системы оператор еще не готов к приему талонов. Это приводит к десериализации пустого тела и закономерному NullPointerException.

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

Математика таймаутов

В исходном коде processTicket жестко задано ожидание: 10 секунд. Откуда взялась эта цифра? Чаще всего разработчики ставят таймауты интуитивно. Если на локальной машине тест проходит за 3 секунды, ставят 10 «с запасом». Но на серверах CI, где параллельно запускаются сотни контейнеров, ресурсы процессора и сети ограничены, из-за чего фоновые джобы бэкенда отрабатывают медленнее.

Таймаут должен быть математически обоснован метриками системы. Допустим, мы проанализировали логи Kibana и выяснили две величины: TavgT_{avg} — среднее время обработки талона в нормальных условиях. TmaxT_{max} — максимальное время обработки при пиковой нагрузке на базу данных.

Безопасный таймаут для автотеста вычисляется по формуле: TtimeoutTmax+ΔT_{timeout} \geq T_{max} + \Delta

Где: TtimeoutT_{timeout} — итоговое время ожидания в Awaitility (параметр atMost). TmaxT_{max} — худшее время ответа бэкенда (например, 12 секунд). Δ\Delta — буфер на сетевую задержку и накладные расходы CI-раннера (обычно 2–3 секунды).

Если TmaxT_{max} составляет 12 секунд, наш старый таймаут в 10 секунд был обречен на периодические падения. Правильное значение TtimeoutT_{timeout} должно быть не менее 15 секунд.

Динамический опрос и задержка старта

Исходный тест использовал линейный опрос: pollInterval(2, SECONDS). Это значит, что запросы летят на нулевой, второй, четвертой, шестой секундах.

Мы знаем, что в первые секунды после вызова START_PROCESS_TICKET бэкенд гарантированно еще не успел обновить счетчик waitCountLastTRM. Делать запрос на нулевой секунде бессмысленно — он точно вернет null и вызовет исключение, которое нам придется игнорировать. Более того, частые запросы в начале создают лишнюю нагрузку на API.

Для оптимизации применяются два инструмента:

  1. Задержка первого опроса (pollDelay) — указывает Awaitility подождать определенное время перед самой первой проверкой.
  2. Динамический интервал — вместо фиксированных 2 секунд время между попытками постепенно увеличивается (например, по ряду Фибоначчи).

Финальная архитектура метода

Объединим обработку исключений, правильные таймауты и динамический опрос в итоговом решении для шага processTicket.

import static org.awaitility.Awaitility.await;
import static org.awaitility.pollinterval.FibonacciPollInterval.fibonacci;

public void processTicket(String session, EventType eventType, int ticketsLeft) {
    await("Ожидаем процессинга талона и обновления счетчика")
        .atMost(15, SECONDS) // Математически обоснованный T_timeout
        .pollDelay(3, SECONDS) // Ждем 3 секунды до первого запроса
        .pollInterval(fibonacci(SECONDS)) // Интервалы: 1s, 1s, 2s, 3s, 5s...
        .ignoreException(NullPointerException.class) // Гасим NPE от пустых ответов
        .until(() -> {
            EventResponseData response = event(session, eventType);
            // Безопасная навигация на случай, если ignoreException пропустит null
            if (response == null || response.getWaitCountLastTRM() == null) {
                return -1;
            }
            return response.getWaitCountLastTRM();
        }, equalTo(ticketsLeft));
}

Что изменилось и почему это работает:

  1. Мы увеличили atMost до 15 секунд, покрыв возможные просадки производительности на CI.
  2. Добавили pollDelay(3, SECONDS). Теперь тест не бьется в закрытую дверь мгновенно, давая фоновым процессам бэкенда фору.
  3. Заменили жесткий pollInterval на fibonacci. Если система отвечает быстро (после 3 секунд задержки), тест завершится почти сразу. Если система тормозит, интервалы между запросами будут расти, снижая спам запросами к API.
  4. Оставили ignoreException, чтобы легально проглатывать NPE в моменты, когда сессия или талон находятся в промежуточном состоянии.

Благодаря этим стратегиям тест AEQS-11 перестает зависеть от случайных таймингов сети и скорости работы базы данных. Он становится детерминированным: если бэкенд укладывается в контрактные метрики производительности, тест всегда будет зеленым.