如何对NestJS自定义CacheInterceptor进行单元测试
Nest自定义CacheInterceptor单元测试实现方案
测试核心思路是:你只需要mock拦截器依赖的内置属性、构造符合入参要求的ExecutionContext mock对象,即可完成trackBy方法的全逻辑覆盖,不需要启动完整的Nest测试容器。
核心依赖Mock说明
你的自定义拦截器只用到了两个继承自父类的属性,以及ExecutionContext入参的两个方法,所有mock都只需要实现用到的部分即可,不需要补全全量接口:
reflector:仅需mockget方法,返回预设的缓存元数据值httpAdapterHost:仅需在其httpAdapter属性上挂载mock的httpAdapter,后者实现getRequestUrl方法返回预设请求路径即可ExecutionContext:仅需实现getHandler(返回模拟的路由处理函数)、switchToHttp(返回带getRequest方法的对象,getRequest返回构造的mock请求对象,包含body和headers字段)两个方法,剩余接口方法不需要实现,用TS类型断言绕过类型检查即可。
测试覆盖场景建议
为保证逻辑符合预期,需要覆盖以下场景:
- 所有hash因子(缓存元数据、请求URL、请求体、Accept-Language头)都存在时,缓存键计算正确
- 任意一个hash因子变化时,生成的缓存键会同步变化
- 部分因子不存在(比如无缓存元数据、GET请求无body、请求未带Accept-Language头)时,不会抛出异常,可正常生成缓存键
完整测试代码(Jest)
import { MyCacheInterceptor } from './my-cache.interceptor'; import { CACHE_KEY_METADATA, ExecutionContext } from '@nestjs/common'; import { createHash } from 'crypto'; describe('MyCacheInterceptor', () => { let interceptor: MyCacheInterceptor; // mock依赖对象,只实现用到的方法 const mockReflector = { get: jest.fn() }; const mockHttpAdapter = { getRequestUrl: jest.fn() }; const mockHttpAdapterHost = { httpAdapter: mockHttpAdapter }; // 构造函数需要传入cacheManager,传空对象mock即可,测试trackBy不会用到 const mockCacheManager = {}; beforeEach(() => { // 重置所有mock调用记录 jest.clearAllMocks(); // 实例化拦截器,传入构造函数所需的三个依赖 interceptor = new MyCacheInterceptor( mockCacheManager as any, mockReflector as any, mockHttpAdapterHost as any ); }); /** * 构造mock ExecutionContext的工具函数 * 不同测试用例可传入不同参数模拟不同请求场景 */ const buildMockContext = (options: { requestUrl: string; body?: any; acceptLanguage?: string; }): ExecutionContext => { const mockRequest = { body: options.body, headers: { 'accept-language': options.acceptLanguage } }; return { getHandler: jest.fn().mockReturnValue(() => {}), switchToHttp: jest.fn().mockReturnValue({ getRequest: jest.fn().mockReturnValue(mockRequest) }) } as unknown as ExecutionContext; }; // 对齐拦截器hash逻辑,计算预期缓存键 const calcExpectedKey = (factors: (string | undefined)[]) => { return factors.reduce((hash, item) => { return hash.update(item ? Buffer.from(item) : Buffer.alloc(0)); }, createHash('md5')).digest('hex'); }; it('所有hash因子存在时,可正确生成缓存键', () => { const testCacheMeta = 'user_list_cache'; const testUrl = '/api/users'; const testBody = { page: 1, pageSize: 10 }; const testLang = 'zh-CN'; // 预设mock返回值 mockReflector.get.mockReturnValue(testCacheMeta); mockHttpAdapter.getRequestUrl.mockReturnValue(testUrl); const context = buildMockContext({ requestUrl: testUrl, body: testBody, acceptLanguage: testLang }); const resultKey = interceptor.trackBy(context); const expectedKey = calcExpectedKey([ testCacheMeta, testUrl, JSON.stringify(testBody), testLang ]); expect(resultKey).toBe(expectedKey); // 验证依赖方法被正确调用 expect(mockReflector.get).toHaveBeenCalledWith(CACHE_KEY_METADATA, context.getHandler()); expect(mockHttpAdapter.getRequestUrl).toHaveBeenCalledWith(context.switchToHttp().getRequest()); }); it('请求体变化时,生成的缓存键不同', () => { mockReflector.get.mockReturnValue('user_list_cache'); mockHttpAdapter.getRequestUrl.mockReturnValue('/api/users'); const ctx1 = buildMockContext({ requestUrl: '/api/users', body: { page: 1 }, acceptLanguage: 'zh-CN' }); const ctx2 = buildMockContext({ requestUrl: '/api/users', body: { page: 2 }, acceptLanguage: 'zh-CN' }); expect(interceptor.trackBy(ctx1)).not.toBe(interceptor.trackBy(ctx2)); }); it('Accept-Language头变化时,生成的缓存键不同', () => { mockReflector.get.mockReturnValue('user_list_cache'); mockHttpAdapter.getRequestUrl.mockReturnValue('/api/users'); const ctx1 = buildMockContext({ requestUrl: '/api/users', body: { page: 1 }, acceptLanguage: 'zh-CN' }); const ctx2 = buildMockContext({ requestUrl: '/api/users', body: { page: 1 }, acceptLanguage: 'en-US' }); expect(interceptor.trackBy(ctx1)).not.toBe(interceptor.trackBy(ctx2)); }); it('无缓存元数据、无请求体、无Accept-Language头时,可正常生成缓存键', () => { const testUrl = '/api/public/config'; mockReflector.get.mockReturnValue(undefined); mockHttpAdapter.getRequestUrl.mockReturnValue(testUrl); const context = buildMockContext({ requestUrl: testUrl }); const resultKey = interceptor.trackBy(context); const expectedKey = calcExpectedKey([ undefined, testUrl, JSON.stringify(undefined), undefined ]); expect(resultKey).toBe(expectedKey); }); });
注意事项
- 不需要为了满足TS类型要求补全ExecutionContext的所有方法,测试场景下只需要保证实际调用到的方法存在即可,类型断言是单测里的常规操作。
- 父类
CacheInterceptor自带的缓存读写、响应拦截逻辑属于Nest框架官方维护范畴,已经过框架层测试,不需要在业务单测中重复覆盖,只需要验证自己重写的trackBy逻辑即可。 - 不要硬编码md5结果作为预期值,用和业务逻辑一致的辅助函数计算预期值,后续如果hash因子调整,只需要同步修改辅助函数的入参即可,降低测试维护成本。
内容的提问来源于stack exchange,提问作者sclausen
相关产品推荐
相关产品推荐

