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

Jest toHaveBeenNthCalledWith为何取变量当前值而非调用时的值?

问题

我正在开发一个使用@nestjs/axios、@nestjs/testing等插件的NestJS应用,编写了一个分页请求API并收集结果ID的syncRecordList函数:

async syncRecordList(limit: number = 1000) {
    const url = 'https://my.test.url/records';
    const headers = {
        Authorization: 'Bearer token-value',
    };
    const params = {
        limit, // 每页条数
        offset: 0, // 分页偏移量
    };
    const itemIds: number[] = [];
    let more: boolean = true;
    while (more) {
        const response = await this.httpService.axiosRef.get(url, {
            headers,
            params,
        });
        response.data.items.forEach((item) => {
            itemIds.push(item.id);
        });
        params.offset += limit;
        more = response.data.hasMore;
    }
    return itemIds;
}

为测试该函数,我编写了如下测试用例:

describe('syncRecordList', () => {
    it('should call multiple times if there is pagination', async () => {
        const response0 = {
            data: {
                items: [{ id: 1 }, { id: 2 }],
                hasMore: true,
            },
        };

        const response1 = {
            data: {
                items: [{ id: 3 }, { id: 4 }],
                hasMore: false,
            },
        };

        const callExpectWith0 = [
            'https://my.test.url/records',
            {
                headers: {
                    Authorization: 'Bearer token-value',
                },
                params: {
                    limit: 2,
                    offset: 0
                },
            },
        ]

        const callExpectWith1 = [
            'https://my.test.url/records',
            {
                headers: {
                    Authorization: 'Bearer token-value',
                },
                params: {
                    limit: 2,
                    offset: 2
                },
            },
        ]

        const httpSpy = jest.spyOn(service.httpService.axiosRef, 'get')
            .mockResolvedValueOnce(response0)
            .mockResolvedValueOnce(response1);

        await service.syncRecordList(2);

        expect(httpSpy).toHaveBeenCalledTimes(2);
        expect(httpSpy).toHaveBeenNthCalledWith(1, ...callExpectWith0);
        expect(httpSpy).toHaveBeenNthCalledWith(2, ...callExpectWith1);
    });
});

我期望第一次调用使用limit: 2, offset: 0,第二次使用limit: 2, offset: 2,但测试失败,断言显示两次调用的offset均为4——即测试结束时params变量的最终值,而非调用时的实际值。

修改函数,每次调用时传入params的副本后测试即可通过:

async syncRecordList(type: NetSuiteRecordTypes, integrationId: string, limit: number = 1000) {
    // ... 省略其他代码
    const params = {
        limit, // 每页条数
        offset: 0, // 分页偏移量
    };
    const itemIds: number[] = [];
    let more: boolean = true;
    while (more) {
        // 为让测试通过添加的逻辑:params对象被修改导致测试失败,因Jest默认引用变量当前值而非调用时的实际值
        const paramsToUse = { ...params };
        const response = await this.httpService.axiosRef.get(url, {
            headers,
            params: paramsToUse,
        });
        response.data.items.forEach((item) => {
            itemIds.push(item.id);
        });
        params.offset += limit;
        more = response.data.hasMore;
    }
    return itemIds;
}

我的问题是:有没有办法让Jest的toHaveBeenNthCalledWith断言使用调用时传入的参数值,而非测试结束时变量的当前值?

解决方案

方法1:手动捕获调用时的参数快照

Jest默认保存参数的引用而非值的副本,可在mock函数内手动深拷贝并记录每次调用的参数:

it('should call multiple times if there is pagination', async () => {
    const response0 = { data: { items: [{ id: 1 }, { id: 2 }], hasMore: true } };
    const response1 = { data: { items: [{ id: 3 }, { id: 4 }], hasMore: false } };
    const callExpectWith0 = ['https://my.test.url/records', { headers: { Authorization: 'Bearer token-value' }, params: { limit: 2, offset: 0 } }];
    const callExpectWith1 = ['https://my.test.url/records', { headers: { Authorization: 'Bearer token-value' }, params: { limit: 2, offset: 2 } }];

    const callArgs = [];
    const httpSpy = jest.spyOn(service.httpService.axiosRef, 'get')
        .mockImplementation((url, config) => {
            // 深拷贝参数,保存调用时的快照
            callArgs.push([url, JSON.parse(JSON.stringify(config))]);
            return callArgs.length === 1 ? Promise.resolve(response0) : Promise.resolve(response1);
        });

    await service.syncRecordList(2);

    expect(httpSpy).toHaveBeenCalledTimes(2);
    expect(callArgs[0]).toEqual(callExpectWith0);
    expect(callArgs[1]).toEqual(callExpectWith1);
});

通过JSON.parse(JSON.stringify(config))深拷贝参数,确保记录的是调用时的参数状态,而非后续被修改的引用。

方法2:自定义mock函数的参数捕获逻辑

使用jest.fn()创建mock,手动保存参数的副本到mock.calls:

it('should call multiple times if there is pagination', async () => {
    const response0 = { data: { items: [{ id: 1 }, { id: 2 }], hasMore: true } };
    const response1 = { data: { items: [{ id: 3 }, { id: 4 }], hasMore: false } };
    const callExpectWith0 = ['https://my.test.url/records', { headers: { Authorization: 'Bearer token-value' }, params: { limit: 2, offset: 0 } }];
    const callExpectWith1 = ['https://my.test.url/records', { headers: { Authorization: 'Bearer token-value' }, params: { limit: 2, offset: 2 } }];

    const mockGet = jest.fn((url, config) => {
        // 手动添加深拷贝后的参数到mock调用记录
        mockGet.mock.calls.push([url, { ...config, params: { ...config.params } }]);
        return mockGet.mock.calls.length === 1 ? Promise.resolve(response0) : Promise.resolve(response1);
    });
    service.httpService.axiosRef.get = mockGet;

    await service.syncRecordList(2);

    expect(mockGet).toHaveBeenCalledTimes(2);
    expect(mockGet.mock.calls[0]).toEqual(callExpectWith0);
    expect(mockGet.mock.calls[1]).toEqual(callExpectWith1);
});

这种方式绕过Jest默认的引用保存逻辑,直接存储调用时的参数副本。

方法3:保持原函数的参数副本传递(推荐)

你当前修改函数,每次创建params副本再传入的方式不仅能解决测试问题,还让函数逻辑更清晰——传递参数快照而非引用,避免后续修改影响已发出的请求(即使axios内部会处理参数,显式创建副本也更符合代码可读性要求)。这种方式无需修改测试代码,同时优化了业务逻辑的健壮性。

内容的提问来源于stack exchange,提问作者undertakingyou

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.29 12:17:12