Jest测试:多GCP场景下如何模拟GetService方法中的responsePromises变量(map映射结果)
问题背景
我在给持久化层的GetService.getItems()写单元测试时遇到了麻烦。这个方法里会把FILES数组通过map生成一个Promise数组responsePromises,每个Promise都是调用GCP Storage服务的read方法来获取云端JSON文件。
现在的问题是,我没法直接模拟这个responsePromises变量——之前试过用jest.spyOn去mockgcpStorageService.read方法,但因为项目里有多个GCP服务的使用场景,这种方式没法满足我的测试需求。我想知道有没有办法在Jest测试套件里直接模拟responsePromises这个变量?
附上相关代码:
export class GetService { constructor( @inject(BINDINGS.GcpStorageService) private gcpStorageService: GcpStorageServiceInterface, ) {} @CatchError() async getItems(): Promise<Interface> { const FILES = [X, Y, Z, K, L]; const responsePromises = FILES.map((FILE_NAME: string) => this.gcpStorageService.read(BUCKET, FILE_NAME)); const responseJsons = await Promise.all(responsePromises); // 省略后续逻辑 } }
解决方案
方案1:重构代码抽离逻辑(最推荐)
其实最稳妥且优雅的方式是做个小重构,把生成responsePromises的逻辑抽成一个独立的方法。这样一来,测试时就能直接mock这个方法,完全绕过对GCP服务的依赖。
修改后的GetService类:
export class GetService { constructor( @inject(BINDINGS.GcpStorageService) private gcpStorageService: GcpStorageServiceInterface, ) {} @CatchError() async getItems(): Promise<Interface> { const responsePromises = this.getResponsePromises(); const responseJsons = await Promise.all(responsePromises); // 省略后续逻辑 } // 把生成Promise数组的逻辑抽成保护方法,方便测试mock protected getResponsePromises(): Promise<string>[] { const FILES = [X, Y, Z, K, L]; return FILES.map((FILE_NAME: string) => this.gcpStorageService.read(BUCKET, FILE_NAME)); } }
测试代码示例:
describe('GetService', () => { let getService: GetService; const mockGcpStorageService = {} as GcpStorageServiceInterface; beforeEach(() => { getService = new GetService(mockGcpStorageService); }); it('should process items correctly', async () => { // 预设你需要的Promise数组返回值 const mockResponsePromises = [ Promise.resolve(JSON.stringify({ data: 'x-content' })), Promise.resolve(JSON.stringify({ data: 'y-content' })), Promise.resolve(JSON.stringify({ data: 'z-content' })), // 对应其他文件的模拟数据 ]; // 使用jest.spyOn mock这个保护方法 jest.spyOn(getService as any, 'getResponsePromises').mockReturnValue(mockResponsePromises); const result = await getService.getItems(); // 这里根据你的业务逻辑写断言,比如验证结果是否符合预期 expect(result).toEqual(/* 你的预期结果 */); }); });
方案2:Hack式拦截Array.map(不推荐,仅当无法修改原代码时使用)
如果因为各种原因不能修改原有业务代码,你可以尝试临时mock全局的Array.prototype.map方法,直接返回你预设的responsePromises。但这种方式要格外小心,测试完成后一定要恢复原生方法,避免影响其他测试用例。
测试代码示例:
describe('GetService', () => { let getService: GetService; const mockGcpStorageService = {} as GcpStorageServiceInterface; const originalMap = Array.prototype.map; // 保存原生方法 beforeEach(() => { getService = new GetService(mockGcpStorageService); }); afterEach(() => { // 测试结束后恢复原生map方法 Array.prototype.map = originalMap; }); it('should process items correctly', async () => { // 预设最终要解析的JSON数据 const mockResponseJsons = [ { id: 1, content: 'x' }, { id: 2, content: 'y' }, { id: 3, content: 'z' }, // 对应其他文件的数据 ]; // 拦截map调用,直接返回预设的Promise数组 Array.prototype.map = jest.fn(() => { return mockResponseJsons.map(item => Promise.resolve(JSON.stringify(item))); }) as typeof Array.prototype.map; const result = await getService.getItems(); // 写你的断言逻辑 expect(result).toEqual(/* 预期结果 */); expect(Array.prototype.map).toHaveBeenCalled(); }); });
方案3:通过依赖注入替换FILES数组(灵活扩展)
如果FILES数组可以改成依赖注入的方式传入,那测试时就能自定义文件列表,再配合mockread方法返回对应的数据,这种方式也能间接实现控制responsePromises的效果,同时让代码的扩展性更好。
修改后的GetService类:
export class GetService { private readonly FILES: string[]; constructor( @inject(BINDINGS.GcpStorageService) private gcpStorageService: GcpStorageServiceInterface, // 注入文件列表,默认值保留原有的文件 @inject(BINDINGS.FILE_LIST) files: string[] = [X, Y, Z, K, L], ) { this.FILES = files; } @CatchError() async getItems(): Promise<Interface> { const responsePromises = this.FILES.map((FILE_NAME: string) => this.gcpStorageService.read(BUCKET, FILE_NAME)); const responseJsons = await Promise.all(responsePromises); // 省略后续逻辑 } }
测试代码示例:
describe('GetService', () => { let getService: GetService; const mockGcpStorageService = { read: jest.fn() } as unknown as GcpStorageServiceInterface; beforeEach(() => { // 测试时注入自定义的文件列表 getService = new GetService(mockGcpStorageService, ['test-x', 'test-y', 'test-z']); }); it('should process items correctly', async () => { // 让read方法根据文件名返回对应的数据 mockGcpStorageService.read.mockImplementation((bucket, fileName) => { switch(fileName) { case 'test-x': return Promise.resolve(JSON.stringify({ data: 'test-x-content' })); case 'test-y': return Promise.resolve(JSON.stringify({ data: 'test-y-content' })); case 'test-z': return Promise.resolve(JSON.stringify({ data: 'test-z-content' })); default: return Promise.resolve('{}'); } }); const result = await getService.getItems(); // 写断言逻辑 expect(mockGcpStorageService.read).toHaveBeenCalledTimes(3); expect(result).toEqual(/* 预期结果 */); }); });
总结
优先推荐方案1,因为它通过最小的重构提升了代码的可测试性,逻辑清晰且没有hack风险。如果无法修改原代码,可以考虑方案2,但一定要记得恢复原生方法。方案3则适合需要灵活扩展文件列表的场景,同时也能很好地支持测试。
内容的提问来源于stack exchange,提问作者MrVoland

