Angular + ArcGIS API for JavaScript: A unit testing strategy using dependency injection and the facade pattern

Visit this repository for a complete working example of the code referenced in this post:
https://github.com/mfcallahan/angular-cli-esri-map-unit-testing

The working application is deployed here:
https://mfcallahan.github.io/angular-cli-esri-map-unit-testing

If you have used the ArcGIS API for JavaScript, you may recall that it is built on top of the Dojo Toolkit, using their Asynchronous Module Definition (AMD) format to load the various modules of the ArcGIS API into an application. This originally meant that the ArcGIS API for JavaScript was very tightly coupled to the Dojo toolkit, and building applications which leveraged the API left little choice but for the developer to learn and use Dojo. Fortunately, Esri recognized that many developers wanted to use their API within other frameworks, and through their Commitment to Open, they provided many solutions and examples showing developers how to use the ArcGIS Dojo modules within different frameworks like jQuery, AngualrJS, Knockout.js, and Backbone.js. This was, however, not an easy road for the developer as documentation was scarce, and development against the ArcGIS API for JavaScript could sometimes feel like jamming a square peg in a round hole.

But I must commend Esri for continuing to improve upon the ArcGIS API for JavaScript. Their documentation is top notch, filled with heaps of sample code and interactive examples for nearly every component of the API. Today it is easier than ever to incorporate the API your application which is built with modern web frameworks and tooling, including Angular, Ember, React, Vue, Webpack, etc. They also support TypeScript ❀️! I won’t go into detail about all the different ways to use the ArcGIS API for JavaScript in this post, instead focusing on one tool, the esri-loader, and a specific problem I encountered while exploring it.

To summarize why the esri-loader package is needed, Esri states that “you can’t simply npm install the ArcGIS API and then import ‘esri’ modules in a non-Dojo application.” (Although future releases of the ArcGIS API will be available through npm). “The only reliable way to load ArcGIS API for JavaScript modules is using Dojo’s AMD loader. However, when using the ArcGIS API in an application built with another framework, you typically want to use the tools and conventions of that framework rather than the Dojo build system.” The esri-loader package exposes a few methods that allow the developer to very easily load the necessary modules from the API and use them within the application. This is the perfect solution for developing applications at my job, as we use Angular. Using the example provided by Esri, I can generate a MapComponent in my Angular app, import the esri-loader module and the esri types, and then load the ArcGIS modules to construct a map. My new MapComponent class looks something like this:

// map.component.ts

import { Component, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { loadModules } from 'esri-loader';
import esri = __esri; // Esri types

@Component({
  selector: 'app-map',
  templateUrl: './map.component.html',
  styleUrls: ['./map.component.scss'],
})
export class MapComponent implements AfterViewInit {
  @ViewChild('map', { static: false })
  private mapElementRef?: ElementRef;
  mapView?: esri.MapView;
  map?: esri.Map;
  defaultCenterLat: number;
  defaultCenterLon: number;
  defaultZoom: number;
  defaultBaseMap: string;

  constructor() {
    // Set default map center and zoom to continental USA
    this.defaultCenterLat = 39.83;
    this.defaultCenterLon = -98.58;
    this.defaultZoom = 5;
    this.defaultBaseMap = 'streets';
  }

  ngAfterViewInit(): void {
    this.initDefaultMap();
  }

  private async initDefaultMap(): Promise<void> {
    const [Map, MapView] = await loadModules(['esri/Map', 'esri/views/MapView']);

    this.map = new Map({
      basemap: this.defaultBaseMap,
    });

    this.mapView = new MapView({
      map: this.map,
      center: [this.defaultCenterLon, this.defaultCenterLat],
      zoom: this.defaultZoom,
      container: this.mapElementRef?.nativeElement,
      ui: {
        components: ['attribution'],
      },
    });
  }
}
<!-- map.component.html -->

<div class="mapView" #map></div>
/* map.component.scss */

.mapView {
  height: 100%;
}

That is all that is needed to add an ArcGIS map to my application!

However, I immediately noticed I was going to have an issue unit testing this class, or any other class which has a dependency on the esri-loader module for importing the ArcGIS API for JavaScript modules. The Angular CLI automatically generated a test suite for this class which looks like the following:

// map.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MapComponent } from './map.component';

describe('MapComponent', () => {
  let component: MapComponent;
  let fixture: ComponentFixture<MapComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [MapComponent],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(MapComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should initialize MapComponent', () => {
    expect(component).toBeTruthy();
  });
});

