Оглавление

Введение
Архитектура и принципы работы
Установка и конфигурация
Базовые сценарии использования
Продвинутые паттерны
Оптимизация производительности
Мониторинг и диагностика
Альтернативы и сравнительный анализ
Best Practices и антипаттерны
Заключение

Введение

Асинхронные операции представляют значительные сложности для автоматизированного тестирования. Традиционный подход с Thread.sleep() обладает фундаментальными недостатками:

  • Нерациональное использование времени: Фиксированные задержки выполняются независимо от фактической готовности системы

  • Низкая надежность: Тесты становятся нестабильными при любых отклонениях в временных характеристиках

  • Сложность поддержки: "Магические числа" требуют постоянной корректировки и не документируют intent

Awaitility предоставляет декларативный подход к обработке асинхронных операций в тестах. Данное руководство охватывает практические аспекты использования библиотеки с акцентом на production-ready подходы.

Официальные источники:

GitHub
Документация

Архитектура и принципы работы

Awaitility реализует паттерн активного ожидания, периодически проверяя условие до тех пор, пока оно не будет выполнено, или не истечёт таймаут. В отличие от Thread.sleep(), этот подход динамически адаптируется к реальному времени выполнения асинхронной операции.

// Упрощённая схема работы Awaitility (псевдокод для объяснения идеи)
public class AwaitilityCore {
    // Планировщик для повторных проверок
    private final ScheduledExecutorService scheduler;
    // Настройки таймаутов, интервалов и игнорируемых исключений
    private final ConditionSettings settings;
    
    public void await(Callable<Boolean> condition) {
        long endTime = System.nanoTime() + settings.getTimeout().toNanos();
        
        while (System.nanoTime() < endTime) {
            try {
                if (condition.call()) return; // Если условие выполнено — выходим успешно
            } catch (Exception e) {
                if (!settings.shouldIgnore(e)) throw e; // Игнорируем только разрешённые исключения
            }
            
            try {
                // Ждём до следующей проверки
                Thread.sleep(settings.getPollInterval().toMillis());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt(); // Восстанавливаем статус прерывания
                throw new ConditionTimeoutException("Operation interrupted");
            }
        }
        throw new ConditionTimeoutException("Condition not met within timeout"); // Таймаут
    }
}

Ключевые компоненты:

  • ScheduledExecutorService для управления потоками

  • Гибкая система обработки исключений

  • Детализированная информация о таймаутах

  • Поддержка кастомных политик повторных попыток

Установка и конфигурация

Зависимости

Проверяем актуальную версию на Maven Central
На момент написания статьи актуальная версия 4.3.0.

Gradle (Groovy DSL):

testImplementation 'org.awaitility:awaitility:4.3.0'

Maven:

<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <version>4.3.0</version>
    <scope>test</scope>
    <type>jar</type>
</dependency>

Базовая настройка

В тестах используйте статический импорт для удобства: import static org.awaitility.Awaitility.*.
Примеры используют JUnit 5 и AssertJ.

import org.junit.jupiter.api.BeforeAll;
import static org.awaitility.Awaitility.setDefaultPollDelay;
import static org.awaitility.Awaitility.setDefaultPollInterval;
import static org.awaitility.Awaitility.setDefaultTimeout;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.awaitility.pollinterval.FibonacciPollInterval.fibonacci;

// Глобальная конфигурация Awaitility
@BeforeAll
static void setupAwaitility() {
    // Таймаут по умолчанию (очень полезно для избежания "вечных" тестов)
    setDefaultTimeout(30, SECONDS);
    // Интервалы опроса по умолчанию (Фибоначчи)
    setDefaultPollInterval(fibonacci(MILLISECONDS));
    // Задержка перед первой проверкой
    setDefaultPollDelay(100, MILLISECONDS);
}

Базовые сценарии использования

Простое ожидание условия

