Асинхронная загрузка в Tom Select строится вокруг механизма
load(query, callback), который позволяет подгружать
варианты из удалённого источника по мере ввода пользователя. Внутри
этого механизма обычно используется fetch,
XMLHttpRequest или любая обёртка над HTTP-клиентом.
Поведение компонента становится зависимым от сети, таймингов, отмены
запросов и кэширования, что делает тестирование нетривиальным.
Основная логика асинхронной загрузки в Tom Select включает:
{ 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 должен
быть вызван всегда: при успехе, ошибке и пустом запросе. Это создаёт
основу для тестируемого контракта.
При быстром вводе:
"a")"ab")Ответ от A может прийти позже AB и перезаписать актуальные данные.
Без AbortController старые запросы продолжают
выполняться и влиять на состояние.
Tom Select часто использует задержки ввода. Это усложняет детерминированное тестирование.
Основная цель юнит-тестов — проверить контракт
load(query, callback) без реального DOM и сети.
fetchglobal.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");
});
Важно, что тест проверяет не только вызов, но и сохранение последнего значения.
Типичная защита — сохранение актуального запроса:
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"]);
});
Современный подход предполагает отмену предыдущих запросов.
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();
}
});
}
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();
});
При тестировании Tom Select в реальном DOM проверяется:
Пример с 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);
});
Интеграционные 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");
Асинхронная загрузка часто сопровождается индикатором состояния.
Проверяются состояния:
Пример логики:
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 требует проверки трёх уровней:
Основные риски концентрируются вокруг: