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

Взаимодействие в Mapbox GL JS строится вокруг событий карты, слоёв, источников данных и пользовательских жестов. Основная сложность тестирования заключается в том, что библиотека опирается на WebGL, асинхронную отрисовку и внутренний event loop рендерера, что делает традиционные unit-тесты недостаточными без дополнительной инфраструктуры.

Особенности интерактивной модели Mapbox GL JS

Mapbox GL JS оперирует несколькими слоями взаимодействия:

  • события карты (click, mousemove, mousedown, mouseup, drag)
  • события слоёв (mouseenter, mouseleave, click по слоям)
  • источники данных (GeoJSONSource, VectorSource)
  • рендеринг через WebGL
  • внутренний цикл отрисовки (render, idle, data)

Ключевой момент: большинство интерактивных событий не являются DOM-событиями в классическом смысле. Они преобразуются через WebGL hit-testing и внутреннюю математику координат.

Это приводит к необходимости тестировать не только обработчики событий, но и состояние карты, источников и слоёв после взаимодействия.


Проблема окружения WebGL в тестах

Стандартные тестовые среды (Jest, Vitest) используют jsdom, который не поддерживает WebGL. Это означает, что прямое создание карты:

new mapboxgl.Map({ container: div, style: 'mapbox://styles/...' })

вызовет ошибки или не будет рендерить слои.

Используются несколько подходов:

1. Мокирование Mapbox GL JS

Наиболее распространённый подход — замена библиотеки на mock:

jest.mock('mapbox-gl', () => ({
  Map: class {
    on() {}
    off() {}
    addSource() {}
    addLayer() {}
    removeLayer() {}
    removeSource() {}
    getLayer() { return {}; }
    getSource() { return {}; }
    project(lngLat) {
      return { x: lngLat.lng * 10, y: lngLat.lat * 10 };
    }
  }
}));

Этот подход позволяет тестировать бизнес-логику взаимодействия, но не реальную отрисовку.


2. Использование headless WebGL (node-canvas / headless-gl)

Более приближённый к реальности вариант — использование WebGL polyfill:

  • headless-gl
  • node-canvas
  • jsdom + WebGL context stub

Пример:

import { createCanvas } from 'canvas';
import gl from 'gl';

global.document = {};
global.window = {};
global.HTMLCanvasElement = createCanvas;

const canvas = createCanvas(512, 512);
const context = gl(512, 512, { preserveDrawingBuffer: true });

Этот подход позволяет Mapbox GL JS реально инициализироваться, но остаётся нестабильным для сложных сцен.


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

Клик по карте

Ключевая задача — проверить, что обработчик получает корректные координаты и состояние слоёв обновляется.

map.on('click', 'cities-layer', (e) => {
  setSelectedFeature(e.features[0]);
});

Тестирование логики:

test('selects feature on click', () => {
  const map = createMockMap();

  const handler = jest.fn();
  map.on('click', 'cities-layer', handler);

  map.trigger('click', {
    features: [{ id: 1, properties: { name: 'City' } }]
  });

  expect(handler).toHaveBeenCalled();
});

Здесь используется искусственный метод trigger, добавляемый в mock-объект карты.


Наведение курсора

Hover-события важны для UI-интеракций:

map.on('mouseenter', 'points-layer', () => {
  map.getCanvas().style.cursor = 'pointer';
});

Тест:

test('changes cursor on hover', () => {
  const map = createMockMap();
  const canvas = map.getCanvas();

  map.trigger('mouseenter', {});

  expect(canvas.style.cursor).toBe('pointer');
});

Тестирование слоёв и источников

Добавление GeoJSON источника

map.addSource('points', {
  type: 'geojson',
  data: {
    type: 'FeatureCollection',
    features: []
  }
});

Проверка:

test('adds geojson source', () => {
  const map = createMockMap();

  map.addSource('points', { type: 'geojson', data: {} });

  expect(map.getSource('points')).toBeDefined();
});

Обновление данных источника

const source = map.getSource('points');
source.setData(newData);

Тестирование важно для динамических приложений (например, real-time трекинг):

test('updates source data', () => {
  const map = createMockMap();
  const source = createMockSource();

  map.getSource = () => source;

  const data = { type: 'FeatureCollection', features: [] };

  source.setData = jest.fn();

  source.setData(data);

  expect(source.setData).toHaveBeenCalledWith(data);
});

