Unit Testing Code with Observables

Many Angular 2 and Ionic 2 APIs return RxJS observables. To unit test your code that's consuming them, you will need to create your own observable streams with test data. With this approach I wrote tests for code reacting to user's current geolocation.

In essence, my page under test subscribes to the stream of user's locations and displays his last known location on the map:

import { Component, ViewChild } from '@angular/core';

@Component({
  selector: 'page-map',
  templateUrl: 'map.html'
})
export class MapPage {
  @ViewChild('map') mapDiv;

  constructor(private location: Location, private googleMap: GoogleMap) {}

  initMap(): Promise<void> {
    return this.location.getCurrent().then(currentLatLng => {
      let map = this.googleMap.init(this.mapDiv.nativeElement, currentLatLng);
      let locationMarker = map.addMarker(currentLatLng);

      this.location.watch().subscribe(latLng => {
        locationMarker.setPosition(latLng) = latLng;
      });
    });
  }
}

Although I am using Ionic's Geolocation plugin and Google Maps, I wrapped both of them in my own providers so that I can easily replace their actual implementation in my tests.

Instead of simply wrapping the calls, my Location provider also converts the result to make it easily consumable by Google Maps:

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';

import { Injectable } from '@angular/core';
import { Geolocation } from 'ionic-native';

@Injectable()
export class Location {
  getCurrent(): Promise<LatLng> {
    return Geolocation.getCurrentPosition({
      enableHighAccuracy: true
    }).then(geoposition => {
      return {
        lat: geoposition.coords.latitude,
        lng: geoposition.coords.longitude
      };
    });
  }

  watch(): Observable<LatLng> {
    return Geolocation.watchPosition({
      enableHighAccuracy: true
    }).map(geoposition => {
      return {
        lat: geoposition.coords.latitude,
        lng: geoposition.coords.longitude
      };
    });
  }
}

To wrap Google Maps, I had to create my own Map and Marker counterparts as well:

import { Injectable } from '@angular/core';

@Injectable()
export class GoogleMap {
  init(div: Element, center: LatLng): Map {
    return new Map(div, center);
  }
}

export class Map {
  private map: google.maps.Map;

  constructor(div: Element, center: LatLng) {
    this.map = new google.maps.Map(div, {
      center: center,
      zoom: 15,
      disableDefaultUI: true
    });
  }

  addMarker(position: LatLng) {
    return new Marker(this.map, position);
  }
}

export class Marker {
  private marker: google.maps.Marker;

  constructor(map: google.maps.Map, position: LatLng) {
    this.marker = new google.maps.Marker({
      position: position,
      map: map
    });
  }

  setPosition(position: LatLng) {
    this.marker.setPosition(position);
  }
}

LatLng from both providers above is a simple interface:

export interface LatLng {
  lat: number;
  lng: number;
}

All of this allows me to create short self-explanatory tests:

it('updates marker position based on watched location', (done) => {
  let location = { lat: 46.037708, lng: 14.563042 };
  let locations = [
    { lat: 46.040517, lng: 14.557972 },
    { lat: 46.039268, lng: 14.566240 },
    { lat: 46.044803, lng: 14.567643 }
  ];
  let page = initPage(location, locations);

  page.initMap().then(() => {

    let expectedCallArgs = locations.map(loc => [loc]);
    expect((markerMock.setPosition as jasmine.Spy).calls.allArgs())
      .toEqual(expectedCallArgs);
    done();
  });
});

I moved additional setup code out of the test to allow reuse across multiple tests and to keep each individual test as concise as possible:

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/from';

describe('Function: initMap', () => {
  let markerMock: Marker;

  let initPage = (location: LatLng, locations: Array<LatLng>) => {
    let locationMock = jasmine.createSpyObj('Location', ['getCurrent', 'watch']);
    (locationMock.getCurrent as jasmine.Spy).and
      .returnValue(new Promise<LatLng>(resolve => {
        resolve(location);
      }));
    (locationMock.watch as jasmine.Spy).and
      .returnValue(Observable.from(locations));

    let googleMapMock = jasmine.createSpyObj('GoogleMap', ['init']);
    let mapMock = jasmine.createSpyObj('Map', ['addMarker']);
    (googleMapMock.init as jasmine.Spy).and.returnValue(mapMock);
    markerMock = jasmine.createSpyObj('Marker', ['setPosition']);
    (mapMock.addMarker as jasmine.Spy).and.returnValue(markerMock);

    return new MapPage(locationMock, googleMapMock);
  };

  it('updates marker position based on watched location', (done) => {
    // test code from above
  });
});

You can notice, how I simply create a new Observable from the array of locations. Its subscriber will receive those locations as a stream of values, behaving the same as if those locations were returned from Geolocation plugin over a longer period of time.

The rest of the setup code heavily relies on Jasmine spies to create mock objects as expected by the page implementation. As a side benefit, these spies allow me to assert calls to mocked functions and their arguments. In the test, I use this feature to verify the locations that were sent as new positions to the marker.

In the post I've assumed that you have already configured your Ionic 2 application for running unit tests. The Clicker seed project is probably the best guidance on how to do it.

Copyright
Creative Commons License