Test MapLibre maps
with Playwright.
Find a feature. Click it. Assert what changed.
geowright reads the map through its public API and acts with real mouse, touch and keyboard input, never map.fire(). Assert what was added, changed or removed on a WebGL map that a DOM-only suite can't see.


deliveriesPreview. Start the live demo to load the local map.
const map = geowright(page);await map.feature({ layer: 'deliveries', id: 7 }).click();await expect(map).toHavePopup({ text: 'Delivery 07' });Choose a condition
Start the demo, then select a delivery, hide it, or cover it. The code shows what a test would do in each case.
See the complete testWhat a DOM-only suite misses on a map
A MapLibre map renders to a single WebGL canvas. Finding an element, reading its text, clicking it, waiting for it to appear: all of it stops working once the thing under test is a pixel inside that canvas. geowright closes the two gaps that leaves.
Assert
After an action in the normal UI, verify that something was added to, changed on, or removed from the map.
await page.getByTestId('alert-remove-2').click();await expect(map).not.toHaveFeature({ layer: 'alerts', id: 2 });Act
Perform actions that start on the map: click a feature, right-click a location, draw, drag a vertex.
await map.feature({ layer: 'alerts', id: 4 }).click();await map.clickAt({ lng: 34.78, lat: 32.08 });A tour of the API
Assertions retry like web-first assertions; actions auto-wait, are strict, and are verified.
API referenceQueries that match what a user can see
Visible by defaultmap.feature(q)is strict, like a Locator;map.features(q)is a set. MapLibre also returns features painted at opacity 0, so queries evaluate the style for each feature and match only the visible ones. A failure names the property that hid it.await expect(map).toHaveFeature({ layer: 'alerts', where: { severity: 'high' } });await expect(map.features({ layer: 'alerts' })).toHaveFeatureCount(3);await expect(map.feature({ layer: 'sensors', id: 501 })).toBeVisibleOnMap();Failures say what was on the map
A failed assertion lists what was rendered and why nothing matched. It also attaches the map with every candidate pixel drawn on it, and tells apart a missing layer, a hidden layer, and an off-screen, covered or ambiguous feature.
Failure messagesexpect(map).toHaveFeature({"layer":"alerts","where":{"severity":"critical"}})Expected: at least one visible feature matching, within 7000msReceived: 0 match(es)What is on the map: view: {"center":{"lng":34.78,"lat":32.08},"zoom":12, ...} layer "alerts": 3 feature(s) rendered in viewport - alerts#3 {"id":3,"name":"Noise complaint","severity":"low"} @(363.5,428.7) - alerts#2 {"id":2,"name":"Road closure","severity":"medium"} @(538.3,256.9) - alerts#1 {"id":1,"name":"Gas leak","severity":"high"} @(421.8,325.6) "severity" wanted "critical"; rendered values: "low", "medium", "high"Touch and keyboard
Touch and keyboardtap()is verified likeclick(). Pinch, two-finger rotate, one-finger pan and double tap go through the Chrome DevTools Protocol, so they are Chromium only. Keys go to the focused map.test.use({ hasTouch: true });const here = { lng: 34.78, lat: 32.08 };await map.feature({ layer: 'alerts', id: 2 }).tap();await map.pinchAt(here, { scale: 2 });await map.twoFingerRotateAt(here, { degrees: 40 });await map.keyboard.press('Shift+ArrowRight');Markers and popups
DOM markers and popups come back as normal Playwright Locators.
Markers and popupstoHavePopupchecks that a popup is open and anchored on the feature. Feature actions never click through a marker.const hq = await map.marker({ at: { lng: 34.84, lat: 32.12 } });await hq.click();await map.feature({ layer: 'alerts', id: 1 }).click();await expect(map).toHavePopup({ feature: { layer: 'alerts', id: 1 }, text: 'Gas leak' });Drawing and geometry
Draw helpers are input choreography, not a binding to one tool, and are tested against terra-draw and mapbox-gl-draw.
Geometry and drawingtoHaveGeometrycompares the full geometry the map's source holds, in pixels.await page.getByTestId('draw-polygon').click();await map.draw.polygon([a, b, c, d], { finish: 'click-first' });const drawn = map.feature({ layer: 'td-polygon' });await expect(drawn).toHaveGeometry([a, b, c, d], { tolerancePx: 2 });Vector tiles without a tile server
Real MVT tiles are cut from a GeoJSON fixture inside
Vector tiles without a serverpage.route. The app keeps its production{z}/{x}/{y}.pbfURL and code path.import { routeVectorTiles } from 'geowright/tiles';await routeVectorTiles(page, '**/vt/buildings/**', { buildings: buildingsGeoJSON });await page.goto('/');await expect(map).toHaveFeature({ layer: 'buildings' });
How feature interactions work
A map can draw a feature without making it usable. geowright checks the conditions that matter before sending input.
What an action waits for- One intended feature
- A strict query resolves exactly one match by layer, ID, properties, or location. Ambiguity produces a diagnostic, not a guess.
- A usable action point
- The target must be visible, stable, and reachable. An overlapping feature or page element can block the click.
- Real browser input
- Playwright sends the pointer input. The target is checked again when the press arrives; geowright never calls
map.fire(). - An observable result
- Retry assertions against features, popups, geometry, or the camera until the expected map state appears.
Add geowright to your test suite
Register your map in a test build. Then use the same feature queries and assertions inside Playwright.
if (import.meta.env.MODE !== 'production') {
(window.__geowright_maps ??= {}).main = map;
}- 1
Expose the map in your test build
Add the hook beside map creation, guarded for test or development builds. Nothing is imported into your app.
- 2
Prepare Playwright
The initializer supplies a fixture and the software-WebGL setup, plus a first spec.
- 3
Make one useful assertion
Query a feature, send real input, and check what changed. Replace the example data with your own.
Compatibility and testing evidence
Recorded coverage, with the limits alongside it.
| Scope | Recorded evidence | What that means |
|---|---|---|
| Other applications | 33 apps · 135 specs | MapLibre examples and React, Vue, and Svelte wrapper apps. Test subjects, not customer endorsements. |
| Version compatibility | MapLibre 2–6 Mapbox 1.13 | The core matrix covers actions and assertions. Globe, terrain, touch, and drawing have narrower coverage. |
| Repeated execution | 6,980 executions · 0 retries 349 tests × 20 repeats | 0 flakes, 0 retries across 20 repeats of the complete suite; 13 opt-in tests skipped. The suite has passed this bar repeatedly as it grew. |
The design notes include the failures that led to fixes, along with what remains unresolved.
Read the evidence in contextLimits, stated plainly
What geowright cannot do, or does only partly, from its README.
- Tested on Chromium with software WebGL. Touch gestures beyond
tapgo through CDP, Chromium only. - One line in the app is required. Auto-discovery is not possible for bundled ESM apps.
waitForIdle()cannot see app-level async, such as a fetch that adds a layer later. Use the retrying assertions for that.- Tiled sources: the browser cannot know what is off-screen, and the failure messages say so.
hoveron a moving target is only dependable when frames are short;clickholds up.
Questions
Does anything ship in my app?
No. The app registers its map with one line and imports nothing; the adapter is injected by Playwright at test time. Keep that line out of production builds: it makes the map instance reachable by every script on the page.
The app hookDoes it call map.fire()?
No. Actions are real input, page.mouse at projected pixels, and the adapter uses the map library's public API only.
Which map libraries and versions?
MapLibre GL JS 2–6 and Mapbox GL JS 1.13. Globe, terrain, touch, drawing tools and the third-party apps are checked on MapLibre 6 only. Mapbox GL JS v2 and later are not tested.
Supported versionsWhich browsers and Playwright versions?
Tested on Chromium with software WebGL. @playwright/test 1.49 or later: CI runs the full suite on 1.49.0 and on the current version.
Does it work with React, Svelte or Vue wrappers?
Yes: one line in the wrapper's load callback, for react-map-gl, @vis.gl/react-maplibre, svelte-maplibre-gl and vue-maplibre-gl. npx geowright init prints the form your wrapper needs.
Can a browser agent use it?
Through Playwright MCP's --init-script: a text snapshot lists each feature with a verified clickable pixel, and an accessibility mirror makes features clickable buttons.
Write your first map test
Expose the map in a test build, prepare Playwright, then assert on one feature. The guide walks through each step.
Read next: why geowright uses real input, how a map click is verified, what “visible” means, waiting without sleeping, troubleshooting and the changelog.