React Map GL - How do I test this?

philhardwick

Phil Hardwick

Posted on June 6, 2020

React Map GL - How do I test this?

How do I test this? This is a common question I ask myself when writing a new feature. Sometimes it’s not so easy and you need to truly think about what you’re testing and the level at which you’re testing (in the test pyramid).

I’m creating an app which draws layers on a map. Testing these layers are visible to the user is a difficult problem, so I’ll share how I solved it and why.

Libraries and Components

I’m using Typescript and React with react-map-gl to provide the map component. As a test framework I’m using Testcafe.

Make the map queryable

The first thing is to make the map available to the tests. This is done by using React’s ref prop. I have passed a function which takes the element and assigns it to the window. Casting the window to any is required to ensure the Typescript compiler allows us to assign a new variable on the window object.

<MapGL
    {...viewport}
    width="100%"
    height="100%"
    style={{ position: 'relative' }}
    mapStyle="mapbox://styles/streets-v11"
    onViewportChange={this._onViewportChange}
    mapboxApiAccessToken={process.env.REACT_APP_MAPBOX_ACCESS_TOKEN}
    ref={el => ((window as any).map = el)}
  >
    <div className="navigation-control-container">
      <NavigationControl showCompass={false} />
    </div>
    <MapLegend />
  </MapGL>

Querying the map for layers

Testcafe (and most ui test frameworks like Cypress or Selenium) allow you to define code which can be executed on the browser. We’ll leverage this to access the map and its layers. In Testcafe this is done using a ClientFunction. getMapLayer uses a ClientFunction by passing a function which returns a Promise. The most difficult thing about this setup was getting the transpiled code to be correct. Testcafe runs the code in the ClientFunction through Babel to allow you to use modern language features but it also means you have to structure your code quite specifically. I found it was easiest to define all called functions in the same file, pass those functions as dependencies and don’t call any other functions inside those functions (unless they’re defined in the scope of the function - like filterLayerFields).

//map-commponent.page-object.ts
import {ClientFunction, Selector, t} from 'testcafe';
import {IMapLayer} from "./map-types";

// declared so typescript allows you to access window.map from the client function
declare let map: any;

const getMapLayerAndData = (localMap, layerId): IMapLayer => {
  const filterLayerFields = layer => {
    return {
      id: layer.id,
      type: layer.type,
      paint: layer.paint,
      layout: layer.layout
    };
  };

  const layer = localMap.getMap().getLayer(layerId);
  const source = localMap.getMap().getSource(layerId);
  return {
    layer: layer && filterLayerFields(layer),
    geoJson: source && source._data
  };
}

const doOnMapOnceLoadedOrImmediately = (localMap, callback) => {
  if (localMap.getMap().loaded()) {
    callback()
  } else {
    localMap.getMap().on('idle', () => {
      callback();
    });
  }
}

const getMapLayer = layerId =>
  ClientFunction(() =>
    new Promise<IMapLayer>(resolve => {
      doOnMapOnceLoadedOrImmediately(map, () => resolve(getMapLayerAndData(map, layerId)))
    }), { dependencies: { layerId, doOnMapOnceLoadedOrImmediately, getMapLayerAndData }
  });

And the types used:

//map-types.js
import { FeatureCollection, Point, MultiPoint, LineString, 
    MultiLineString, Polygon, MultiPolygon, GeoJsonProperties } from 'geojson';

export interface IPropertyValue {
  kind: string;
  value: any;
}

export interface IPaintProperty {
  property: any;
  value: IPropertyValue;
  parameters: any;
}

export interface IPaintValues {
  'line-opacity': IPaintProperty;
  'line-color': IPaintProperty;
  'line-width': IPaintProperty;
  'line-floorwidth': IPaintProperty;
}

export interface IPaint {
  _values: IPaintValues;
  _properties: any;
}

export interface ILayer {
  id: string;
  type: string;
  paint: IPaint;
  layout: any;
}

export interface IMapLayer {
  geoJson: FeatureCollection<Point | MultiPoint | LineString | 
    MultiLineString | Polygon | MultiPolygon, GeoJsonProperties>;
  layer: ILayer;
}

Using this in your test

Now you can call this client function. As with a lot of UI testing it helps to build in wait mechanisms because it will take some time for the map to render all the layers. This will make your test less brittle. I’ve put this code in a page component, so it can be easily reused. This is the same file that has the functions above declared globally (but scoped to the file).

//map-commponent.page-object.ts
import {ClientFunction, Selector, t} from 'testcafe';
import {IMapLayer} from "./map-types";

export default class MapComponent {
  mapCanvas: Selector = Selector('.mapboxgl-canvas', { timeout: 1000 });

  private async waitForMapLayer(layerId: string, condition: (IMapLayer) => boolean) {
    let mapLayer = await getMapLayer(layerId)();
    let retries = 0;
    while (!condition(mapLayer) && retries < 20) {
      t.wait(100);
      retries++
      mapLayer = await getMapLayer(layerId)();
    }
    return mapLayer
  }

  mapLayerHasFeatures = (numberOfFeatures: number) => (mapLayer: IMapLayer): boolean => {
    return mapLayer.geoJson !== undefined && mapLayer.geoJson.features.length === numberOfFeatures;
  }

  async getLayer(layerId: string, waitUntilNumberOfFeatures: number): Promise<IMapLayer> {
    return await this.waitForMapLayer(layerId, this.mapLayerHasFeatures(waitUntilNumberOfFeatures));
  }
}

Finally, you can call this in a test:

import MapComponent from "../map-component.page-object";

test('map layer exists with correct coordinates', async t => {
  const mapComponent = new MapComponent()
  const mapLayer = await mapComponent.getLayer("my-road", 1);
  t.expect(mapLayer.geoJson.features[0].geometry.coordinates)
    .eql([[0.1872612, 51.9872986], [0.1876876, 51.9725736]])
});

Things to watch out for

You can’t currently pass a typescript class to the client function as a dependency otherwise it will hang. See this issue.

What you’re actually testing

There are other ways to test this too. I’ve seen a lot of screenshot comparing tests. Comparing screenshots is the ultimate way to definitely make sure your layers are drawn on the map. I’ve also thought about comparing the image data from a canvas and counting the number of colour pixels. However, I decided not to go either of those ways because I felt the tests would be too brittle and require too much maintenance. When using my approach I’m just testing my code and some of React Map GL’s code, then trusting mapbox to render the layers correctly on a canvas. I can imagine they already have a lot of sophisticated testing around this, so I don’t need to duplicate that by comparing pixels.

💖 💪 🙅 🚩
philhardwick
Phil Hardwick

Posted on June 6, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

React Map GL - How do I test this?
typescript React Map GL - How do I test this?

June 6, 2020