如何让Jest间谍函数深拷贝存储调用参数以避免测试误判?
问题:Jest spyOn 记录参数因引用修改导致断言失败
测试整合了数据获取、缓存、地理编码的Places类时,发现Jest的spyOn监听store方法时会按引用存储调用参数。后续业务代码修改参数对象的经纬度值后,spyOn记录的参数也随之改变,导致断言store被调用时经纬度为null的测试失败。
业务代码
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[] { // 调用外部API并返回结果 } } class PlacesCache { store(country: string, places: Place[]) { // 将数据存储到数据库 } get(country: string): Place[] | null { // 从数据库获取数据,不存在则返回null } } interface GeocodedAddress { address: string; longitude: number; latitude: number; } class Geocoder { geocode(address: string): GeocodedAddress { // 调用地理编码服务返回结果 } }
测试代码
const mockedPlaces = [ { name: "place no. 1", address: "Atlantis", longitude: null, latitude: null }, { name: "place no. 2", address: "Mars base no. 3", longitude: null, latitude: null }, ] const mockedPlacesExternalApiClient = { fetchAll: jest.fn().mockImplementation(() => structuredClone(mockedPlaces)) } const mockedGeocodedAddress = { address: "doesn't matter here", longitude: 1337, latitude: 7331, } const 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, // 错误:预期经纬度为null,但实际已被修改为1337/7331 ); }) })
解决方案(无需修改业务代码)
方案1:修改spyOn实现,记录深拷贝的参数
通过重写spyOn的mock实现,在调用原方法前深拷贝参数,让Jest记录拷贝后的对象(不会被后续代码修改):
describe('Places#findAll()', () => { it('should call cache#store() once when called two times', () => { // 保存原方法引用 const originalStore = placesCache.store; // 创建spy并替换实现,深拷贝参数后再调用原方法 const storeMethod = jest.spyOn(placesCache, 'store').mockImplementation((country, places) => { const copiedPlaces = structuredClone(places); return originalStore.call(placesCache, country, copiedPlaces); }); places.findAll('abc'); places.findAll('abc'); expect(storeMethod).toHaveBeenCalledTimes(1); expect(storeMethod).toHaveBeenNthCalledWith(1, 'abc', mockedPlaces); }) })
方案2:使用Jest匹配器进行结构断言
不直接对比数组引用,而是用expect.arrayContaining和expect.objectContaining验证参数的结构与预期值,忽略后续修改的影响:
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', expect.arrayContaining( mockedPlaces.map(place => expect.objectContaining(place)) ) ); }) })
内容的提问来源于stack exchange,提问作者Robert Dorna
相关产品推荐
相关产品推荐

