Тестирование асинхронной загрузки

Асинхронная загрузка в Tom Select строится вокруг механизма load(query, callback), который позволяет подгружать варианты из удалённого источника по мере ввода пользователя. Внутри этого механизма обычно используется fetch, XMLHttpRequest или любая обёртка над HTTP-клиентом. Поведение компонента становится зависимым от сети, таймингов, отмены запросов и кэширования, что делает тестирование нетривиальным.

Основная логика асинхронной загрузки в Tom Select включает:

  • реакцию на ввод пользователя;
  • дебаунс запросов;
  • отправку HTTP-запроса;
  • обработку ответа;
  • преобразование данных в формат { value, text };
  • обновление списка опций;
  • обработку ошибок и пустых ответов.

Базовая модель асинхронного загрузчика

Типичная реализация загрузки:

new TomSelect("#select", {
  valueField: "id",
  labelField: "title",
  searchField: "title",

  load(query, callback) {
    if (!query.length) return callback();

    fetch(`/api/items?q=${encodeURIComponent(query)}`)
      .then(res => res.json())
      .then(data => {
        callback(data.items);
      })
      .catch(() => {
        callback();
      });
  }
});

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


Проблемы, возникающие при асинхронной загрузке

1. Гонки запросов (race conditions)

При быстром вводе:

  • запрос A ("a")
  • запрос AB ("ab")

Ответ от A может прийти позже AB и перезаписать актуальные данные.

2. Отсутствие отмены предыдущих запросов

Без AbortController старые запросы продолжают выполняться и влиять на состояние.

3. Дебаунс и задержки

Tom Select часто использует задержки ввода. Это усложняет детерминированное тестирование.

4. Неконсистентные состояния

  • загрузка началась, но ответ не пришёл;
  • ошибка сети;
  • пустой результат;
  • частичный ответ.

Юнит-тестирование асинхронной загрузки

Основная цель юнит-тестов — проверить контракт load(query, callback) без реального DOM и сети.

Мокирование fetch

global.fetch = jest.fn();

Тест успешного ответа:

test("загружает данные и передаёт их в callback", async () => {
  fetch.mockResolvedValue({
    json: async () => ({
      items: [{ id: 1, title: "Apple" }]
    })
  });

  const callback = jest.fn();

  const load = (query, cb) => {
    fetch(`/api?q=${query}`)
      .then(r => r.json())
      .then(d => cb(d.items));
  };

  await load("ap", callback);

  expect(callback).toHaveBeenCalledWith([
    { id: 1, title: "Apple" }
  ]);
});

Тестирование обработки ошибок

test("при ошибке вызывает callback без данных", async () => {
  fetch.mockRejectedValue(new Error("Network error"));

  const callback = jest.fn();

  const load = (query, cb) => {
    fetch(`/api?q=${query}`)
      .then(r => r.json())
      .then(d => cb(d.items))
      .catch(() => cb());
  };

  await load("ap", callback);

  expect(callback).toHaveBeenCalledWith();
});

Тестирование дебаунса

Асинхронная загрузка часто обёрнута в debounce. Здесь применяются fake timers.

jest.useFakeTimers();

test("дебаунсит запросы", () => {
  const load = jest.fn();

  const debounced = debounce(load, 300);

  debounced("a");
  debounced("ab");
  debounced("abc");

  jest.advanceTimersByTime(300);

  expect(load).toHaveBeenCalledTimes(1);
  expect(load).toHaveBeenCalledWith("abc");
});

Важно, что тест проверяет не только вызов, но и сохранение последнего значения.


Борьба с race conditions в тестах

Типичная защита — сохранение актуального запроса:

let lastQuery = "";

function load(query, callback) {
  lastQuery = query;

  fetch(`/api?q=${query}`)
    .then(r => r.json())
    .then(data => {
      if (query !== lastQuery) return;
      callback(data.items);
    });
}

Тест:

test("игнорирует устаревшие ответы", async () => {
  let resolveA;
  let resolveB;

  fetch
    .mockImplementationOnce(() =>
      new Promise(res => (resolveA = res))
    )
    .mockImplementationOnce(() =>
      new Promise(res => (resolveB = res))
    );

  const callback = jest.fn();

  load("a", callback);
  load("ab", callback);

  resolveA({
    json: async () => ({ items: ["old"] })
  });

  resolveB({
    json: async () => ({ items: ["new"] })
  });

  await Promise.resolve();

  expect(callback).toHaveBeenCalledWith(["new"]);
});

