Map Testing Guide

How to Test Mapbox GL Markers with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing Mapbox GL JS markers, popups, and map interactions with Playwright. WebGL canvas rendering, custom HTML markers, fly-to animations, cluster expansion, geocoding search, and the pitfalls that break real map test suites in CI.

700M+

โ€œMapbox serves over 700 million monthly active users across mobile and web, powering maps for companies like Instacart, The New York Times, and Strava.โ€

Mapbox

0Native DOM markers in default GL JS
0Map scenarios covered
0fpsWebGL render target
0%Fewer lines with Assrt

Mapbox GL Marker Rendering Flow

BrowserYour AppMapbox GL JSWebGL CanvasMapbox Tiles APILoad page with mapInitialize map with accessTokenFetch vector tilesReturn tile dataRender tiles to canvasAdd markers / layersRender marker overlaysClick marker elementShow popup, fire event

1. Why Testing Mapbox GL Markers Is Harder Than It Looks

Mapbox GL JS renders its map inside a WebGL canvas element. Unlike traditional DOM-based mapping libraries such as Leaflet, where every marker, polyline, and tile is a discrete HTML element you can query with document.querySelector, Mapbox GL paints everything onto a single <canvas> tag. Playwright locators like getByRole and getByText cannot see anything drawn inside that canvas. Your test literally cannot find a marker that exists only as pixels in a GPU buffer.

Mapbox GL does offer two escape hatches from this canvas-only world. The first is the Marker class, which creates a real DOM element (a <div> with class mapboxgl-marker) that floats above the canvas and repositions itself as the map moves. The second is the custom HTML marker, where you provide your own DOM element to the Marker constructor. Both of these produce real DOM nodes that Playwright can target. But if your application uses symbol layers or circle layers to render points (the approach recommended by Mapbox for performance at scale), those points exist solely inside the canvas. Testing them requires a completely different strategy: evaluating JavaScript inside the page to call map.queryRenderedFeatures() and inspect the GeoJSON data programmatically.

Beyond the rendering model, there are four additional structural challenges. First, the map loads tiles asynchronously from the Mapbox API, and marker placement depends on the map reaching an idle state after tiles finish rendering. Second, fly-to and ease-to animations are asynchronous transitions that take hundreds of milliseconds to complete, and asserting map state mid-animation produces flaky results. Third, Mapbox GL requires a valid access token to load tiles; tests that run without a token see a blank canvas and every marker test fails silently. Fourth, WebGL requires a GPU context. CI environments that lack GPU acceleration (most Docker-based runners) fall back to software rendering via Mesa/LLVMpipe, which is orders of magnitude slower and can cause timeout failures for any test that waits for the map to reach idle.

Mapbox GL Rendering Pipeline

๐ŸŒ

Page Load

App mounts map container

โš™๏ธ

GL Init

mapboxgl.Map() created

โ†ช๏ธ

Tile Fetch

Vector tiles from API

๐ŸŒ

WebGL Render

Tiles painted to canvas

โœ…

Marker Overlay

DOM markers positioned

โœ…

Idle Event

Map is ready for testing

2. Setting Up a Reliable Test Environment

Before writing any marker tests, you need three things in place: a valid Mapbox access token available to your test runner, a Playwright configuration that handles WebGL properly, and a helper function that waits for the map to reach its idle state before any assertions run. Skip any one of these and you will spend more time debugging infrastructure than writing tests.

Environment Variables

Store your Mapbox token in a .env file that Playwright loads automatically. Use a separate token for CI with restricted scopes so a leaked token in logs cannot modify your styles or tilesets.

Environment Setup

Playwright Configuration for WebGL

Chromium disables GPU acceleration by default in headless mode. Mapbox GL JS falls back to software rendering, which works but is slow. For CI stability, launch with the --use-gl=angle flag if your runner has GPU support, or accept the slower software path and increase timeouts. The configuration below handles both environments.

playwright.config.ts

The waitForMapIdle Helper

Every Mapbox GL test must wait for the map to finish loading tiles and rendering before making assertions. The idle event fires when the map has finished all pending rendering work. Without this wait, your marker tests will randomly fail because the map has not yet positioned the marker elements in the DOM.

tests/helpers/map-utils.ts

Test Setup Flow

๐Ÿ”’

Set Token

MAPBOX_ACCESS_TOKEN in .env

