Юнит-тестирование

Библиотека Leaflet тесно связана с DOM, графическим контекстом и асинхронной загрузкой тайлов, что формирует специфический профиль тестирования. В отличие от чистых вычислительных модулей, здесь значительная часть логики завязана на браузерное окружение, события мыши, геометрические преобразования координат и взаимодействие слоёв карты.

Ключевая сложность заключается в том, что большая часть функциональности Leaflet опирается на реальные браузерные API: HTMLElement, Canvas, requestAnimationFrame, обработчики событий и сетевые запросы к тайл-серверам. Поэтому юнит-тестирование требует частичной эмуляции среды выполнения.

Изоляция логики от браузерного окружения

При тестировании компонентов Leaflet важно разделять:

  • чистую логику (расчёты координат, трансформации, управление состоянием слоёв)
  • взаимодействие с DOM
  • сетевые запросы (тайлы, GeoJSON)

Чистая логика может тестироваться напрямую, тогда как DOM-часть требует эмуляции через инструменты вроде JSDOM или jsdom-like окружений.

Пример выделения логики:

function latLngToTile(lat, lng, zoom) {
  const n = Math.pow(2, zoom);
  const x = Math.floor((lng + 180) / 360 * n);
  const y = Math.floor(
    (1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n
  );
  return { x, y };
}

Такую функцию можно тестировать без Leaflet и без карты:

test('latLngToTile converts coordinates correctly', () => {
  const result = latLngToTile(55.75, 37.62, 10);
  expect(result).toEqual({ x: expect.any(Number), y: expect.any(Number) });
});

Использование Jest и JSDOM

Наиболее распространённая связка для тестирования Leaflet-кода — Jest с JSDOM. JSDOM эмулирует DOM-структуру, позволяя создавать контейнер карты без реального браузера.

Инициализация тестового окружения:

import { JSDOM } from 'jsdom';

const dom = new JSDOM('<!DOCTYPE html><div id="map"></div>');
global.document = dom.window.document;
global.window = dom.window;

Создание карты в тесте:

import L from 'leaflet';

test('map initializes without errors', () => {
  const map = L.map('map').setView([51.505, -0.09], 13);
  expect(map).toBeDefined();
});

Важно учитывать, что JSDOM не реализует canvas и часть геометрических API, поэтому визуальные операции часто требуют моков.

Мокирование тайловых слоёв

Сетевые запросы к тайлам являются одной из основных проблем при тестировании. Реальные HTTP-запросы недопустимы в юнит-тестах, поэтому используется подмена L.TileLayer.

Пример заглушки:

class MockTileLayer {
  constructor() {
    this.tiles = {};
  }

  addTo(map) {
    this.map = map;
    return this;
  }

  getTileUrl(coords) {
    return `http://example.com/${coords.z}/${coords.x}/${coords.y}.png`;
  }
}

Подмена в тесте:

L.TileLayer = MockTileLayer;

test('tile layer generates url', () => {
  const layer = new L.TileLayer();
  const url = layer.getTileUrl({ x: 1, y: 2, z: 3 });
  expect(url).toBe('http://example.com/3/1/2.png');
});

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

Leaflet активно использует событийную модель: click, zoom, move, load. Тестирование этих механизмов требует симуляции DOM-событий.

Пример теста клика:

test('map click event triggers handler', () => {
  const map = L.map('map').setView([0, 0], 1);

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

  map.fire('click', {
    latlng: L.latLng(10, 10)
  });

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

Метод fire позволяет вызывать события без реального взаимодействия с интерфейсом.

Проверка слоёв и их состояния

Слои Leaflet представляют собой отдельные сущности, добавляемые и удаляемые с карты. Юнит-тестирование слоёв фокусируется на их жизненном цикле.

test('layer can be added and removed', () => {
  const map = L.map('map');
  const layer = L.marker([0, 0]);

  layer.addTo(map);
  expect(map.hasLayer(layer)).toBe(true);

  map.removeLayer(layer);
  expect(map.hasLayer(layer)).toBe(false);
});

Особое внимание уделяется корректности обновления внутренних коллекций слоёв.

Тестирование GeoJSON-слоёв

GeoJSON-слои являются одной из наиболее сложных структур из-за вложенности данных и стилей.

test('geojson layer applies style', () => {
  const geojson = {
    type: 'Feature',
    geometry: {
      type: 'Point',
      coordinates: [30, 10]
    }
  };

  const layer = L.geoJSON(geojson, {
    pointToLayer: () => L.circleMarker([10, 30])
  });

  expect(layer.getLayers().length).toBe(1);
});

Проверяется корректность парсинга и генерации внутренних слоёв.

Работа с асинхронностью тайлов

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

Подмена Image:

global.Image = class {
  constructor() {
    setTimeout(() => {
      this.onload && this.onload();
    }, 0);
  }
};

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

test('tile image loads successfully', async () => {
  const layer = L.tileLayer('http://example.com/{z}/{x}/{y}.png');
  layer.addTo(L.map('map'));

  await new Promise(resolve => setTimeout(resolve, 10));

  expect(layer.isLoading).toBeDefined();
});

Тестирование кастомных контролов

Контролы Leaflet (zoom, layers, custom buttons) требуют проверки DOM-структуры и событий.

const CustomControl = L.Control.extend({
  onAdd: function () {
    const div = L.DomUtil.create('div', 'custom-control');
    div.innerHTML = 'Test';
    return div;
  }
});

Тест:

test('custom control renders DOM element', () => {
  const map = L.map('map');
  const control = new CustomControl();

  control.addTo(map);

  const el = document.querySelector('.custom-control');
  expect(el).not.toBeNull();
});

Моки географических преобразований

Leaflet использует проекцию Web Mercator, что требует проверки корректности преобразований координат.

test('projection converts correctly', () => {
  const point = L.CRS.EPSG3857.project(L.latLng(0, 0));
  expect(point.x).toBe(0);
  expect(point.y).toBe(0);
});

При этом важно учитывать погрешности вычислений с плавающей точкой.

Тестирование производительности вычислений

Хотя это ближе к интеграционному тестированию, часто проверяется скорость пересчёта координат и обновления слоёв.

test('projection performance under load', () => {
  const start = performance.now();

  for (let i = 0; i < 10000; i++) {
    L.CRS.EPSG3857.project(L.latLng(i % 90, i % 180));
  }

  const end = performance.now();
  expect(end - start).toBeLessThan(200);
});

Стратегии подмены DOM-операций

Leaflet активно использует classList, appendChild, removeChild. При тестировании применяются следующие подходы:

  • частичная подмена методов L.DomUtil
  • использование spy-функций
  • переопределение document.createElement

Пример:

jest.spyOn(L.DomUtil, 'addClass');

L.DomUtil.addClass(document.createElement('div'), 'test');

expect(L.DomUtil.addClass).toHaveBeenCalled();

Тестирование плагинов Leaflet

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

test('plugin registers method on map', () => {
  L.Map.include({
    testPlugin: function () {
      return true;
    }
  });

  const map = L.map('map');
  expect(map.testPlugin()).toBe(true);
});

Проверка состояния после уничтожения карты

Корректное освобождение ресурсов важно для предотвращения утечек памяти.

test('map is properly removed', () => {
  const map = L.map('map');
  map.remove();

  expect(map._container).toBeNull();
});

Особое внимание уделяется очистке событий и таймеров.

Подмена requestAnimationFrame

Анимации зума и панорамирования требуют контроля времени выполнения.

global.requestAnimationFrame = (cb) => setTimeout(cb, 0);

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

Проверка поведения при ошибках

Юнит-тестирование включает сценарии некорректных данных:

  • пустые координаты
  • некорректный GeoJSON
  • отсутствующие тайлы
test('invalid latlng throws or handles gracefully', () => {
  expect(() => L.latLng(null, null)).toThrow();
});

Такие тесты фиксируют устойчивость API к ошибкам входных данных.