import org.junit.jupiter.api.Test;
import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldCompleteAsyncOperation() {
    // Arrange: инициализируем сервис
    AsyncService service = new AsyncService();
    
    // Act: запускаем асинхронный процесс
    service.startBackgroundProcess();
    
    // Assert: ждём максимум 5 секунд, проверяем каждые 500 мс
    await()
        .atMost(5, SECONDS)
        .pollInterval(500, MILLISECONDS)
        .until(() -> service.isProcessCompleted()); // Условие завершения
    
    assertThat(service.getResult()).isEqualTo("SUCCESS");
}

Ожидание с обработкой исключений

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

import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.NoSuchElementException;
// Предполагаемый класс исключения для временных сбоев
import com.example.service.exception.ServiceUnavailableException;

@Test
void shouldHandleTransientFailures() {
    await()
        // Игнорируем временные ошибки, которые не мешают прогрессу
        .ignoreException(ServiceUnavailableException.class)
        .atMost(10, SECONDS)
        .until(() -> {
            // В случае недоступности сервиса пробуем снова
            return repository.findByStatus("ACTIVE") != null;
        });
    assertThat(repository.count()).isGreaterThan(0);
}

Комплексные проверки состояния

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

import org.junit.jupiter.api.Test;
import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldVerifySystemState() {
    await()
        .atMost(30, SECONDS) // Ждём до 30 секунд
        .untilAsserted(() -> {
            SystemHealth health = healthChecker.getSystemHealth(); // Получаем состояние системы
            
            // Проверяем несколько условий одновременно
            assertThat(health.getStatus())
                .isEqualTo(HealthStatus.HEALTHY);
            assertThat(health.getActiveConnections())
                .isBetween(10, 1000);
            assertThat(health.getErrorRate())
                .isLessThan(0.01);
        });
}

Продвинутые паттерны

Микросервисная архитектура

import java.time.Instant;
import org.junit.jupiter.api.Test;
import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldVerifyDistributedTransaction() {
    String transactionId = transactionService.startDistributedTransaction(); // Запуск распределённой транзакции
    
    await()
        .pollInterval(1, SECONDS) // Проверяем каждую секунду
        .atMost(60, SECONDS)      // Не дольше 1 минуты
        .untilAsserted(() -> {
            // Проверяем согласованность во всех сервисах
            TransactionStatus status = transactionService.getStatus(transactionId);
            PaymentStatus payment = paymentService.getTransactionStatus(transactionId);
            InventoryReservation inventory = inventoryService.getReservation(transactionId);
            
            // Проверяем целостность транзакции
            assertThat(status)
                .isEqualTo(TransactionStatus.COMPLETED);
            assertThat(payment)
                .isEqualTo(PaymentStatus.CONFIRMED);
            assertThat(inventory.isReserved())
                .isTrue();
            
            // Сравнение с временными метками для тестирования
            assertThat(inventory.getExpiryTime())
                .isAfter(Instant.now().minusSeconds(1));
        });
}

Работа с очередями (Kafka)

Для тестирования асинхронных операций с Kafka рекомендуется использовать Spring Kafka Test Utilities, так как они предоставляют удобные и надёжные методы для работы с сообщениями в тестах.

import org.springframework.kafka.test.utils.KafkaTestUtils;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.junit.jupiter.api.Test;
import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldProcessKafkaMessage() {
    // Отправляем сообщение в Kafka
    kafkaTemplate.send("test-topic", "key", "test-payload");
    
    // Ждём, пока сообщение будет прочитано потребителем
    await()
        .atMost(15, SECONDS)
        .until(() -> {
            // KafkaTestUtils.getSingleRecord ожидает запись из топика с таймаутом в 100 мс
            ConsumerRecord<String, String> record = KafkaTestUtils.getSingleRecord(consumer, "test-topic", 100);
            return record != null;
        });
}

Важные замечания по Kafka:

  • Всегда сбрасывать offsets между тестами

  • Использовать изолированные consumer groups

  • Настраивать appropriate session timeouts

Базы данных и eventual consistency