โš™๏ธ

Configure PW

WebGL flags + timeouts

๐ŸŒ

Start App

Dev server with map page

โœ…

Wait Idle

map.once('idle', ...)

โœ…

Run Tests

Markers are in DOM

3. Scenario: Default Marker Placement and Visibility

The simplest Mapbox GL test is verifying that a default marker appears on the map at the correct coordinates. The default marker is a teal SVG pin wrapped in a <div class="mapboxgl-marker"> element. Playwright can locate it directly. The challenge is ensuring the marker's screen position actually corresponds to the expected geographic coordinates. A marker that is visible but placed at the wrong longitude is a passing test hiding a real bug.

The test below navigates to the map page, waits for the idle event, confirms the marker element exists in the DOM, and then verifies its geographic position by evaluating the marker's internal getLngLat() method through the Mapbox GL API.

1

Default Marker Placement

Straightforward
tests/markers/default-marker.spec.ts

Default Marker: Playwright vs Assrt

import { test, expect } from '@playwright/test';
import { waitForMapIdle } from '../helpers/map-utils';

test('default marker at expected coords', async ({ page }) => {
  await page.goto('/map');
  await waitForMapIdle(page);

  const marker = page.locator('.mapboxgl-marker').first();
  await expect(marker).toBeVisible();

  const lngLat = await page.evaluate(() => {
    const markerEl = document.querySelector('.mapboxgl-marker');
    const map = (window as any).map;
    const markers = map._markers ?? [];
    const match = markers.find(
      (m: any) => m.getElement() === markerEl
    );
    const pos = match.getLngLat();
    return { lng: pos.lng, lat: pos.lat };
  });

  expect(lngLat.lng).toBeCloseTo(-73.9857, 2);
  expect(lngLat.lat).toBeCloseTo(40.7484, 2);
});
50% fewer lines

4. Scenario: Custom HTML Marker with Data Attributes

Most production Mapbox applications do not use the default teal pin. They pass a custom HTML element to the Marker constructor to render branded pins, category icons, or interactive chips. Because these custom markers are real DOM elements, they are the most testable part of the Mapbox GL ecosystem. The key technique is to add data-* attributes to your marker elements during construction. This gives Playwright a stable, semantic hook that survives CSS refactors, icon swaps, and class name changes.

The application code below shows how to create a custom marker with a data-marker-id and data-category attribute. The test then uses those attributes for precise targeting instead of fragile class selectors.

2

Custom HTML Marker with Data Attributes

Moderate

Application Code: Creating Testable Markers

src/components/MapMarkers.tsx

Playwright Test

tests/markers/custom-html-marker.spec.ts

Custom HTML Marker: Playwright vs Assrt

import { test, expect } from '@playwright/test';
import { waitForMapIdle } from '../helpers/map-utils';

test('custom markers render correctly', async ({ page }) => {
  await page.goto('/map?category=restaurant');
  await waitForMapIdle(page);

  const markers = page.locator('[data-marker-id]');
  expect(await markers.count()).toBeGreaterThan(0);

  const joesPizza = page.locator('[data-marker-id="joes-pizza"]');
  await expect(joesPizza).toBeVisible();
  await expect(joesPizza).toHaveAttribute(
    'data-category', 'restaurant'
  );

  const label = joesPizza.locator('.marker-label');
  await expect(label).toHaveText("Joe's Pizza");
});
41% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started โ†’

6. Scenario: Fly-To Animation Completes Correctly

The map.flyTo() method triggers a smooth animated transition to a new center and zoom level. It is widely used when a user selects a location from a list or clicks a search result. Testing fly-to is tricky because the animation runs asynchronously over 1 to 3 seconds, and asserting the map center during the animation returns an intermediate value that does not match the target. You must wait for the moveend event before checking the final map state.

The strategy is straightforward: trigger the action that calls flyTo, then evaluate a promise inside the page that resolves when the map fires moveend. After the promise resolves, read the map center and zoom and compare them against the expected target values.

4

Fly-To Animation

Complex
tests/map/fly-to.spec.ts

Fly-To Animation: Playwright vs Assrt

import { test, expect } from '@playwright/test';
import { waitForMapIdle, getMapCenter, getMapZoom }
  from '../helpers/map-utils';