Тестирование всплывающих окон (Popup)

Popup в Mapbox GL JS часто используется для отображения информации:

const popup = new mapboxgl.Popup()
  .setLngLat([30, 50])
  .setHTML('<div>Info</div>')
  .addTo(map);

Тестирование обычно ограничивается проверкой вызова методов:

test('creates popup with content', () => {
  const popup = createMockPopup();

  popup.setHTML('<div>Info</div>');

  expect(popup.setHTML).toHaveBeenCalledWith('<div>Info</div>');
});

Интеграционное тестирование взаимодействий

Unit-тесты недостаточны для проверки поведения карты как системы. Интеграционные тесты чаще всего реализуются через:

  • Playwright
  • Cypress

Пример Playwright

test('click selects feature on map', async ({ page }) => {
  await page.goto('http://localhost:3000');

  await page.click('.mapboxgl-canvas');

  const selected = await page.evaluate(() =>
    window.__selectedFeature
  );

  expect(selected).toBeDefined();
});

Mapbox GL JS в таком случае работает в реальном браузере с WebGL, что делает тесты более надёжными.


Симуляция пользовательских событий

Mapbox GL JS использует собственную систему координат, поэтому прямые DOM-события недостаточны.

Для тестирования часто создаются утилиты:

function clickMap(map, lng, lat) {
  map.fire('click', {
    lngLat: { lng, lat },
    point: map.project({ lng, lat })
  });
}

Это позволяет тестировать бизнес-логику без реального рендеринга.


Тестирование фильтров слоёв

map.setFilter('cities-layer', ['==', ['get', 'type'], 'city']);

Проверка:

test('applies filter to layer', () => {
  const map = createMockMap();

  map.setFilter('cities-layer', ['==', 'type', 'city']);

  expect(map.setFilter).toHaveBeenCalled();
});

Асинхронность рендеринга

Mapbox GL JS работает асинхронно, поэтому критично учитывать события:

  • load
  • render
  • idle
map.on('load', () => {
  map.addLayer(layer);
});

Тест:

test('adds layer after load', () => {
  const map = createMockMap();

  const handler = jest.fn();
  map.on('load', handler);

  map.trigger('load');

  expect(handler).toHaveBeenCalled();
});

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

Перемещение карты связано с состоянием камеры:

map.on('move', () => {
  updateViewport(map.getCenter());
});

Проверка:

test('updates viewport on move', () => {
  const map = createMockMap();

  map.getCenter = () => ({ lng: 10, lat: 20 });

  const spy = jest.fn();
  map.on('move', () => spy(map.getCenter()));

  map.trigger('move');

  expect(spy).toHaveBeenCalledWith({ lng: 10, lat: 20 });
});

Изоляция логики взаимодействия

Практика разделения логики:

  • слой Mapbox GL JS (только рендер и события)
  • слой бизнес-логики (обработка данных)
  • слой состояния (Redux, Zustand, MobX)

Пример:

function handleMapClick(feature, dispatch) {
  dispatch({ type: 'SELECT_FEATURE', payload: feature });
}

Тестирование без Mapbox:

test('dispatches selected feature', () => {
  const dispatch = jest.fn();

  handleMapClick({ id: 1 }, dispatch);

  expect(dispatch).toHaveBeenCalledWith({
    type: 'SELECT_FEATURE',
    payload: { id: 1 }
  });
});

Работа с requestAnimationFrame

Mapbox GL JS активно использует requestAnimationFrame, что влияет на тесты.

Паттерн стабилизации:

beforeEach(() => {
  jest.useFakeTimers();
});

test('renders frame', () => {
  const map = createMockMap();

  map.trigger('render');

  jest.runAllTimers();

  expect(true).toBe(true);
});

Типичные ошибки при тестировании взаимодействий

  • попытка тестировать WebGL-рендер в jsdom без моков
  • отсутствие симуляции событий fire / trigger
  • игнорирование асинхронных состояний карты
  • привязка тестов к DOM-структуре canvas
  • отсутствие контроля состояния источников данных

Подход к построению тестовой архитектуры

Стабильная стратегия обычно включает:

  • mock Mapbox GL JS для unit-тестов
  • интеграционные тесты в браузере
  • изолированную бизнес-логику без зависимости от карты
  • утилиты для симуляции событий
  • контроль состояния источников и слоёв через интерфейсы-обёртки