В библиотеке Mapbox GL JS ключевым элементом является экземпляр
Map, инкапсулирующий состояние карты, управление слоями,
источниками данных, событиями и взаимодействием с WebGL-рендерером.
Именно через этот объект выполняются основные операции:
setStyle,
getStyle)addLayer,
removeLayer)addSource,
removeSource)on, off,
once)flyTo, jumpTo,
easeTo)getZoom,
getCenter, getBounds)В тестовой среде прямое использование реального экземпляра
Map приводит к избыточной сложности: требуется DOM,
WebGL-контекст, асинхронная инициализация и сетевые запросы к тайлам.
Поэтому используется мокирование.
Мокирование Map объекта решает несколько фундаментальных задач тестирования:
Особенно критично это в unit-тестах, где проверяется не карта, а логика взаимодействия с ней.
Простейший подход заключается в создании заглушки, повторяющей
интерфейс Map.
export class MapMock {
constructor(options = {}) {
this.options = options;
this.layers = [];
this.sources = {};
this.events = {};
}
addLayer(layer) {
this.layers.push(layer);
}
removeLayer(id) {
this.layers = this.layers.filter(l => l.id !== id);
}
addSource(id, source) {
this.sources[id] = source;
}
removeSource(id) {
delete this.sources[id];
}
on(event, handler) {
this.events[event] = this.events[event] || [];
this.events[event].push(handler);
}
off(event, handler) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(h => h !== handler);
}
trigger(event, payload) {
(this.events[event] || []).forEach(h => h(payload));
}
getZoom() {
return 10;
}
getCenter() {
return { lng: 0, lat: 0 };
}
setStyle() {}
flyTo() {}
jumpTo() {}
}
Такой подход обеспечивает контроль над состоянием карты без необходимости запускать Mapbox GL JS.
При использовании Jest часто применяется автоматическая подмена
модуля mapbox-gl.
jest.mock('mapbox-gl', () => {
return {
Map: jest.fn().mockImplementation(() => ({
addLayer: jest.fn(),
removeLayer: jest.fn(),
addSource: jest.fn(),
removeSource: jest.fn(),
on: jest.fn(),
off: jest.fn(),
flyTo: jest.fn(),
setStyle: jest.fn(),
getZoom: jest.fn(() => 12),
getCenter: jest.fn(() => ({ lng: 1, lat: 2 }))
}))
};
});
В этом случае каждый метод становится spy-функцией, позволяющей отслеживать вызовы:
expect(Map.prototype.addLayer).toHaveBeenCalled();
Статические jest.fn() ограничены отсутствием внутреннего
состояния. Более сложный вариант — фабрика моков:
const createMapMock = () => {
const state = {
layers: [],
sources: {}
};
return {
addLayer: jest.fn((layer) => {
state.layers.push(layer);
}),
getLayers: jest.fn(() => state.layers),
addSource: jest.fn((id, source) => {
state.sources[id] = source;
}),
getSource: jest.fn((id) => state.sources[id]),
removeSource: jest.fn((id) => {
delete state.sources[id];
})
};
};
jest.mock('mapbox-gl', () => ({
Map: jest.fn(() => createMapMock())
}));
Такой подход позволяет тестировать сценарии, где важно изменение состояния карты.
Более архитектурно устойчивый способ — отказ от прямого импорта
mapbox-gl внутри бизнес-логики.
export class MapService {
constructor(mapInstance) {
this.map = mapInstance;
}
addMainLayer() {
this.map.addLayer({
id: 'main',
type: 'fill',
source: 'data'
});
}
}
Тест:
const mapMock = {
addLayer: jest.fn()
};
const service = new MapService(mapMock);
service.addMainLayer();
expect(mapMock.addLayer).toHaveBeenCalledWith({
id: 'main',
type: 'fill',
source: 'data'
});
Такой подход полностью устраняет необходимость мокировать сам
mapbox-gl.
Mapbox GL активно использует события (load,
click, move), поэтому требуется имитация event
emitter.
class EventMapMock {
constructor() {
this.handlers = {};
}
on(event, cb) {
this.handlers[event] = this.handlers[event] || [];
this.handlers[event].push(cb);
}
off(event, cb) {
this.handlers[event] = (this.handlers[event] || []).filter(h => h !== cb);
}
emit(event, payload) {
(this.handlers[event] || []).forEach(cb => cb(payload));
}
}
Расширение под Mapbox:
class MapMock extends EventMapMock {
addLayer = jest.fn();
addSource = jest.fn();
setStyle = jest.fn();
}
Mapbox Map часто вызывает load асинхронно. Для
тестирования важно контролировать момент завершения загрузки.
class AsyncMapMock {
constructor() {
this.loaded = false;
this.events = {};
}
on(event, cb) {
this.events[event] = cb;
}
simulateLoad() {
this.loaded = true;
if (this.events['load']) {
this.events['load']();
}
}
isLoaded() {
return this.loaded;
}
}
Использование:
const map = new AsyncMapMock();
map.on('load', () => {
// логика после загрузки
});
map.simulateLoad();
Иногда требуется сохранить часть оригинального API.
jest.mock('mapbox-gl', () => {
const original = jest.requireActual('mapbox-gl');
return {
...original,
Map: jest.fn(() => ({
addLayer: jest.fn(),
addSource: jest.fn()
}))
};
});
Такой подход полезен при интеграционных тестах.
Mapbox GL JS имеет большой API surface, и ручное поддержание мока становится проблемой. Распространённая стратегия — создание «контрактного мока»:
Пример типизации:
interface IMap {
addLayer(layer: any): void;
addSource(id: string, source: any): void;
removeLayer(id: string): void;
on(event: string, handler: Function): void;
}
И реализация мока строго под этот контракт.
Ключевая цель мокирования — проверка взаимодействий, а не состояния Mapbox.
Пример сценария:
service.enableHeatmap();
expect(map.addSource).toHaveBeenCalledWith(
'heatmap-source',
expect.any(Object)
);
expect(map.addLayer).toHaveBeenCalledWith(
expect.objectContaining({
id: 'heatmap-layer'
})
);
Иногда требуется частичный контроль без полного мока:
import mapboxgl from 'mapbox-gl';
const originalMap = mapboxgl.Map;
mapboxgl.Map = function (...args) {
const instance = new originalMap(...args);
jest.spyOn(instance, 'addLayer');
jest.spyOn(instance, 'addSource');
return instance;
};
Этот подход применяется при интеграционных тестах с реальным рендерингом.
Мокирование неизбежно вводит расхождение с реальным поведением:
Поэтому мок используется только для логики уровня приложения, а не для проверки поведения Mapbox GL JS как системы рендеринга.