I’m trying to test a class that is combining fetching, caching, geocoding and returning a list of places. The code that is being tested looks something like this (this is not a full working executable code, but enough to illustrate the problem):
interface Place {
name: string;
address: string;
longitude: number | null;
latitude: number | null;
}
class Places {
findAll(country: string): Place[] {
let places = this.placesCache.get(country)
if (places === null)
places = this.placesExternalApiClient.fetchAll(country);
// BREAKPOINT no.1 (before store() call)
this.placesCache.store(country, places);
// BREAKPOINT no.2 (after store() call)
}
for (const place of places) {
// BREAKPOINT no.3 (before geocode() call)
const geocodedAddress = this.geocoder.geocode(place.address);
place.longitude = geocodedAddress.longitude;
place.latitude = geocodedAddress.latitude;
}
return places;
}
}
class PlacesExternalApiClient {
fetchAll(country: string): Place[] {
// makes a request to some external API server, parses results and returns them
}
}
class PlacesCache {
store(country: string, places: Place[]) {
// stores country and places in database with a relation
}
get(country: string): Place[] | null {
// if country is in database, returns all related places (possibly []),
// if country is not in db, returns null
}
}
interface GeocodedAddress {
address: string;
longitude: number;
latitude: number;
}
class Geocoder {
geocode(address: string): GeocodedAddress {
// makes a request to some geocoding service like Google Geocoder,
// and returns the best result.
}
}
And this is the test (kinda, illustrates the problem):
mockedPlaces = [
{ name: "place no. 1", address: "Atlantis", longitude: null, latitude: null },
{ name: "place no. 2", address: "Mars base no. 3", longitude: null, latitude: null },
]
mockedPlacesExternalApiClient = {
fetchAll: jest.fn().mockImplementation(() => structuredClone(mockedPlaces))
}
mockedGeocodedAddress = {
address: "doesn't matter here",
longitude: 1337,
latitude: 7331,
}
mockedGeocoder = {
geocode: jest.fn().mockImplementation(() => structuredClone(mockedGeocodedAddress))
}
describe('Places#findAll()', () => {
it('should call cache#store() once when called two times', () => {
const storeMethod = jest.spyOn(placesCache, 'store');
places.findAll('abc');
places.findAll('abc');
expect(storeMethod).toHaveBeenCalledTimes(1);
expect(storeMethod).toHaveBeenNthCalledWith(
1,
'abc',
mockedPlaces, // ERROR: expected places have lng and lat null, null
// but received places have lng and lat 1337, 7331
);
})
})
I found during debugging that, during first iteration over places.findAll()
(when there is cache miss):
- on
BREAKPOINT no.1
:places (var)
hasnull
as coordinates - on
BREAKPOINT no.2
:places (var)
still hasnull
as coordinatesplacesCache.store
(which is aSpied
jest object) has.mock.calls[0][1]
which is an array where there a two places, both withnull
for lng and lat as expected
- on first hit of
BREAKPOINT no.3
: nothing changed in.mock.calls
and nothing changed inplaces (var)
- on second hit of
BREAKPOINT no.3
: first place in both arrays:places (var)
and array.mock.calls[0][1]
had changed their coordinates to 1337, 7331!
because of that, later when I ask jest what argument storeMethod was called with, it incorrectly thinks it was called with latitude and longitude 1337, 7331 and not null
, null
.
It does not work as I expected it to work because spyOn simply assigns/pushes arguments that it got to .mock.calls[]
, it does not perform a deep copy, and therefore when the argument is an object that is later mutated, that change is also affecting what the spied object recorded as its call argument.
How can I make spyOn deep clone the arguments it gets?
or, How can I make this test work some other way, without changing the implementation of business logic (first snippet)?
I want to test that store()
was called with longitude and latitude set to null
. Now my test gives me a false negative.