test('flyTo on location click', async ({ page }) => {
  await page.goto('/map');
  await waitForMapIdle(page);

  await page.locator('[data-location="brooklyn-bridge"]').click();

  await page.evaluate(() => {
    return new Promise<void>((resolve) => {
      const map = (window as any).map;
      map.once('moveend', () => resolve());
    });
  });

  const center = await getMapCenter(page);
  expect(center.lng).toBeCloseTo(-73.9969, 2);
  expect(center.lat).toBeCloseTo(40.7061, 2);

  const zoom = await getMapZoom(page);
  expect(zoom).toBeGreaterThanOrEqual(15);
});
53% fewer lines

7. Scenario: Cluster Expansion on Zoom

Mapbox GL's built-in clustering groups nearby points into a single cluster circle when the map is zoomed out. As the user zooms in, clusters split into smaller clusters or individual points. Clustering is implemented at the source level using the cluster: true option on a GeoJSON source. The cluster circles are rendered as circle layers inside the WebGL canvas, not as DOM elements. This means you cannot click a cluster with a standard Playwright locator. Instead, you must query the rendered features, find the cluster, calculate its pixel position, click the canvas at that point, and then verify that the map zoomed in and the cluster expanded.

The test strategy involves three phases. First, at a low zoom level, use map.queryRenderedFeatures() to confirm clusters exist and count them. Second, trigger a click on a cluster to zoom in (most implementations call map.getSource().getClusterExpansionZoom() followed by flyTo). Third, after the zoom animation completes, query rendered features again and verify that fewer clusters exist or that individual point features are now visible.

5

Cluster Expansion on Zoom

Complex
tests/map/clustering.spec.ts
Cluster Test Run Output

8. Scenario: Geocoder Search Drops a Marker

Many Mapbox applications include the @mapbox/mapbox-gl-geocoder control, which provides a search box that autocompletes place names and drops a marker at the selected location. Testing this flow involves typing into the geocoder input, waiting for the autocomplete suggestions to appear (which depend on a network request to the Mapbox Geocoding API), selecting a result, and then verifying that the map panned to the correct location and a result marker appeared.

The network dependency makes this test inherently flaky in CI if you hit the live Mapbox Geocoding API. The recommended approach is to intercept the geocoding request with page.route() and return a fixture response. This eliminates network variability and lets you control exactly which suggestions appear and which coordinates the selected result resolves to.

6

Geocoder Search

Moderate
tests/map/geocoder-search.spec.ts

Geocoder Search: Playwright vs Assrt

test('geocoder search drops marker', async ({ page }) => {
  await page.route('**/geocoding/v5/**', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(geocodeFixture),
    });
  });

  await page.goto('/map');
  await waitForMapIdle(page);

  const input = page.locator('.mapboxgl-ctrl-geocoder input');
  await input.fill('Empire State Building');

  const suggestion = page.locator('.suggestions li').first();
  await expect(suggestion).toBeVisible();
  await suggestion.click();

  await page.evaluate(() => new Promise<void>((resolve) => {
    (window as any).map.once('moveend', () => resolve());
  }));

  const center = await getMapCenter(page);
  expect(center.lng).toBeCloseTo(-73.9857, 2);
});
57% fewer lines

9. Common Pitfalls That Break Mapbox Test Suites

After running Mapbox GL Playwright tests across dozens of CI configurations, these are the failures that surface repeatedly. Each one has been reported in GitHub issues on the mapbox-gl-js repository or the Playwright issue tracker. Knowing them in advance saves you hours of debugging.

Pitfall 1: Asserting Before Map Idle

The most common failure. The test checks for markers immediately after page.goto() without waiting for tiles to load and the map to reach idle state. On fast local machines this appears to work; in CI with slower network and no GPU, the map is still loading tiles when the assertion runs. The fix is the waitForMapIdle helper shown in Section 2. Never skip it.

Pitfall 2: Missing or Invalid Access Token in CI

Mapbox GL JS requires a valid access token to fetch tiles. If your CI environment does not inject MAPBOX_ACCESS_TOKEN as a secret, the map initializes but never loads tiles. The canvas stays blank, no markers appear, and every test fails with a generic timeout. The error message from Mapbox GL is a console warning, not a thrown exception, so Playwright does not catch it by default. Add a check at the start of your test suite that reads the console logs and fails fast if the token warning appears.

Pitfall 3: WebGL Context Lost in Parallel Tests

