You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何让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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.10 11:31:06