Attempting to test a complex class that handles fetching, caching, geocoding, and returning a list of places. The code being tested showcases the following structure:
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.
}
}
The test scenario is as follows:
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
);
})
})
A detailed debugging session revealed inconsistencies during the testing process:
- At
BREAKPOINT no.1
, the variableplaces
contains coordinates set tonull
. - Upon reaching
BREAKPOINT no.2
:places
still reflects coordinates asnull
.placesCache.store
retains the expected data with both places having coordinates set tonull
.
- At the initial encounter with
BREAKPOINT no.3
, there are no changes in the data or variables. - However, on the second occurrence of
BREAKPOINT no.3
, the coordinates for the first place in bothplaces
array and.mock.calls[0][1]
array shift fromnull
to 1337, 7331.
This issue causes inaccuracies in the test results due to the way spyOn records arguments without deep copying, resulting in unexpected behavior when objects are mutated. How can I ensure spyOn performs a deep clone of the arguments it receives? Alternatively, how can I refine the testing methodology without altering the original business logic implementation?
The primary objective is to confirm that store()
was called with longitude and latitude values set to null
, despite the current test producing false negatives.