Running multiple Mapbox GL tests in parallel workers on a CI machine with limited GPU memory can trigger a webglcontextlost event. When this happens, the canvas goes black and all subsequent assertions fail. The solution is to either run map tests with workers: 1 in your Playwright config, or use the fullyParallel: false option for the map test project. This is a known limitation of WebGL in headless browsers (Chromium issue #1396273).

Pitfall 4: Asserting Map Center During Animation

Calling map.getCenter() while a flyTo or easeTo animation is running returns an intermediate value between the origin and destination. Tests that assert the center immediately after triggering a fly-to will get a value that matches neither the start nor the end. Always await the moveend event before reading the final map state, as demonstrated in Section 6.

Pitfall 5: Geocoder Tests Hitting the Live API

The Mapbox Geocoding API returns different results depending on the user's IP location, the API version, and the specificity of the query. A test that types โ€œCentral Parkโ€ and expects the first result to be โ€œCentral Park, New York, NYโ€ will break when run from a European CI server that returns a different Central Park first. Always intercept geocoding requests with page.route() and return fixture data, as shown in Section 8.

Pre-flight Checklist for Mapbox GL Tests

  • MAPBOX_ACCESS_TOKEN is set in CI environment secrets
  • Playwright launches with --enable-webgl and --use-gl=swiftshader
  • Every test calls waitForMapIdle before assertions
  • Map tests run with workers: 1 to prevent WebGL context loss
  • Geocoder tests intercept API calls with fixtures
  • flyTo assertions wait for the moveend event
  • Asserting marker coordinates without tolerance (use toBeCloseTo)
  • Running map tests in parallel on GPU-less CI
  • Clicking canvas without converting lngLat to pixel first
Common CI Failure: Missing Token

10. Writing These Scenarios in Plain English with Assrt

The Playwright tests in this guide are powerful but verbose. Each scenario requires importing helpers, calling page.evaluate() for map-internal state, managing async animation events, and constructing precise locator chains. Assrt lets you express the same scenarios as plain-English specification files. Behind the scenes, Assrt compiles these into the same Playwright TypeScript, including the waitForMapIdle calls, the moveend event waits, and the coordinate tolerance assertions.

Here is the full popup interaction scenario from Section 5, rewritten as an Assrt file. Notice that the complexity of canvas coordinate projection, popup DOM querying, and close button interaction is expressed in three simple steps and four expectations.

scenarios/mapbox-popup-interaction.assrt

Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repo as real tests you can read, run, and modify. When Mapbox GL updates its DOM structure or your application refactors marker class names, Assrt detects the test failure, analyzes the new DOM, and opens a pull request with the updated locators. Your scenario files stay untouched.

Full Popup Test: Playwright vs Assrt

import { test, expect } from '@playwright/test';
import { waitForMapIdle } from '../helpers/map-utils';

test('clicking marker opens popup', async ({ page }) => {
  await page.goto('/map');
  await waitForMapIdle(page);

  const marker = page.locator('[data-marker-id="central-park"]');
  await expect(marker).toBeVisible();
  await marker.click();

  const popup = page.locator('.mapboxgl-popup');
  await expect(popup).toBeVisible({ timeout: 5_000 });

  const content = popup.locator('.mapboxgl-popup-content');
  await expect(content).toContainText('Central Park');
  await expect(content).toContainText('New York, NY');

  const close = popup.locator('.mapboxgl-popup-close-button');
  await close.click();
  await expect(popup).not.toBeVisible();
});

test('only one popup visible at a time', async ({ page }) => {
  await page.goto('/map');
  await waitForMapIdle(page);

  await page.locator('[data-marker-id="central-park"]').click();
  const popup = page.locator('.mapboxgl-popup');
  await expect(popup).toBeVisible({ timeout: 5_000 });

  await page.locator('[data-marker-id="brooklyn-bridge"]').click();
  await expect(popup).toContainText('Brooklyn Bridge');

  const allPopups = page.locator('.mapboxgl-popup');
  expect(await allPopups.count()).toBe(1);
});
56% fewer lines

Start with the default marker placement test. Once it passes in your CI, add the custom HTML marker scenario, then the popup interaction, then fly-to animation, then cluster expansion, and finally the geocoder search. In a single afternoon you can have comprehensive Mapbox GL marker coverage that most production map applications never achieve by hand.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk