Тестирование переподключений

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

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

Основная сложность заключается в том, что переподключение в STOMP-клиентах обычно реализуется через цикл с задержками:

  • обработчик onWebSocketClose
  • планирование reconnect через setTimeout
  • повторный вызов activate()
  • восстановление подписок

Любая ошибка в этой цепочке приводит либо к бесконечным попыткам, либо к полной потере соединения без восстановления.


Моделирование разрыва соединения в тестовой среде

Для проверки логики переподключения требуется имитация поведения WebSocket. В большинстве тестовых сценариев используется подмена глобального WebSocket на мок-объект.

Базовая структура мока включает:

  • событие открытия соединения
  • событие закрытия соединения
  • возможность вручную вызывать onclose, onerror, onmessage

Пример модели:

class MockWebSocket {
  constructor() {
    this.ono pen = null;
    this.oncl ose = null;
    this.oner ror = null;
    this.onmess age = null;
    this.readyState = 1;
  }

  simulateOpen() {
    this.readyState = 1;
    this.onopen && this.onopen();
  }

  simulateClose(code = 1006) {
    this.readyState = 3;
    this.onclose && this.onclose({ code });
  }

  simulateMessage(data) {
    this.onmessage && this.onmessage({ data });
  }
}

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

  • внезапное разъединение
  • повторное подключение
  • частичную потерю сообщений
  • ошибки handshake

Проверка базового сценария переподключения

Типовой тест переподключения строится вокруг ожидания повторного вызова activate() или создания нового WebSocket после закрытия соединения.

Основная цель — убедиться, что клиент не останавливается после первого разрыва.

Логика теста:

  • создаётся STOMP-клиент
  • подменяется WebSocket
  • инициируется соединение
  • симулируется close
  • проверяется запуск reconnect-логики

При использовании Jest важно контролировать таймеры:

jest.useFakeTimers();

test('reconnect after disconnect', () => {
  const client = createStompClient();

  client.activate();

  mockSocket.simulateOpen();
  mockSocket.simulateClose();

  jest.advanceTimersByTime(5000);

  expect(MockWebSocket.instances.length).toBeGreaterThan(1);
});

Ключевой момент — контроль времени. Без fake timers тесты становятся нестабильными и зависимыми от реального планировщика задач.


Экспоненциальная задержка и её тестирование

Во многих реализациях STOMP.js применяется стратегия exponential backoff:

  • первая попытка: 500 мс
  • вторая: 1000 мс
  • третья: 2000 мс
  • далее ограничение максимального интервала

Это предотвращает перегрузку брокера и сети.

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

Подход:

  • перехват вызова setTimeout
  • фиксация аргументов задержки
  • последовательное продвижение времени

Пример:

test('exponential backoff progression', () => {
  const delays = [];

  global.setTimeout = (fn, delay) => {
    delays.push(delay);
    fn();
  };

  const client = createStompClientWithBackoff();
  client.activate();

  mockSocket.simulateClose();
  mockSocket.simulateClose();
  mockSocket.simulateClose();

  expect(delays[0]).toBe(500);
  expect(delays[1]).toBe(1000);
  expect(delays[2]).toBe(2000);
});

Важный аспект — контроль побочных эффектов. Реальные таймеры должны быть полностью изолированы.


Проверка восстановления подписок после reconnect

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

Типичный поток:

  1. соединение установлено
  2. выполняются subscribe
  3. соединение разрывается
  4. reconnect
  5. повторный subscribe

Ошибки в этом процессе приводят к:

  • потере сообщений
  • дублированию подписок
  • утечке памяти

Тестовая стратегия:

  • фиксировать список вызовов subscribe
  • проверять повторное выполнение после reconnect
test('resubscribes after reconnect', () => {
  const subscribeMock = jest.fn();

  const client = createClient({
    onConnect: () => {
      client.subscribe('/topic/test', subscribeMock);
    }
  });

  client.activate();

  mockSocket.simulateOpen();
  mockSocket.simulateClose();

  jest.runOnlyPendingTimers();
  mockSocket.simulateOpen();

  expect(subscribeMock).toHaveBeenCalledTimes(2);
});

Здесь важно различать:

  • первичную подписку
  • восстановленную подписку

Тестирование множественных быстрых разрывов

Реальные сети часто дают не один разрыв, а серию:

  • кратковременный disconnect
  • повторное соединение
  • повторный disconnect

Такие сценарии приводят к состоянию гонки, если логика переподключения не защищена от параллельных запусков.

Основная проверка:

  • отсутствуют параллельные activate() вызовы
  • нет множественных WebSocket-инстансов
test('prevents parallel reconnect loops', () => {
  const client = createClient();

  client.activate();

  mockSocket.simulateClose();
  mockSocket.simulateClose();
  mockSocket.simulateClose();

  jest.advanceTimersByTime(10000);

  expect(MockWebSocket.instances.length).toBeLessThanOrEqual(2);
});

Ключевой момент — защита состояния:

  • флаг isReconnecting
  • блокировка повторного запуска

Интеграционное тестирование с эмуляцией брокера

Более реалистичный подход — использование фейкового STOMP-брокера. Он позволяет проверять не только reconnect, но и поведение подписок и доставки сообщений.

Эмуляция включает:

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

Сценарий:

  • клиент подключается
  • подписывается на канал
  • брокер отключается
  • брокер восстанавливается
  • сообщения продолжают доставляться

Особое внимание уделяется сохранению подписок на стороне клиента, а не брокера.


Таймеры, race conditions и детерминированность тестов

Основная проблема тестирования reconnect-логики — недетерминированность.

Источники:

  • реальные setTimeout
  • события WebSocket
  • асинхронные очереди микротасков

Для устранения нестабильности:

  • используется jest.useFakeTimers()
  • блокируется реальный event loop
  • контролируется порядок вызовов

Дополнительно применяются техники:

  • явная очистка состояния клиента между тестами
  • изоляция глобальных объектов
  • перехват Date.now() при backoff-логике

Проверка отключения reconnect при destroy

Отдельный важный сценарий — корректное прекращение переподключений при явном уничтожении клиента.

После вызова deactivate():

  • таймеры должны быть отменены
  • reconnect не должен инициироваться
  • WebSocket не должен пересоздаваться
test('stops reconnect after deactivate', () => {
  const client = createClient();

  client.activate();
  mockSocket.simulateClose();

  client.deactivate();

  jest.runAllTimers();

  expect(MockWebSocket.instances.length).toBe(1);
});

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


Защита от утечек памяти при переподключениях

При длительной работе приложения переподключения могут приводить к накоплению:

  • старых подписок
  • таймеров
  • обработчиков событий

Тестирование включает:

  • проверку количества активных подписок
  • контроль числа listener-ов
  • отсутствие роста массивов между reconnect-циклами

Подход часто дополняется инструментами профилирования памяти, но базовые проверки возможны и на уровне unit-тестов через мок-объекты.


Поведение при нестабильном брокере

Некоторые брокеры могут:

  • принимать соединение, но не отправлять ACK
  • разрывать соединение сразу после CONNECT
  • возвращать ошибки без close-события

Для таких случаев тесты моделируют:

  • onerror без onclose
  • мгновенные reconnect loops
  • задержки между handshake и ACK

Цель — убедиться, что клиент не входит в бесконечный цикл без паузы и не блокирует event loop.