Unit тесты для карт

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');
});

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

Для тестирования взаимодействия с картой используется подмена 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 только теми методами, которые реально используются. Избыточная имитация приводит к хрупким тестам.

Тестирование источников данных (sources)

Работа с источниками данных часто включает динамическое обновление 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 } });
});

Изоляция WebGL и проблема окружения

MapLibre GL JS требует WebGL-контекста, который отсутствует в стандартном Node.js окружении. Поэтому применяются стратегии:

  1. Полное мокирование maplibre-gl
  2. Использование jsdom без рендера
  3. Подмена класса 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);
});

Разделение unit и integration тестов

Логика работы с MapLibre GL JS часто требует четкого разделения:

  • Unit-тесты: проверка функций генерации конфигураций, трансформации данных, обработки событий
  • Integration-тесты: взаимодействие с реальной картой в headless-браузере
  • E2E: проверка поведения интерфейса

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

Проверка вызовов API без рендера

Большая часть unit-тестирования сводится к проверке корректных вызовов API:

  • addSource
  • addLayer
  • removeLayer
  • setLayoutProperty
  • setPaintProperty

Пример:

function hideLayer(map, id) {
  map.setLayoutProperty(id, 'visibility', 'none');
}

Тест:

test('hideLayer sets visibility none', () => {
  hideLayer(mapMock, 'users');

  expect(mapMock.setLayoutProperty).toHaveBeenCalledWith(
    'users',
    'visibility',
    'none'
  );
});

Стратегия устойчивых тестов

При работе с картографическим стеком критично избегать тестов, завязанных на внутреннюю реализацию библиотеки. Проверяется только:

  • входные параметры функций
  • вызовы публичных методов API
  • структура возвращаемых объектов
  • реакция на события через mock-слой

Такой подход сохраняет тесты стабильными при обновлении версий MapLibre GL JS и снижает зависимость от WebGL-окружения.