如何为带错误捕获/抛出逻辑的NestJS拦截器编写单元测试?
问题分析与解决方案
1. 拦截器错误重抛逻辑问题
你的测试失败核心原因是**catchError中使用了async/await,导致throwError返回的错误Observable被包装成了成功的Promise值**。RxJS的catchError期望返回一个Observable,而async函数会自动将返回值包装为Promise,最终被转换为一个发出该值的Observable,所以错误被“吞”了,进入了next回调而非error回调。
修复后的拦截器代码
@Injectable() export class TokenInterceptor implements NestInterceptor { private readonly authUrl = 'https://example.come/tokens/OAuth'; private token = ''; constructor(private readonly http: HttpService) { this.fetchToken().subscribe((token) => { this.token = token; this.http.axiosRef.defaults.headers.common.Authorization = this.token; }); } public intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> { this.http.axiosRef.defaults.headers.common.Authorization = this.token; return next.handle().pipe( catchError((error: AxiosError) => { if (error.config) { if (error.response?.status === HttpStatus.UNAUTHORIZED && error.config.url !== this.authUrl) { return this.fetchToken().pipe( switchMap((newToken) => { this.token = newToken; this.http.axiosRef.defaults.headers.common.Authorization = newToken; // 更新原请求配置的Authorization头,重新发起请求 const updatedConfig = { ...error.config, headers: { ...error.config.headers, Authorization: newToken, }, }; return this.http.request(updatedConfig); }), ); } } // 直接抛出异常,catchError会自动将其转换为错误Observable throw new HttpException(error.message, error.response?.status ?? 500, { cause: error }); }), ); } private fetchToken(): Observable<string> { return this.http .post<ISharepointTokenResponseData>( this.authUrl, {/*special params here*/}, ) .pipe(map((res) => `Bearer ${res.data.access_token}`)); } }
关键修改点:
- 移除
catchError的async修饰符,改用RxJS的switchMap处理异步刷新Token逻辑 - 错误分支直接
throw异常,替代返回throwError的写法 - 修复重试逻辑:原代码仅返回请求对象,现在通过
http.request重新执行带新Token的请求
2. 完善单元测试用例
修复“非401错误透传”测试
it('should pass the error through for non-401 errors', (done: jest.DoneCallback) => { const executionCtxMock = mockDeep<ExecutionContext>(); const axiosError = new AxiosError( 'you screwed up!', '403', {}, undefined, { status: 403, statusText: 'Forbidden', data: '', headers: {}, config: {} } ); const nextMock: CallHandler = { handle: () => throwError(() => axiosError), }; interceptor.intercept(executionCtxMock, nextMock).subscribe({ next: () => { expect(false).toBe(true); // 进入此分支则测试失败 done(); }, error: (err: HttpException) => { expect(err.message).toBe('you screwed up!'); expect(err.getStatus()).toBe(403); expect(err.cause).toBe(axiosError); done(); }, }); });
实现“401时重新获取Token并重试”测试
it('should refetch the auth token when we get a 401 "unauthorized" response and add that new token to outgoing requests', (done: jest.DoneCallback) => { const executionCtxMock = mockDeep<ExecutionContext>(); const originalToken = 'old-token'; const newToken = 'new-valid-token'; // 模拟初始化Token、刷新Token两次请求 jest.spyOn(httpService, 'post') .mockReturnValueOnce(of({ data: { access_token: originalToken } } as AxiosResponse)) .mockReturnValueOnce(of({ data: { access_token: newToken } } as AxiosResponse)); // 模拟原请求返回401,重试请求返回成功 const axios401Error = new AxiosError( 'Unauthorized', '401', { url: 'https://target-api.com' }, undefined, { status: 401, statusText: 'Unauthorized', data: '', headers: {}, config: { url: 'https://target-api.com' } } ); jest.spyOn(httpService, 'request') .mockReturnValueOnce(throwError(() => axios401Error)) .mockReturnValueOnce(of({ data: 'success' })); const nextMock: CallHandler = { handle: () => throwError(() => axios401Error), }; interceptor.intercept(executionCtxMock, nextMock).subscribe({ next: (res) => { expect(res).toEqual({ data: 'success' }); // 验证Token已更新 expect(interceptor['token']).toBe(`Bearer ${newToken}`); expect(httpService.axiosRef.defaults.headers.common.Authorization).toBe(`Bearer ${newToken}`); // 验证刷新Token的请求被触发 expect(httpService.post).toHaveBeenCalledTimes(2); // 验证重试请求使用了新Token expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining({ headers: expect.objectContaining({ Authorization: `Bearer ${newToken}` }) })); done(); }, error: () => { expect(false).toBe(true); done(); }, }); });
实现“401但请求Auth URL时不刷新Token”测试
it('should NOT refetch the auth token when we get a 401 "unauthorized" response and the URL is the auth request URL', (done: jest.DoneCallback) => { const executionCtxMock = mockDeep<ExecutionContext>(); const authUrl401Error = new AxiosError( 'Auth Failed', '401', { url: 'https://example.come/tokens/OAuth' }, undefined, { status: 401, statusText: 'Unauthorized', data: '', headers: {}, config: { url: 'https://example.come/tokens/OAuth' } } ); const nextMock: CallHandler = { handle: () => throwError(() => authUrl401Error), }; interceptor.intercept(executionCtxMock, nextMock).subscribe({ next: () => { expect(false).toBe(true); done(); }, error: (err: HttpException) => { expect(err.message).toBe('Auth Failed'); expect(err.getStatus()).toBe(401); // 验证仅初始化时调用过一次Token请求,未触发刷新 expect(httpService.post).toHaveBeenCalledTimes(1); done(); }, }); });
额外测试注意事项
- 尽量避免直接访问私有成员(如
interceptor['token']),可添加包内可见的getter方法,或通过跟踪fetchToken的调用结果验证Token更新 - 确保
HttpService的axiosRef.defaults.headers被正确初始化,避免测试中出现undefined错误
内容的提问来源于stack exchange,提问作者Chris Barr
相关产品推荐
相关产品推荐