When the component is compiled, the ngAfterViewInit() method is called after the view is fully initialized, which then calls the initDefaultMap() method I added to the class. Inside initDefaultMap() the esri-loader loadModules() method is called to load the Map and MapView API modules. By default, the esri-loader will load the API modules from the Esri CDN, making one or more HTTP requests to fetch the necessary resources. (You can also specify the location and version of the API to load). This can be observed by inspecting the network traffic when loading your app:

This is a good thing when the app is running normally in production – the modules are lazily loaded, with the app only fetching the API resources until the are actually needed. Each module is only requested once; subsequent calls to loadModules() for a previously loaded module will not need to make the same HTTP requests again. If your application didn’t need to render a map until the user navigates to a different component of the app, this gives the performance benefit of not loading the API modules at initial app load. However, this is less than desirable in a unit testing scenario. We want to test individual units of our code, asserting that they are correct and that they return the expected data given certain inputs. By making actual HTTP requests in a unit test, it makes the test more like an integration test – we’re not writing tests to assert that our app was able to connect to the Esri CDN and fetch all the resources. Further, there may be a scenario where your tests are executing in an environment where they cannot connect to the outside internet, such as a build server, causing the tests to fail. You may also want to test the “unhappy” path, simulating an error response from a request to fetch ArcGIS API modules and ensuring your application handles that correctly.

So how do we proceed from here? We’ve identified the issue: calling the esri-loader’s loadModules() method will execute HTTP requests to fetch API resources when called. We need to write tests for a class which has a dependency on esri-loader, but we don’t want to execute the esri-loader methods during the tests because they will make HTTP requests. My first instinct was to attempt to mock the calls to the loadModules() function using the Jasmine Spy class, returning mock versions of the Esri modules. This was difficult as the esri-loader module exports individual functions, and using the Jasmine spyOn() method requires passing in a parent object of the function you want to mock, like this:

spyOn(EsriLoader, 'loadModules').and.callFake(() => {
  // return mocked versions of the Esri modules here 
});

This meant I would need to import the entire esri-loader module into a class that needed to use its functions, not just the functions I wanted to use. If I am following the Yagni principle, that is less than ideal:

import * as _esriLoader from 'esri-loader';

export class MapComponent implements OnInit {
  EsriLoader: any;

  constructor() {
    this.EsriLoader = _esriLoader;
  }
}

This did not work, however. I came across this open issue in the Jasmine GitHub repository describing the same problem I was having with spying on individual functions that are individually exported from a module. In the long discussion thread from that link, various workarounds are proposed of how to mock functions that are exported from a module with no parent object, but as a number of other GitHub users noted, nothing seemed to work. There didn’t appear to be a reliable, straightforward way to mock my code within Jasmine as it was currently written. Even if I did manage to figure something out and was able to spy on and mock the loadModules() method, I wasn’t entirely satisfied with the architecture of the code.

Something to keep in mind when writing unit tests, is that difficult to mock code is difficult to test. We have created a tight coupling between the MapComponent class and the esri-loader dependency; the MapComponent class is itself responsible for ensuring it has access to the esri-loader. My next approach was to use follow the Dependency Inversion Principle (from the SOLID Principles), specifically by utilizing Angualr’s built in dependency injection. I created a MapService class which looks like this:

// map.service.ts

import { ElementRef, Injectable } from '@angular/core';
import { loadModules } from 'esri-loader';
import esri = __esri; // Esri types

@Injectable({
  providedIn: 'root',
})
export class MapService {
  mapView?: esri.MapView;

  constructor() {}

  async initDefaultMap(
    basemap: string,
    centerLon: number,
    centerLat: number,
    zoom: number,
    mapElementRef?: ElementRef
  ): Promise<void> {
    const [Map, MapView] = await loadModules(['esri/Map', 'esri/views/MapView']);

    const map = new Map({
      basemap,
    });

    this.mapView = new MapView({
      map,
      center: [centerLon, centerLat],
      zoom,
      container: mapElementRef?.nativeElement,
      ui: {
        components: ['attribution'],
      },
    });
  }
}

This class encapsulates the Esri MapView, and exposes a method to initialize a new map. It is a singleton service, provided in the application root so that it can be injected into any component in our app which needs to access the map. The map logic can be moved from the MapComponent class to the MapService class, and the MapService is injected into MapComponent via the constructor:

// map.component.ts

