For what it's worth I came up with a strategy, using the Dependency Inversion Principle and the Facade Pattern, to be able to mock ArcGIS JS API modules and eliminate the HTTP requests being made from loadModules().
I posted in greater detail on my blog here: https://seesharpdotnet.wordpress.com/2020/12/03/angular-and-arcgis-api-for-javascript-a-unit-testing...
A working sample application applying this approach and test coverage is available in this repository: https://github.com/mfcallahan/angular-cli-esri-map-unit-testing
Here is the summary from the repo's README file:
The problem
The esri-loader allows an application to load Dojo AMD Modules outside of the Dojo Toolkit. A module can be lazy loaded, improving the initial load performance of the application by waiting to fetch API resources until they are actually needed:
// MapService class to encapsulate ArcGIS API
@Injectable({ providedIn: 'root' })
export class MapService {
mapView?: esri.MapView;
async initDefaultMap(): Promise<void> {
// loadModules() will make HTTP requests to arcgis.com to fetch specified modules
const [Map, MapView] = await loadModules(['esri/Map', 'esri/views/MapView']);
this.mapView = new MapView({
map: new Map({ basemap: 'streets' }),
center: [-112.077, 39.83],
zoom: 5,
});
}
}
However, this can make unit testing difficult, as the system under test does not have any reference to the objects in an ArcGIS API module until an HTTP request is made to fetch it. A test for the initDefaultMap() method will call loadModules() and make HTTP requests to arcgis.com to fetch the resources needed. This may not be desirable for a few reasons:
- The test becomes more like an integration test; we want to assert the component.mapView was correctly set inside loadModules(), not test that the application could connect to the internet and fetch dependencies.
- Tests may be executed in an environment which may not have access to the ArcGIS CDN, such as an automated build pipeline or server.
- Tests to ensure error responses from the request to load an ArcGIS API module (the unhappy path) are handled properly may be necessary.
// initDefaultMap() unit test
it('should initialize a default map', async () => {
await service.initDefaultMap(); // test will make actual HTTP requests!
expect(service.mapView).not.toBeUndefined();
});
My solution
Difficult to mock code is difficult to test! By refactoring the application code to follow the Dependency Inversion Principle and leverage Dependency Injection, the tight coupling between the above initDefaultMap() method and the esri-loader can be eliminated. The Facade Pattern can be used, creating a wrapper class for loadModules() and others methods exported by esri-loader which can then be injected into the class that has a dependencies on ArcGIS API modules. The wrapper class exposes its own loadModules() method which can be easily mocked, eliminating HTTP requests to the ArcGIS CDN in a test suite. A library such as TypeMoq can be used to create mock instances of the various ArcGIS API modules.
// Singleton service wrapper class for esri-loader
@Injectable({ providedIn: 'root' })
export class EsriLoaderWrapperService {
constructor() {}
public async loadModules(modules: string[]): Promise<any[]> {
return await loadModules(modules);
}
public getInstance<T>(type: new (paramObj: any) => T, paramObj?: any): T {
return new type(paramObj);
}
}
// Updated MapService class
@Injectable({ providedIn: 'root' })
export class MapService {
mapView?: esri.MapView;
constructor(readonly esriLoaderWrapperService: EsriLoaderWrapperService) {}
async initDefaultMap(): Promise<void> {
const [Map, MapView] = await this.esriLoaderWrapperService.loadModules(['esri/Map', 'esri/views/MapView']);
const map = this.esriLoaderWrapperService.getInstance<esri.Map>(Map, { 'streets' });
this.mapView = this.esriLoaderWrapperService.getInstance<esri.MapView>(MapView, {
map,
center: [-112.077, 39.83],
zoom: 5,
});
}
}
// Updated initDefaultMap() unit test
it('should initialize a default map', async () => {
// Arrange
const mockMap = TypeMoq.Mock.ofType<esri.Map>();
const mockMapView = TypeMoq.Mock.ofType<esri.MapView>();
const esriMockTypes = [mockMap, mockMapView];
const loadModulesSpy = spyOn(service.esriLoaderWrapperService, 'loadModules').and.returnValue(
Promise.resolve(esriMockTypes)
);
const getInstanceSpy = spyOn(service.esriLoaderWrapperService, 'getInstance').and.returnValues(
...esriMockTypes.map((mock) => mock.object)
);
// 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);
});