Mock out Google Maps Geocoder with Jasmine Spies


The Google Maps Geocoder is a handy utility that lets you do this:

var geocoder = new google.maps.Geocoder();
geocoder.geocode(
  { address: '1600 Amphitheatre Parkway, Mountain View, CA 94043' },
  function (results) {
    // address results of Google HQ
  },
);

When writing unit tests though, we don’t really want to be hitting the Google API each time. So we need a way to mock out the calls.

A Service to Test

Let’s say we have the following service method in our code.

geocodeLocation(location) {
  var deferred = $.Deferred();
  var geocoder = new google.maps.Geocoder();
 
  geocoder.geocode({ 'address': location }, function(results, status) {
    if (status === google.maps.GeocoderStatus.OK) {
      if (results && results.length) {
        var result = results[0];
        var geometry = result.geometry.location;
        deferred.resolve([geometry.lat(), geometry.lng()]);
        return;
      }
    }
 
    deferred.reject();
  });
 
  return deferred.promise();
}

Here, we’re passing in an address string and getting back a promise. We then either resolve or fulfil that promise based on the Geocoder result.

So how do we test this?

Mocking

Jasmine Spies can be used to mock function calls. But, since a constructor for a JS object is just another function, we can mock this out in much the same way.

var constructorSpy = spyOn(google.maps, 'Geocoder');

We still need a stub object to use in our tests though, so we can create one with just the geocode method.

var geocoder = jasmine.createSpyObj('Geocoder', ['geocode']);

All we need to do now is return the mock object from the constructor call.

Since it’s useful to start with a fresh object for each test, we can connect these up in a beforeEach in our Jasmine tests.

var geocoder;
 
beforeEach(function () {
  var constructorSpy = spyOn(google.maps, 'Geocoder');
  geocoder = jasmine.createSpyObj('Geocoder', ['geocode']);
 
  constructorSpy.and.returnValue(geocoder);
});

Using the Mock

Let’s say we wanted to test how our code would handle a response from the Geocoder that had no results. Using our mock, this would look like:

it('returns an error if the data service returns no results', function (done) {
  var location = 'some location value';
  geocoder.geocode.and.callFake(function (request, callback) {
    callback([], google.maps.GeocoderStatus.ZERO_RESULTS);
  });
 
  // Act
  var result = geocodeLocation(location);
 
  // Assert
  expect(geocoder.geocode).toHaveBeenCalled();
  var lastCall = geocoder.geocode.calls.mostRecent();
  var args = lastCall.args[0];
  expect(args.address).toEqual(location);
 
  result.fail(done);
});

On lines 4-6, we’re setting up how our mocked Geocoder instance will respond to our calls. In this case, we’ll return an empty array and the ZERO_RESULTS flag from Google.

On lines 12-15, we can then assert that these methods have been called with the expected values.

What if we wanted to return results? In that case, we’ll need some results to return, so let’s create them first.

Mock Results

The Google Maps Geocode response type looks something like this:

results[]: {
  types[]: string,
  formatted_address: string,
  address_components[]: {
    short_name: string,
    long_name: string,
    postcode_localities[]: string,
    types[]: string
  },
  partial_match: boolean,
  place_id: string,
  postcode_localities[]: string,
  geometry: {
    location: LatLng,
    location_type: GeocoderLocationType
    viewport: LatLngBounds,
    bounds: LatLngBounds
  }
}

Since we don’t care too much about the actual content of the fake result, we can create some helper methods to generate random/dummy data.

function getRandomInRange(from, to, fixed) {
  return parseFloat((Math.random() * (to - from) + from).toFixed(fixed));
}
 
var createResult = function (key) {
  // Generate a random lat/lng value
  var getRandomLatLng = function () {
    return new google.maps.LatLng(
      getRandomInRange(-180, 180, 7),
      getRandomInRange(-180, 180, 7),
    );
  };
 
  return {
    address_components: [],
    formatted_address: key + ' Some Street, Somewhere',
    geometry: {
      location: getRandomLatLng(),
      bounds: new google.maps.LatLngBounds(
        getRandomLatLng(),
        getRandomLatLng(),
      ),
      location_type: google.maps.GeocoderLocationType.ROOFTOP,
      viewport: new google.maps.LatLngBounds(
        getRandomLatLng(),
        getRandomLatLng(),
      ),
    },
    types: ['route'],
  };
};
 
var resultCount = 10;
var results = [];
for (var i = 0; i < resultCount; i++) {
  results.push(createResult(i));
}

Here, we’re just creating some random data in the format the the Geocoder will return.

In our code, we can then tell the mock Geocoder to return this.

geocoder.geocode.and.callFake(function (request, callback) {
  callback(results, google.maps.GeocoderStatus.OK);
});

Then, in our code, we can test that the results are mapped correctly.

// Act
var result = service.geocodeLocation(location);
 
// Assert
expect(geocoder.geocode).toHaveBeenCalled();
 
var lastCall = geocoder.geocode.calls.mostRecent();
var args = lastCall.args[0];
expect(args.address).toEqual(location);
 
result.then(function (returnedValue) {
  expect(returnedValue).toEqual([
    results[0].geometry.location.lat(),
    results[0].geometry.location.lng(),
  ]);
  done();
});

And we have a fully controllable mock Geocoder to play with.