Мокирование Map объекта

Роль Map объекта в архитектуре Mapbox GL JS

В библиотеке Mapbox GL JS ключевым элементом является экземпляр Map, инкапсулирующий состояние карты, управление слоями, источниками данных, событиями и взаимодействием с WebGL-рендерером. Именно через этот объект выполняются основные операции:

  • инициализация карты и привязка к DOM-элементу
  • управление стилем (setStyle, getStyle)
  • добавление и удаление слоёв (addLayer, removeLayer)
  • работа с источниками данных (addSource, removeSource)
  • обработка событий (on, off, once)
  • управление камерой (flyTo, jumpTo, easeTo)
  • получение состояния карты (getZoom, getCenter, getBounds)

В тестовой среде прямое использование реального экземпляра Map приводит к избыточной сложности: требуется DOM, WebGL-контекст, асинхронная инициализация и сетевые запросы к тайлам. Поэтому используется мокирование.


Причины мокирования Mapbox Map

Мокирование Map объекта решает несколько фундаментальных задач тестирования:

  • устранение зависимости от WebGL и браузерного окружения
  • ускорение тестов за счёт отсутствия рендеринга
  • контроль поведения API Mapbox
  • возможность проверки вызовов методов
  • изоляция бизнес-логики от сторонней библиотеки

Особенно критично это в unit-тестах, где проверяется не карта, а логика взаимодействия с ней.


Базовая стратегия: замена Map на stub-объект

Простейший подход заключается в создании заглушки, повторяющей интерфейс 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

При использовании 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())
}));

Такой подход позволяет тестировать сценарии, где важно изменение состояния карты.


Мокирование через dependency injection

Более архитектурно устойчивый способ — отказ от прямого импорта 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.


Мокирование событийной системы Map

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();

Частичное мокирование mapbox-gl (partial mock)

Иногда требуется сохранить часть оригинального API.

jest.mock('mapbox-gl', () => {
  const original = jest.requireActual('mapbox-gl');

  return {
    ...original,
    Map: jest.fn(() => ({
      addLayer: jest.fn(),
      addSource: jest.fn()
    }))
  };
});

Такой подход полезен при интеграционных тестах.


Проблема идентичности интерфейса Mapbox

Mapbox GL JS имеет большой API surface, и ручное поддержание мока становится проблемой. Распространённая стратегия — создание «контрактного мока»:

  • мок реализуется на основе интерфейса, описанного в TypeScript
  • используется автогенерация заглушек
  • применяется минимальный набор реально используемых методов

Пример типизации:

interface IMap {
  addLayer(layer: any): void;
  addSource(id: string, source: any): void;
  removeLayer(id: string): void;
  on(event: string, handler: Function): void;
}

И реализация мока строго под этот контракт.


Тестирование взаимодействий с Map

Ключевая цель мокирования — проверка взаимодействий, а не состояния Mapbox.

Пример сценария:

service.enableHeatmap();

expect(map.addSource).toHaveBeenCalledWith(
  'heatmap-source',
  expect.any(Object)
);

expect(map.addLayer).toHaveBeenCalledWith(
  expect.objectContaining({
    id: 'heatmap-layer'
  })
);

Расширенные техники: spy поверх реального Map

Иногда требуется частичный контроль без полного мока:

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 Map

Мокирование неизбежно вводит расхождение с реальным поведением:

  • невозможность тестировать WebGL-рендеринг
  • упрощённая модель событий
  • отсутствие реальной геометрической логики
  • риск расхождения API мока и библиотеки

Поэтому мок используется только для логики уровня приложения, а не для проверки поведения Mapbox GL JS как системы рендеринга.