import java.util.concurrent.TimeUnit;
import org.awaitility.pollinterval.PollInterval;
import org.junit.jupiter.api.Test;
import static org.awaitility.Awaitility.await;
import static org.awaitility.pollinterval.FibonacciPollInterval.fibonacci;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldWaitForDataReplication() {
    String entityId = dataService.createEntityInPrimaryRegion(); // Создаём объект в основной БД
    
    await()
        .pollDelay(2, TimeUnit.SECONDS) // Ждём задержку репликации
        .atMost(120, TimeUnit.SECONDS)  // Не больше 2 минут
        .pollInterval(fibonacci(TimeUnit.MILLISECONDS)) // Интервалы по Фибоначчи
        .untilAsserted(() -> {
            // Проверяем репликацию во всех регионах
            for (String region : replicationRegions) {
                Entity entity = regionalService.findInRegion(region, entityId);
                assertThat(entity)
                    .as("Entity in region %s", region)
                    .isNotNull();
                assertThat(entity.getVersion())
                    .as("Version in region %s", region)
                    .isEqualTo(1L);
            }
        });
}

Оптимизация производительности

Настройка интервалов опроса

import org.junit.jupiter.api.Test;
import static org.awaitility.Awaitility.await;
import org.awaitility.pollinterval.PollInterval;
import static org.awaitility.pollinterval.FibonacciPollInterval.fibonacci;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;

// Набор оптимальных интервалов для разных сценариев
public class PollingIntervals {
    public static PollInterval DATABASE = fibonacci(MILLISECONDS);
    public static PollInterval API = fibonacci(SECONDS).with().initialDelay(500, MILLISECONDS);
    public static PollInterval MESSAGE_QUEUE = fibonacci(MILLISECONDS);
}

@Test
void shouldUseOptimizedIntervals() {
    await()
        .pollInterval(PollingIntervals.DATABASE) // Используем интервал для БД
        .atMost(30, SECONDS)
        .until(database::isReady); // Ждём готовности базы
}

Параллельные проверки

Для параллельных проверок используйте CompletableFuture.runAsync(), так как он семантически точнее отражает задачу ожидания, которая не возвращает значения.

import java.util.concurrent.CompletableFuture;
import org.junit.jupiter.api.Test;
import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldPerformParallelChecks() {
    // Запускаем асинхронные проверки
    CompletableFuture<Void> dbCheck = CompletableFuture.runAsync(() ->
        await().until(database::isConnected));
    
    CompletableFuture<Void> apiCheck = CompletableFuture.runAsync(() ->
        await().until(apiService::isHealthy));
    
    CompletableFuture<Void> cacheCheck = CompletableFuture.runAsync(() ->
        await().until(cache::isAvailable));
    
    // Ждём, пока все асинхронные операции завершатся
    await()
        .atMost(60, SECONDS)
        .until(() -> CompletableFuture.allOf(dbCheck, apiCheck, cacheCheck).isDone());
    
    // Проверяем, что ни один из future не завершился с ошибкой
    assertThat(dbCheck).isCompleted();
    assertThat(apiCheck).isCompleted();
    assertThat(cacheCheck).isCompleted();
}

Мониторинг и диагностика

Интеграция с метриками

import org.awaitility.core.ConditionEvaluationListener;
import org.awaitility.core.EvaluatedCondition;
import org.junit.jupiter.api.Test;
import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;

@Test
void shouldCollectWaitMetrics() {
    Timer.Sample sample = Timer.start(Metrics.globalRegistry); // Запускаем таймер до начала ожидания
    
    try {
        await()
            .atMost(30, SECONDS)
            .until(system::isReady); // Ждём готовности системы
    } finally {
        sample.stop(Timer.builder("awaitility.wait.duration")
            .tag("status", "success")
            .register(Metrics.globalRegistry));
    }
}