import { Component, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { MapService } from 'src/app/services/map.service';

@Component({
  selector: 'app-map',
  templateUrl: './map.component.html',
  styleUrls: ['./map.component.scss'],
})
export class MapComponent implements AfterViewInit {
  @ViewChild('mapView', { static: false })
  mapElementRef?: ElementRef;
  defaultCenterLat: number;
  defaultCenterLon: number;
  defaultZoom: number;
  defaultBaseMap: string;

  constructor(readonly mapService: MapService) {
    // Set default map center and zoom to continental USA
    this.defaultCenterLat = 39.83;
    this.defaultCenterLon = -98.58;
    this.defaultZoom = 5;
    this.defaultBaseMap = 'streets';
  }

  ngAfterViewInit(): void {
    this.mapService.initDefaultMap(
      this.defaultBaseMap,
      this.defaultCenterLon,
      this.defaultCenterLat,
      this.defaultZoom,
      this.mapElementRef
    );
  }
}

Inside the MapComponent.ngAfterViewInit() method, we call the MapService.initDefaultMap() method, passing in the properties for the default map settings as well as the ElementRef property for the map div. The MapComponent will still render the map as it did before, but this will allow us to more easily write a test that asserts the MapComponent class was initialized correctly. The MapComponent tests can be updated with the following code:

// map.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBase } from 'src/test/testBase';
import { MapService } from '../services/map.service';

import { MapComponent } from './map.component';

describe('MapComponent', () => {
  let component: MapComponent;
  let fixture: ComponentFixture<MapComponent>;
  let initDefaultMapSpy: jasmine.Spy;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [MapComponent],
      providers: [{ provide: MapService, useValue: new MapService() }],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(MapComponent);
    component = fixture.componentInstance;
    initDefaultMapSpy = spyOn(component.mapService, 'initDefaultMap').and.returnValue(Promise.resolve());
    fixture.detectChanges();
  });

  afterEach(() => {
    TestBase.testTearDown(fixture);
  });

  it('should initialize MapComponent', () => {
    expect(component).toBeTruthy();
    expect(initDefaultMapSpy).toHaveBeenCalledOnceWith(
      component.defaultBaseMap,
      component.defaultCenterLon,
      component.defaultCenterLat,
      component.defaultZoom,
      component.mapElementRef
    );
  });
});

Inside the updated test suite, we have created a Spy for the MapService.initDefaultMap() method which returns a resolved Promise<void>. Now the 'should initialize MapComponent' test will assert that the component has been created (is truthy), and assert that the MapService.initDefaultMap() method was called with the expected parameters. But this time, our mocked implementation of initDefaultMap() was called: spyOn(component.mapService, 'initDefaultMap').and.returnValue(Promise.resolve()); and not the real implementation which calls the esri-loader loadModules() method that makes the HTTP requests to fetch the API resources. This can be verified by inspecting the network traffic while running the tests; there are no requests to arcgis.com:

So far so good – we’ve eliminated the tight coupling between the MapComponent class and the loadModules() method by moving the logic which loads the ArcGIS API’s modules and creates the map objects to the MapService which can be injected into the MapComponent class. This allows us to mock that behavior in tests for the MapComponent class, and eliminate the HTTP requests being made by loadModules(), but still write a strong test which asserts the component was created correctly. But if we want to test the MapService class we still have the same problem: the call to loadModules() inside the initDefaultMap() method will make those HTTP requests to fetch the API resources for the module names we specify, and the individual methods imported from the esri-loader package are still difficult to mock.

To solve this, we can leverage the Facade Pattern. This is an OOP design pattern which is used to define simplified access to subsystems that can be more complex. The facade is a “wrapper class,” inside of which the logic to interact with that more complex subsystem is contained. The wrapper class exposes public methods that can be easier to use, and will sometimes transform the data being returned to better suit the needs of the consuming clients. The esri-loader package is not necessarily too complex for our app to use, but the facade pattern also provides the useful benefit of making hard to mock classes or methods easier to mock. We can create a wrapper class that looks like this:

// esriLoaderWrapper.service.ts

import { Injectable } from '@angular/core';
import { loadModules } from 'esri-loader';
import { EnvironmentService } from './environment.service';

@Injectable({
  providedIn: 'root',
})
export class EsriLoaderWrapperService {
  constructor() {}

  public async loadModules(modules: string[]): Promise<any[]> {
    return await loadModules(modules, {
      url: 'https://js.arcgis.com/4.17/'
    });
  }
}

EsriLoaderWrapperService is marked with the @Injectable() decorator and is a singleton service, similar to the MapService class. The class is simple – it imports the loadModules() method from the esri-loader package, and defines its own simplified loadModules() method, with a single string array parameter for the name of the modules to load. This methods “wraps up” the call to the esri-loader loadModules() method, adding an additional object parameter to specify the url to the Esri CDN. The EsriLoaderWrapperService can then be injected into the MapService class via the constructor, and consumed in the initDefaultMap() method:

//map.service.ts

import { ElementRef, Injectable } from '@angular/core';
import { EsriLoaderWrapperService } from 'src/app/services/esriLoaderWrapper.service';
import esri = __esri; // Esri types

@Injectable({
  providedIn: 'root',
})
export class MapService {
  mapView?: esri.MapView;

  constructor(readonly esriLoaderWrapperService: EsriLoaderWrapperService) {}

  async initDefaultMap(
    basemap: string,
    centerLon: number,
    centerLat: number,
    zoom: number,
    mapElementRef?: ElementRef
  ): Promise<void> {
    const [Map, MapView] = await this.esriLoaderWrapperService.loadModules([
      'esri/Map',
      'esri/views/MapView',
    ]);

    const map = new Map({
      basemap,
    });

    this.mapView = new MapView({
      map,
      center: [centerLon, centerLat],
      zoom,
      container: mapElementRef?.nativeElement,
      ui: {
        components: ['attribution'],
      },
    });
  }
}

The app still works the same, rendering our map on the page, but we can now easily write tests for the MapService class. Because the wrapper loadModules() method is now bound to the EsriLoaderWapperService object which is being injected into the MapService class, we can use Jasmine’s spyOn() method to create a spy for the loadModules() method, mocking its behavior so HTTP requests to fetch the API modules aren’t made, and returning mock objects for those API modules. The MapService class test suite looks like this:

// map.service.spec.ts

import { ElementRef } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { EsriLoaderWrapperService } from 'src/app/services/esriLoaderWrapper.service';

import { MapService } from './map.service';

fdescribe('MapService', () => {
  let service: MapService;
  const esriLoaderWrapperService = new EsriLoaderWrapperService();

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: EsriLoaderWrapperService,
          useValue: esriLoaderWrapperService,
        },
      ],
    });
    service = TestBed.inject(MapService);
  });

  it('should created MapService', () => {
    expect(service).toBeTruthy();
  });

  it('should initialize a default map', async () => {
    // TODO: add test here
  });
});

Since the MapService class now has a dependency on EsriLoaderWrapperService, a new instance is created and is provided inside TestBed.configureTestingModule(). We can update the 'should initiate a default map' test with the following:

it('should initialize a default map', async () => {
  const basemap = 'streets';
  const centerLon = -112.077;
  const centerLat = 33.491;
  const zoom = 10;
  const elementRef = new ElementRef(null);

  const loadModulesSpy = spyOn(service.esriLoaderWrapperService, 'loadModules').and.returnValue(
    Promise.resolve([() => {}, () => {}])
  );

  await service.initDefaultMap(basemap, centerLon, centerLat, zoom, elementRef);

  expect(loadModulesSpy).toHaveBeenCalled();
  expect(service.mapView).not.toBeUndefined();
});

Inside this test, the parameters to pass in to initDefaultMap() are declared, as well as a loadModulesSpy which mocks the behavior of the EsriLoaderWrapperService.loadModules() method that is called inside initDefaultMap(). The spy will tell that method to return a resolved promise with an array of two empty, anonymous functions: [() => {}, () => {}]. These will serve as mocks for the two modules that are loaded, ‘esri/Map’ and ‘esri/views/MapView’. The test will assert that the spy we created was called, and that the service.mapView property was no longer undefined, having being set inside initDefaultMap().

When debugging the test, we can see that our mocked modules can be used inside initDefaultMap() but are not the same as the object returned from the call to the concrete implementation of loadModules().

Mocked “MapView” during unit test
Real “MapView” when running the application

And again, we can inspect the network traffic while running the tests and see that no HTTP requests were made to fetch ArcGIS API resources because we mocked the behavior of loadModules() to simply return some dummy data.

Using empty anonymous functions as mocks for the ArcGIS API modules returned by loadModules() is not the best approach, however. Say we wanted to modify the MapService.initDefaultMap() method to include a BasemapToggle widget in the MapView by adding 'esri/widgets/BasemapToggle' to the string array parameter passed intoloadModules(), and adding the following to the initDefaultMapp()method:

const toggle: esri.BasemapToggle = new BasemapToggle({
  view: this.mapView,
  nextBasemap: 'hybrid',
});

this.mapView?.ui.add(toggle, 'top-left');

Since we’re now loading three modules form the ArcGIS API, we can update the Spy object in the test with one more mock module:

const loadModulesSpy = spyOn(service.esriLoaderWrapperService, 'loadModules').and.returnValue(
  Promise.resolve([() => {}, () => {}, () => {}])
);

Running the tests again will yield error “TypeError: Cannot read property ‘add’ of undefined”

If we debug the test, the source of the error is easy to see – we’re at attempting to call the add() method on the ui property inside mapView, but neither of those exist:

We can fix this by updating the Spy object to return a mock MapView that has a ui.add() method which will return void like this, and the test will pass:

const loadModulesSpy = spyOn(service.esriLoaderWrapperService, 'loadModules').and.returnValue(
  Promise.resolve([
    () => {},
    () => {
      return {
        ui: {
          add: () => { return; },
        },
      };
    },
    () => {},
  ])
);

But as the application grows and needs to use more properties and methods of different ArcGIS modules that are loaded, we need to keep creating more complex mock objects to be returned from our spies and we will quickly have messy and unmaintainable code. Fortunately, libraries exist to help create mock instances of objects to use when testing. If you are familiar with Moq for .NET, you may be pleasantly surprised to know that TypeMoq exists for TypeScript. With TypeMoq, we can create mock instances of objects and set the behavior of the members of that class, with a syntax similar to Moq in C#. Before updating the test, we can make one change to the EsriLoaderWrapperService class, adding this method which will take a type parameter, and return an instance of that type:

public getInstance<T>(type: new (paramObj: any) => T, paramObj?: any): T {
  return new type(paramObj);
}

The initDefaultMap() method in the MapService class is still responsible for creating its own (“newing up”) instances of the ArcGIS API modules on which it depends by calling each modules type’s constructor, passing in the options object parameter: const map = new Map({ basemap }); As written, we can’t create a new mock instance of the Map modules when the method is being tested. So again, considering the dependency inversion principle, by taking the responsibility of creating those dependencies away from the MapService class and moving it to the EsriLoaderWrapperService class, we are able to provide mock instances of those dependencies when testing. The updated 'should initialize a default map' test looks like this:

it('should initialize a default map', async () => {
  // Arrange
  const mockMap = TypeMoq.Mock.ofType<esri.Map>();
  const mockDefaultUi = TypeMoq.Mock.ofType<esri.DefaultUI>();
  const mockMapView = TypeMoq.Mock.ofType<esri.MapView>();
  mockMapView.setup((mock) => mock.ui).returns(() => mockDefaultUi.object);
  const mockBasemapToggle = TypeMoq.Mock.ofType<esri.BasemapToggle>();

  const esriMockTypes = [mockMap, mockMapView, mockBasemapToggle];

  const loadModulesSpy = spyOn(service.esriLoaderWrapperService, 'loadModules').and.returnValue(
    Promise.resolve(esriMockTypes)
  );

  const getInstanceSpy = spyOn(service.esriLoaderWrapperService, 'getInstance').and.returnValues(
    ...esriMockTypes.map((mock) => mock.object)
  );

  const basemap = 'streets';
  const centerLon = -112.077;
  const centerLat = 33.491;
  const zoom = 10;
  const elementRef = new ElementRef(null);

  // Act
  await service.initDefaultMap(basemap, centerLon, centerLat, zoom, elementRef);

  // Assert
  expect(loadModulesSpy).toHaveBeenCalledTimes(1);
  expect(getInstanceSpy).toHaveBeenCalledTimes(esriMockTypes.length);
  expect(service.mapView).not.toBeUndefined();
  expect(service.mapView).toBe(mockMapView.object);
  mockDefaultUi.verify((mock) => mock.add(TypeMoq.It.isAny(), TypeMoq.It.isAnyString()), TypeMoq.Times.once());
});

Instead of using an anonymous function like () => {} to create a mock class with which we can test, we use TypeMoq like this: TypeMoq.Mock.ofType<esri.Map>();. Inside the test, we can create mock objects for all the ArcGIS API modules which are loaded in the method under test using the esri types, and update our loadModulesSpy object to return these mocks when our esriLoaderWrapperService.loadModules() method is called. An additional spy is added for the EsriLoaderWrapperService.getInstance() method so that it returns our mock instances. Similar to the Moq library for C#, you can access the mock object instance crated with TypeMock using the .object property.

These patterns can be leveraged throughout the app. Any class that needs to load ArcGIS API modules can have the EsriLoaderWrapperService injected, and tests can be more easily written for methods inside that class. I have expanded on the code demonstrated here and created a complete working Angular 11 application which uses the esri-loader and has unit test coverage. Feel free to view the code here, and clone the repository to run the tests: https://github.com/mfcallahan/angular-cli-esri-map-unit-testing

One thought on “Angular + ArcGIS API for JavaScript: A unit testing strategy using dependency injection and the facade pattern

  1. This is a great article, thank you for writing it. I am currently running into problems debugging an app that uses ESM loading of the ArcGIS modules. Have you done in unit testing with that?

    Liked by 1 person

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s