Использование AbortController

Современный подход предполагает отмену предыдущих запросов.

let controller;

function load(query, callback) {
  if (controller) controller.abort();

  controller = new AbortController();

  fetch(`/api?q=${query}`, {
    signal: controller.signal
  })
    .then(r => r.json())
    .then(d => callback(d.items))
    .catch(err => {
      if (err.name !== "AbortError") {
        callback();
      }
    });
}

Тестирование AbortController

test("отменяет предыдущий запрос", async () => {
  const abort = jest.fn();

  global.AbortController = jest.fn(() => ({
    signal: {},
    abort
  }));

  fetch.mockReturnValue(
    new Promise(() => {})
  );

  load("a", jest.fn());
  load("ab", jest.fn());

  expect(abort).toHaveBeenCalled();
});

Интеграционное тестирование с DOM

При тестировании Tom Select в реальном DOM проверяется:

  • появление результатов;
  • обновление dropdown;
  • отображение loading состояния;
  • очистка списка.

Пример с Jest + JSDOM:

test("отображает загруженные опции", async () => {
  document.body.innerHTML = `<select id="s"></select>`;

  global.fetch = jest.fn().mockResolvedValue({
    json: async () => ({
      items: [{ id: 1, title: "Apple" }]
    })
  });

  new TomSelect("#s", {
    valueField: "id",
    labelField: "title",
    load(query, callback) {
      fetch("/api?q=" + query)
        .then(r => r.json())
        .then(d => callback(d.items));
    }
  });

  const input = document.querySelector(".ts-control input");
  input.value = "ap";
  input.dispatchEvent(new Event("input"));

  await new Promise(r => setTimeout(r, 0));

  expect(
    document.querySelectorAll(".option").length
  ).toBeGreaterThan(0);
});

Тестирование через Cypress

Интеграционные E2E тесты проверяют поведение в браузере.

cy.intercept("GET", "/api*", {
  items: [{ id: 1, title: "Apple" }]
}).as("search");

cy.get("#select").type("ap");

cy.wait("@search");

cy.get(".option").should("contain", "Apple");

Тестирование состояний загрузки

Асинхронная загрузка часто сопровождается индикатором состояния.

Проверяются состояния:

  • loading start;
  • loading end;
  • empty result;
  • error state.

Пример логики:

load(query, callback) {
  this.setLoading(true);

  fetch(`/api?q=${query}`)
    .then(r => r.json())
    .then(d => callback(d.items))
    .finally(() => this.setLoading(false));
}

Тест:

test("сбрасывает loading после ответа", async () => {
  const instance = {
    setLoading: jest.fn()
  };

  fetch.mockResolvedValue({
    json: async () => ({ items: [] })
  });

  await load.call(instance, "a", jest.fn());

  expect(instance.setLoading).toHaveBeenCalledWith(false);
});

Тестирование пустых и некорректных ответов

Асинхронные API часто возвращают:

  • []
  • null
  • { items: undefined }

Обработка должна быть устойчивой:

.then(data => {
  callback(data.items || []);
});

Тест:

test("обрабатывает пустой ответ", async () => {
  fetch.mockResolvedValue({
    json: async () => ({})
  });

  const callback = jest.fn();

  await load("a", callback);

  expect(callback).toHaveBeenCalledWith([]);
});

Тестирование задержек ответа

Иногда API отвечает с задержкой, что влияет на UI.

jest.useFakeTimers();

test("обрабатывает задержанный ответ", async () => {
  fetch.mockImplementation(
    () =>
      new Promise(res =>
        setTimeout(() =>
          res({
            json: async () => ({
              items: [{ id: 1, title: "A" }]
            })
          }),
          1000
        )
      )
  );

  const callback = jest.fn();

  load("a", callback);

  jest.advanceTimersByTime(1000);
  await Promise.resolve();

  expect(callback).toHaveBeenCalled();
});

Общая стратегия тестирования асинхронной загрузки

Асинхронная загрузка в Tom Select требует проверки трёх уровней:

  • изолированная логика загрузчика (unit);
  • взаимодействие с DOM и состояниями компонента (integration);
  • поведение в реальном браузере (E2E);

Основные риски концентрируются вокруг:

  • гонок запросов;
  • отмены запросов;
  • дебаунса;
  • нестабильных сетевых ответов;
  • неконсистентных состояний UI.