Testing your maps
Unit-test components that render ng-map in jsdom, without WebGL, with the same MapLibre fake the library tests itself with.
How it works
# MapLibre needs WebGL, which jsdom does not have, and ng-mapcn imports maplibre-gl statically. So the testing toolkit replaces the whole module: a fake with the same surface (Map, Marker, Popup, ...) that records every call, fires events on demand and renders markers, popups and controls into the map container, so you can query them like any other DOM. Your test runner swaps the module through its module mocking.
Three entry points ship in the ng-mapcn package:
| Entry point | Type | Description |
|---|---|---|
ng-mapcn/testing/maplibre | Map, Marker, Popup, Point, fakeRegistry, configureFakeMapLibre | The runner-neutral fake that replaces maplibre-gl. It imports nothing, so it works with any runner. |
ng-mapcn/testing | renderMap, loadMap, reloadStyle, getFakeMap, structuralCalls, MAP_TEST_SELECTORS, ... | Helpers that render a template with ng-map and drive the fake, and the DOM selectors for any test. No @angular/cdk. |
ng-mapcn/testing/harness | NgMapHarness, NgMapMarkerHarness, NgMapPopupHarness, NgMapControlsHarness | Angular CDK component harnesses. Needs the optional @angular/cdk peer. |
Vitest setup
# Vitest is the first-class runner (it is what Angular's unit-test builder uses). Two files are needed. The setup file mocks maplibre-gl and turns the fake's methods into vi.fn() spies:
import { configureFakeMapLibre, fakeRegistry } from 'ng-mapcn/testing/maplibre';
import { beforeEach, vi } from 'vitest';
// Replace maplibre-gl with the fake for every spec.
vi.mock('maplibre-gl', () => import('ng-mapcn/testing/maplibre'));
// Make the fake's methods vi.fn() spies (jumpTo, panBy, setStyle, setData, ...).
configureFakeMapLibre({ spy: vi.fn });
beforeEach(() => fakeRegistry.reset()); The config tells Vitest to process ng-mapcn itself instead of loading it from node_modules:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Without this, Node loads ng-mapcn from node_modules and it
// imports the real maplibre-gl, so the mock never applies.
server: { deps: { inline: [/ng-mapcn/] } },
},
});Then point the Angular CLI test target at both files:
"test": {
"builder": "@angular/build:unit-test",
"options": {
"setupFiles": ["src/testing/setup.ts"],
"runnerConfig": "vitest.config.mts"
}
}server.deps.inline Node loads the packaged ng-mapcn as an external module, which imports the real maplibre-gl and ignores vi.mock. The pattern also covers the ng-mapcn/testing* entry points, so the fake stays a single module instance shared by the library, the helpers and your specs. Jest setup
# Jest maps the module instead of mocking it. Point maplibre-gl at the fake and pass jest.fn as the spy factory:
// jest.config.js
module.exports = {
setupFilesAfterEnv: ['<rootDir>/src/testing/setup.ts'],
moduleNameMapper: {
'^maplibre-gl$': 'ng-mapcn/testing/maplibre',
},
};// src/testing/setup.ts
import { configureFakeMapLibre, fakeRegistry } from 'ng-mapcn/testing/maplibre';
configureFakeMapLibre({ spy: jest.fn });
beforeEach(() => fakeRegistry.reset());ng-mapcn also needs your usual Angular transform for Jest. Please open an issue if you hit a problem. Karma and Jasmine are not supported. They cannot replace an ES module, so there is no way to swap maplibre-gl for the fake.
Your first spec
#renderMap() renders a template inside a host that imports ng-map and returns the fixture, the fake map and the MapContextService. Layer components wait for the map to load, so call loadMap() before asserting on sources and layers:
import { TestBed } from '@angular/core/testing';
import { MapMarkerComponent, MapRouteComponent } from 'ng-mapcn';
import { getFakeMap, loadMap, renderMap, resetMapTesting, structuralCalls } from 'ng-mapcn/testing';
import { fakeRegistry } from 'ng-mapcn/testing/maplibre';
import { afterEach, describe, expect, it } from 'vitest';
const TEMPLATE = `
<ng-map>
<ng-map-marker [longitude]="2" [latitude]="3" />
<ng-map-route id="trip" [coordinates]="coordinates" />
</ng-map>
`;
describe('delivery map', () => {
afterEach(() => {
TestBed.resetTestingModule();
resetMapTesting();
});
it('adds the route once the style has loaded', async () => {
const { fixture, ctx } = await renderMap(TEMPLATE, {
imports: [MapMarkerComponent, MapRouteComponent],
hostProps: {
coordinates: [
[0, 0],
[1, 1],
],
},
});
const map = getFakeMap(fixture);
// Nothing is added until the map has loaded: layer components wait for ctx.isLoaded().
expect(ctx.isLoaded()).toBe(false);
expect(structuralCalls(map)).toEqual([]);
await loadMap(fixture);
expect(ctx.isLoaded()).toBe(true);
expect(structuralCalls(map)).toEqual(['addSource:trip', 'addLayer:trip-layer']);
expect(map.getLayer('trip-layer')).toBeDefined();
expect(fakeRegistry.markers).toHaveLength(1);
expect(fakeRegistry.markers[0].getLngLat()).toEqual({ lng: 2, lat: 3 });
});
});This spec is not pseudo-code: the demo's own test suite runs this exact text and fails if the page and the test drift apart.
Helpers
# Everything below is exported by ng-mapcn/testing. Call resetMapTesting() in afterEach: renderMap() patches the layout getters jsdom lacks.
| Function | Type | Description |
|---|---|---|
renderMap | (template, { imports, providers, hostProps, size }) => Promise<{ fixture, map, ctx }> | Renders the template inside a standalone host, with ng-map imported, and runs change detection until the map exists. Size defaults to 400x300 because jsdom has no layout. The map is not loaded yet. Call it once per test. |
loadMap | (fixture, map?) => Promise<void> | Simulates the first style load: fires load and styledata, waits out the 100 ms debounce, then runs change detection so layer components add their sources and layers. Throws when fake timers are installed without advanceTimers. |
reloadStyle | (fixture, map?) => Promise<void> | Simulates the style requested by setStyle (a theme or styles change) arriving: every source and layer is dropped and the components re-add them. |
update | (fixture, props) => void | Assigns host properties, marks the host for check and runs change detection, so it works with and without zone.js. |
fireMapEvent | (map, event, payload?, layerId?) => void | Fires a map event like MapLibre would. Pass a layer id for layer events, as with map.on('click', layerId, handler). |
getFakeMap | (fixture) => FakeMap | The fake map of the first ng-map. Throws if the map was not created (zero container size). |
structuralCalls | (map) => string[] | The ordered source and layer add and remove operations. Stable format. |
setMapSize | ({ width, height } | null) => void | Changes the container size the map sees, also after rendering. null means zero size, which keeps ng-map from creating its map. |
configureMapTesting | ({ advanceTimers? }) => void | Sets how loadMap and reloadStyle wait: see Fake timers. |
resetMapTesting | () => void | Restores the layout getters renderMap patched and clears fakeRegistry. Call it in afterEach. |
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { MapRouteComponent } from 'ng-mapcn';
import {
fireMapEvent,
getFakeMap,
loadMap,
reloadStyle,
renderMap,
resetMapTesting,
structuralCalls,
} from 'ng-mapcn/testing';
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('route interactions', () => {
afterEach(() => {
TestBed.resetTestingModule();
resetMapTesting();
});
it('emits routeClick when the route layer is clicked', async () => {
const routeClick = vi.fn();
const { fixture } = await renderMap(
`<ng-map>
<ng-map-route id="trip" [coordinates]="coordinates" (routeClick)="routeClick($event)" />
</ng-map>`,
{
imports: [MapRouteComponent],
hostProps: {
routeClick,
coordinates: [
[0, 0],
[1, 1],
],
},
},
);
await loadMap(fixture);
// Layer events are fired with the layer id, like map.on('click', layerId, handler).
fireMapEvent(getFakeMap(fixture), 'click', { lngLat: { lng: 1, lat: 1 } }, 'trip-layer');
expect(routeClick).toHaveBeenCalledWith(
expect.objectContaining({ lngLat: { lng: 1, lat: 1 } }),
);
});
it('adds the route again after a theme switch', async () => {
const theme = signal<'light' | 'dark'>('light');
const { fixture } = await renderMap(
`<ng-map [theme]="theme()">
<ng-map-route id="trip" [coordinates]="coordinates" />
</ng-map>`,
{
imports: [MapRouteComponent],
hostProps: {
theme,
coordinates: [
[0, 0],
[1, 1],
],
},
},
);
const map = getFakeMap(fixture);
await loadMap(fixture);
// The new theme calls map.setStyle(); the style arrives with reloadStyle().
theme.set('dark');
fixture.detectChanges();
await reloadStyle(fixture);
expect(structuralCalls(map)).toEqual([
'addSource:trip',
'addLayer:trip-layer',
'removeLayer:trip-layer',
'removeSource:trip',
'addSource:trip',
'addLayer:trip-layer',
]);
});
});update() or signals. In zoneless apps (the Angular default, and any test setup without zone.js) assigning fixture.componentInstance.x = … marks nothing for check, so the next detectChanges() skips the host and Angular's dev-mode check throws NG0100. update() marks the host for you. Otherwise call fixture.changeDetectorRef.markForCheck() after the assignment, as in any zoneless Angular test, or pass a signal in hostProps and read it in the template (theme()). renderMap() per test. It configures TestBed, so a second call in the same test throws TestBed already instantiated. To test several maps, put them in one template: getFakeMap() and the returned map are the first one, and fakeRegistry.maps holds them all. Inspecting layers and sources
# The fake map is a plain object you can inspect. Three things are stable public API: fakeRegistry, the fake's getters and structuralCalls().
getFakeMap(fixture)returns the fake map of the firstng-map. Use its getters (getLayer,getSource,getCenter,getZoom,getLayerOrder,hasImage,getTerrain, ...) and its spies (jumpTo,panBy,setStyle,setData, ...) for assertions.structuralCalls(map)lists the source and layer operations in order, asaddSource:<id>,addLayer:<id>,removeLayer:<id>andremoveSource:<id>. Its format is stable.fakeRegistryholds everymaps,markersandpopupscreated since the last reset, for examplefakeRegistry.markers[0].getLngLat().
map.calls. The raw strings of that debugging log are not stable and may change in any release. Assert through the getters or structuralCalls(). Popups and map clicks
#The fake closes popups the way MapLibre does, so a spec sees the same behaviour as the browser:
- A map
click(for examplefireMapEvent(map, 'click')) closes every open popup created withoutcloseOnClick: false. MapLibre's default istrue, whichng-map-popupkeeps;ng-marker-popupdefaults tofalse. - Removing a marker (
Marker.remove(), which runs when anng-map-markeris destroyed) closes its open popup.
Both fire the popup's close event, so popupClose emits and ng-map-popup's open turns false. To keep a popup open while the spec fires map clicks, bind [closeOnClick]="false":
import { TestBed } from '@angular/core/testing';
import { MapPopupComponent } from 'ng-mapcn';
import { fireMapEvent, getFakeMap, loadMap, renderMap, resetMapTesting } from 'ng-mapcn/testing';
import { afterEach, describe, expect, it } from 'vitest';
describe('popups and map clicks', () => {
afterEach(() => {
TestBed.resetTestingModule();
resetMapTesting();
});
it('keeps a popup open on map clicks with closeOnClick off', async () => {
const { fixture } = await renderMap(
`<ng-map>
<ng-map-popup [longitude]="2" [latitude]="48" [closeOnClick]="false">Pinned</ng-map-popup>
<ng-map-popup [longitude]="13" [latitude]="52">Closes on a map click</ng-map-popup>
</ng-map>`,
{ imports: [MapPopupComponent] },
);
await loadMap(fixture);
const openPopups = () =>
Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('.maplibregl-popup'),
(popup) => popup.textContent?.trim(),
);
expect(openPopups()).toEqual(['Pinned', 'Closes on a map click']);
// Like MapLibre, the fake closes every popup without closeOnClick: false on a map click.
fireMapEvent(getFakeMap(fixture), 'click', { lngLat: { lng: 0, lat: 0 } });
expect(openPopups()).toEqual(['Pinned']);
});
});Component harnesses
#ng-mapcn/testing/harness exports Angular CDK component harnesses that read and drive the rendered DOM, so specs stay free of selectors. They run in a TestBed on top of the fake, which renders markers, popups and controls where MapLibre does. The repository's end-to-end suite checks that DOM contract (the selectors the harnesses use and the marker's ARIA state) against the real MapLibre in Chromium.
NgMapMarkerHarness reads the marker's popup state from its ARIA attributes: hasPopup() is true when the marker has an ng-marker-popup (aria-haspopup), and isPopupOpen() when that marker's own popup is open (aria-expanded), whatever other popups are open on the map. When the marker content is its own button, it reads that button's aria-expanded, which the app mirrors from the popup (see Limitations).
| Harness | Type | Description |
|---|---|---|
NgMapHarness | isLoading, getMarkers, getPopups, getControls | The map host. Markers, popups and controls are rendered inside the map container, so you find them from here. |
NgMapMarkerHarness | getText, getAriaLabel, click, hover, mouseAway, focus, pressEnter, hasPopup, isPopupOpen | A marker. Filter with { text, label }. hasPopup reads aria-haspopup, isPopupOpen reads aria-expanded: the state of this marker only. |
NgMapPopupHarness | getText, isTooltip, hasCloseButton, close, pressEscape | An open popup or tooltip. A closed popup is not in the DOM. Filter with { text }. |
NgMapControlsHarness | zoomIn, zoomOut, resetNorth, locate, isLocating, toggleFullscreen, isFullscreen | The button group of ng-map-controls. An action throws when its button is hidden by showZoom, showCompass, showLocate or showFullscreen. |
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TestBed } from '@angular/core/testing';
import { MapMarkerComponent, MarkerContentComponent, MarkerPopupComponent } from 'ng-mapcn';
import { loadMap, renderMap, resetMapTesting } from 'ng-mapcn/testing';
import { NgMapHarness, NgMapPopupHarness } from 'ng-mapcn/testing/harness';
import { afterEach, describe, expect, it } from 'vitest';
describe('marker popup', () => {
afterEach(() => {
TestBed.resetTestingModule();
resetMapTesting();
});
it('opens on click and closes with its close button', async () => {
const { fixture } = await renderMap(
`<ng-map>
<ng-map-marker [longitude]="-46.63" [latitude]="-23.55" ariaLabel="São Paulo">
<ng-marker-content>SP</ng-marker-content>
<ng-marker-popup [closeButton]="true">Largest city in Brazil</ng-marker-popup>
</ng-map-marker>
</ng-map>`,
{ imports: [MapMarkerComponent, MarkerContentComponent, MarkerPopupComponent] },
);
await loadMap(fixture);
const loader = TestbedHarnessEnvironment.loader(fixture);
const map = await loader.getHarness(NgMapHarness);
const [marker] = await map.getMarkers({ label: 'São Paulo' });
expect(await marker.getText()).toBe('SP');
expect(await map.getPopups()).toHaveLength(0);
await marker.click();
// aria-expanded on the marker: this marker's popup, not any popup on the map.
expect(await marker.isPopupOpen()).toBe(true);
const popup = await loader.getHarness(NgMapPopupHarness.with({ text: /Largest city/ }));
expect(await popup.isTooltip()).toBe(false);
await popup.close();
expect(await marker.isPopupOpen()).toBe(false);
expect(await map.getPopups()).toHaveLength(0);
});
}); The harnesses are a separate entry point on purpose: they import @angular/cdk/testing, which is an optional peer dependency of ng-mapcn. Install it only if you use them; ng-mapcn/testing works without it.
npm install --save-dev @angular/cdkDOM selectors for end-to-end tests
# The harnesses find everything through MAP_TEST_SELECTORS, exported by ng-mapcn/testing (not by the harness entry, so it needs no @angular/cdk). It is the stable DOM contract of a rendered map, the same with the fake and with the real MapLibre: reuse it in Playwright or Cypress instead of hard-coding the classes. The repository's end-to-end suite checks every entry against the real MapLibre in Chromium.
| Key | Type | Description |
|---|---|---|
map | 'ng-map' | The ng-map host. |
loader | '.ng-map-loader' | The loader overlay. |
marker | '.maplibregl-marker' | A marker element, in .maplibregl-canvas-container. |
popup | '.maplibregl-popup' | An open popup or tooltip, a direct child of the map container. |
popupCard | '.ng-popup-card' | The role="dialog" card of ng-marker-popup / ng-map-popup. |
tooltip | '.ng-tooltip-pill' | The pill of ng-marker-tooltip. |
popupClose | '.ng-popup-close' | The close button of a popup card (closeButton input). |
controls | '.ng-controls' | ng-map-controls, in a control corner. |
controlGroup | '.ng-control-group' | The button group of ng-map-controls. |
import { expect, test } from '@playwright/test';
import { MAP_TEST_SELECTORS as S } from 'ng-mapcn/testing';
test('a marker opens its popup card', async ({ page }) => {
await page.goto('/stores');
const map = page.locator(S.map);
await expect(map.locator(S.loader)).toHaveCount(0);
await map.locator(S.marker, { hasText: 'Downtown' }).click();
const card = map.locator(`${S.popup} ${S.popupCard}`);
await expect(card).toHaveAttribute('role', 'dialog');
await card.locator(S.popupClose).click();
await expect(map.locator(S.popup)).toHaveCount(0);
});Fake timers
#<ng-map> waits 100 ms after styledata before it opens its load gate, and loadMap() and reloadStyle() wait that out. By default they wait on a real timer, which a fake clock would never fire. With fake timers installed, tell them how to advance your clock:
import { TestBed } from '@angular/core/testing';
import { MapRouteComponent } from 'ng-mapcn';
import {
configureMapTesting,
getFakeMap,
loadMap,
renderMap,
resetMapTesting,
structuralCalls,
} from 'ng-mapcn/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('with fake timers', () => {
beforeEach(() => {
vi.useFakeTimers();
// loadMap() waits 100 ms for the style debounce: tell it how to advance your fake clock.
configureMapTesting({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
});
afterEach(() => {
configureMapTesting({ advanceTimers: null });
vi.useRealTimers();
TestBed.resetTestingModule();
resetMapTesting();
});
it('loads the map without waiting on a real timer', async () => {
const { fixture } = await renderMap(
'<ng-map><ng-map-route id="trip" [coordinates]="coordinates" /></ng-map>',
{
imports: [MapRouteComponent],
hostProps: {
coordinates: [
[0, 0],
[1, 1],
],
},
},
);
await loadMap(fixture);
expect(structuralCalls(getFakeMap(fixture))).toEqual(['addSource:trip', 'addLayer:trip-layer']);
});
}); Pair the two: set advanceTimers only while fake timers are installed (vi.advanceTimersByTime throws when they are off), and reset it to null in afterEach as above. With Jest, pass (ms) => jest.advanceTimersByTime(ms). If fake timers are installed and advanceTimers is not set, both helpers throw right away with an error that names configureMapTesting, instead of hanging until the test times out. Any fake-timer library is detected: @sinonjs/fake-timers (Vitest, Jest) by its tag, anything else because setTimeout is no longer the one in place when ng-mapcn/testing was imported (zone.js patching it does not count). A replaced or mocked setTimeout is then checked behaviourally, so a spy or wrapper that still runs on real time is not mistaken for a fake clock.
@sinonjs/fake-timers that replaces setTimeout before ng-mapcn/testing is first imported cannot be detected: the helpers then wait on the fake clock until the test times out. With zone.js loaded, the original it saved is the reference, so the order does not matter. Limitations
#- No rendering. The fake draws nothing: there is no canvas output, and
queryRenderedFeaturesand pixel hit testing are not available. Fire the events yourself withfireMapEvent(). Use end-to-end tests with a real browser (the repository's own e2e suite runs Playwright against real MapLibre in Chromium, see CONTRIBUTING.md) when you need real rendering. - Popups close like MapLibre's. A fired map
clickcloses popups withoutcloseOnClick: false, and removing a marker closes its popup (see Popups and map clicks). A spec that fires map clicks with a popup open must opt out with[closeOnClick]="false". - Vitest and Jest only. Karma and Jasmine cannot mock an ES module, and the Jest setup is documented but not tested.
- Copy-paste users.
ng add ng-mapcncopies the component sources but not the testing files. Install theng-mapcnpackage as a dev dependency to get the fake, the helpers and the harnesses. - The controls harness uses CSS
:has(). It finds the compass button by its icon, so it needs a DOM implementation that supports:has()(the jsdom used by the library's own tests does). - Markers whose content has its own control. When
ng-marker-contentholds a<button>or link, that control is the keyboard target and the marker element has no role, so it gets noaria-expanded. Mirror the state on your button (<ng-marker-popup #info>plus[attr.aria-expanded]="info.isOpen()") andisPopupOpen()reads it from the first button or link in the content. Without itisPopupOpen()throws: check the popup withNgMapHarness.getPopups()instead. - What the end-to-end suite proves. It runs the harness selectors and the marker ARIA checks in a browser against the real MapLibre, not the harness classes themselves, which need a
TestBed. - Controls are found by structure. Button labels are translatable, so the controls harness locates zoom, compass, locate and fullscreen by their position and attributes, not by
aria-label.