Unit-тестирование картографических приложений на MapLibre GL JS требует особого подхода из-за тесной связи библиотеки с WebGL, асинхронной загрузкой ресурсов и сложной внутренней моделью состояния карты. В отличие от обычной бизнес-логики, здесь значительная часть поведения скрыта внутри графического контекста и событийного цикла, что делает прямое тестирование через DOM недостаточным.
Ключевая проблема тестирования — невозможность надежно и быстро
создавать полноценный Map в среде unit-тестов без WebGL.
Поэтому основная стратегия заключается в выносе логики из слоя карты в
чистые функции.
Типичный анти-паттерн:
function addUserLayer(map) {
map.addLayer({
id: 'users',
type: 'circle',
source: 'users-source'
});
}
Такой код сложно тестировать без поднятия карты.
Более тестируемый вариант:
function createUserLayer() {
return {
id: 'users',
type: 'circle',
source: 'users-source'
};
}
function addUserLayer(map) {
map.addLayer(createUserLayer());
}
Теперь unit-тест проверяет только чистую функцию:
test('createUserLayer returns valid layer', () => {
const layer = createUserLayer();
expect(layer.type).toBe('circle');
expect(layer.source).toBe('users-source');
});
Для тестирования взаимодействия с картой используется подмена API
Map. Создается mock-объект, имитирующий только нужные
методы.
const mapMock = {
addLayer: jest.fn(),
addSource: jest.fn(),
on: jest.fn(),
getSource: jest.fn()
};
Такой подход позволяет проверять поведение без запуска WebGL:
test('addUserLayer calls map.addLayer', () => {
addUserLayer(mapMock);
expect(mapMock.addLayer).toHaveBeenCalledTimes(1);
});
Важно ограничивать mock только теми методами, которые реально используются. Избыточная имитация приводит к хрупким тестам.
Работа с источниками данных часто включает динамическое обновление GeoJSON. Логика формирования источника должна тестироваться отдельно от MapLibre.
function createUserSource(users) {
return {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: users.map(u => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [u.lng, u.lat]
},
properties: { id: u.id }
}))
}
};
}
Unit-тест проверяет структуру:
test('createUserSource builds valid GeoJSON', () => {
const source = createUserSource([{ id: 1, lng: 10, lat: 20 }]);
expect(source.type).toBe('geojson');
expect(source.data.features).toHaveLength(1);
expect(source.data.features[0].geometry.type).toBe('Point');
});
MapLibre активно использует события (load,
click, move). В unit-тестах события не должны
зависеть от реального рендера. Используется ручная эмуляция:
function bindClickHandler(map, handler) {
map.on('click', handler);
}
Тест:
test('bindClickHandler registers click event', () => {
const handler = jest.fn();
bindClickHandler(mapMock, handler);
expect(mapMock.on).toHaveBeenCalledWith('click', handler);
});
Для проверки реакции на событие можно вручную вызвать callback:
mapMock.on.mockImplementation((event, cb) => {
if (event === 'click') cb({ lngLat: { lng: 10, lat: 20 } });
});
MapLibre GL JS требует WebGL-контекста, который отсутствует в стандартном Node.js окружении. Поэтому применяются стратегии:
maplibre-gljsdom без рендераMapПример заглушки:
jest.mock('maplibre-gl', () => ({
Map: jest.fn(() => mapMock)
}));
Это позволяет тестировать код, который зависит от конструктора карты, не создавая реальный canvas.
Часто карта выступает как визуализация внешнего состояния. Логика синхронизации состояния должна быть вынесена:
function updateMapData(map, data) {
const source = map.getSource('users');
if (source) {
source.setData(data);
}
}
Тестирование:
const setDataMock = jest.fn();
mapMock.getSource.mockReturnValue({
setData: setDataMock
});
test('updateMapData updates source', () => {
const data = { type: 'FeatureCollection', features: [] };
updateMapData(mapMock, data);
expect(setDataMock).toHaveBeenCalledWith(data);
});
Стили в MapLibre представляют собой большие JSON-объекты. Их следует проверять на уровне структуры, а не полного соответствия.
function createBaseStyle() {
return {
version: 8,
sources: {},
layers: []
};
}
Unit-тесты проверяют только критические поля:
test('createBaseStyle has required fields', () => {
const style = createBaseStyle();
expect(style.version).toBe(8);
expect(style.sources).toBeDefined();
expect(Array.isArray(style.layers)).toBe(true);
});
MapLibre загружает тайлы, изображения и шрифты асинхронно. В unit-тестах такие операции заменяются на промисы-заглушки.
function loadIcon(map, url) {
return new Promise(resolve => {
map.loadImage(url, (err, img) => {
map.addImage('icon', img);
resolve(img);
});
});
}
Тест:
test('loadIcon resolves image', async () => {
const imgMock = {};
mapMock.loadImage = jest.fn((url, cb) => cb(null, imgMock));
mapMock.addImage = jest.fn();
const result = await loadIcon(mapMock, 'icon.png');
expect(mapMock.addImage).toHaveBeenCalledWith('icon', imgMock);
expect(result).toBe(imgMock);
});
Логика работы с MapLibre GL JS часто требует четкого разделения:
Unit-тесты не должны создавать экземпляр Map без
необходимости.
Контролы MapLibre реализуют интерфейс onAdd и
onRemove.
class ZoomControl {
onAdd(map) {
this.map = map;
this.container = document.createElement('div');
return this.container;
}
onRemove() {
this.container.remove();
this.map = null;
}
}
Тестирование:
test('ZoomControl lifecycle', () => {
const control = new ZoomControl();
const container = control.onAdd(mapMock);
expect(container).toBeDefined();
expect(control.map).toBe(mapMock);
control.onRemove();
expect(control.map).toBeNull();
});
Большая часть unit-тестирования сводится к проверке корректных вызовов API:
addSourceaddLayerremoveLayersetLayoutPropertysetPaintPropertyПример:
function hideLayer(map, id) {
map.setLayoutProperty(id, 'visibility', 'none');
}
Тест:
test('hideLayer sets visibility none', () => {
hideLayer(mapMock, 'users');
expect(mapMock.setLayoutProperty).toHaveBeenCalledWith(
'users',
'visibility',
'none'
);
});
При работе с картографическим стеком критично избегать тестов, завязанных на внутреннюю реализацию библиотеки. Проверяется только:
Такой подход сохраняет тесты стабильными при обновлении версий MapLibre GL JS и снижает зависимость от WebGL-окружения.