Расширенное логирование

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.junit.jupiter.api.Test;
import org.awaitility.core.ConditionTimeoutException;
import org.awaitility.core.ConditionEvaluationLogger;
import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldProvideDetailedLogging() {
    // В реальном проекте логгер должен быть инициализирован
    final Logger log = LoggerFactory.getLogger(getClass()); 
    try {
        await()
            .alias("System readiness check") // Название проверки в логах
            .conditionEvaluationListener(new ConditionEvaluationLogger()) // Логгер состояний
            .atMost(60, SECONDS)
            .untilAsserted(() -> {
                log.debug("Checking system components..."); // Дополнительный лог
                assertThat(componentA.isReady()).isTrue();
                assertThat(componentB.isReady()).isTrue();
                assertThat(componentC.isReady()).isTrue();
            });
    } catch (ConditionTimeoutException e) {
        log.error("Test failed to complete in time: {}", e.getMessage());
        throw e;
    }
}

Альтернативы и сравнительный анализ

Сравнение подходов

Критерий

Awaitility

CompletableFuture

CountDownLatch

Reactor StepVerifier

Сложные условия

✅ Отлично

❌ Нет поддержки

❌ Нет поддержки

⚠️ Ограничено

Обработка исключений

✅ Отлично

⚠️ Ограничено

❌ Нет поддержки

✅ Хорошо

Гибкость таймаутов

✅ Отлично

✅ Хорошо

✅ Хорошо

✅ Хорошо

Интеграция с метриками

✅ Отлично

❌ Нет поддержки

❌ Нет поддержки

⚠️ Ограничено

Производительность

✅ Оптимально

✅ Отлично

✅ Отлично

✅ Отлично

Рекомендации по выбору

Используйте Awaitility когда:

  • Требуются сложные составные условия

  • Нужна гибкая обработка исключений

  • Важна интеграция с системами мониторинга

  • Работаете с legacy-кодом

Рассмотрите альтернативы когда:

  • Тестируете reactive streams (StepVerifier)

  • Нужна максимальная производительность (CompletableFuture)

  • Требуется низкоуровневая синхронизация (CountDownLatch)

Best Practices и антипаттерны

Рекомендуемые практики

  1. Идемпотентность условий

// ✅ Правильно: условие идемпотентное, не меняет состояние
await().until(() -> system.getStatus() == READY);

// ❌ Неправильно: условие имеет side effects (изменяет счётчик)
await().until(() -> counter.incrementAndGet() > 5);
  1. Адекватные таймауты

// Таймауты должны учитывать SLA сервиса
await()
    .atMost(serviceSla.getTimeout().plus(5, SECONDS)) // SLA + запас
    .until(service::isAvailable);
  1. Селективная обработка исключений

// Игнорируем только ожидаемые временные исключения
await()
    .ignoreException(ServiceUnavailableException.class)
    .until(service::performOperation);

Избегаемые антипаттерны

  1. Бесконечные ожидания

// ❌ Опасно: тест может зависнуть навсегда
await().forever().until(condition);

// ✅ Правильно: всегда указывать ограничение по времени
await().atMost(MAX_TIMEOUT).until(condition);
  1. Излишне частый опрос

// ❌ Неграмотно: слишком частый опрос нагружает систему
await().pollInterval(10, MILLISECONDS);

// ✅ Грамотно: адаптивные интервалы (Фибоначчи)
await().pollInterval(fibonacci().with().initialDelay(100, MILLISECONDS));

Заключение

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

  1. Понимание домена: Настройка таймаутов и интервалов должна основываться на характеристиках тестируемой системы

  2. Мониторинг и метрики: Инструментирование ожиданий для выявления узких мест

  3. Баланс надежности и производительности: Оптимизация интервалов опроса для минимизации нагрузки

  4. Документирование замысла (intent): Чёткое описание ожиданий в коде тестов.

Области применения:

  • Интеграционное тестирование микросервисов

  • Проверка eventual consistency

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

  • Мониторинг здоровья систем

Ограничения:

  • Не заменяет юнит-тестирование синхронного кода

  • Требует понимания многопоточности

  • Может маскировать реальные проблемы при неправильной настройке

Правильное применение Awaitility значительно повышает надежность и поддерживаемость тестов асинхронных систем, сокращая время на отладку и повышая уверенность в качестве кода.

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