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

如何为带错误捕获/抛出逻辑的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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.24 15:54:54