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

Angular 18缓存拦截器Jest单元测试失败求助

Angular 18缓存拦截器单元测试失败排查

问题背景

Angular 18项目中实现的缓存拦截器在应用内缓存行为正常,但对应的Jest单元测试持续失败,报错Expected one matching request for criteria "Match URL: /test", found none.。

相关代码

测试文件(cache.interceptor.spec.ts)

import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { HttpClient, HTTP_INTERCEPTORS, provideHttpClient } from '@angular/common/http';
import { cacheInterceptor } from './cache.interceptor';
import { CACHE_SERVICE_TOKEN } from './cache.service';
import { MockCacheService } from './services/mock-cache.service';

describe('CacheInterceptor', () => {
 let httpMock: HttpTestingController;
 let httpClient: HttpClient;
 let mockCacheService: MockCacheService;

 beforeEach(() => {
  mockCacheService = new MockCacheService();

  TestBed.configureTestingModule({
   providers: [
    provideHttpClient(),
    provideHttpClientTesting(),
    { provide: HTTP_INTERCEPTORS, useValue: cacheInterceptor, multi: true },
    { provide: CACHE_SERVICE_TOKEN, useValue: mockCacheService }
   ]
  });

  httpMock = TestBed.inject(HttpTestingController);
  httpClient = TestBed.inject(HttpClient);
 });

 afterEach(() => {
  httpMock.verify();
 });

 it('should cache GET requests', (done) => {
  const testUrl = '/test';
  const testData = { data: 'test' };

  // First request, expect a network call and cache the response
  httpClient.get(testUrl).subscribe(response => {
   expect(response).toEqual(testData);
  });

  const req = httpMock.expectOne(testUrl);
  expect(req.request.method).toBe('GET');
  req.flush(testData);

  // Second request, should return cached response
  httpClient.get(testUrl).subscribe(response => {
   expect(response).toEqual(testData);
   done();
  });

  // Ensure no network call is made for the cached response
  httpMock.expectNone(testUrl);
 });

 it('should not cache non-GET requests', (done) => {
  const testUrl = '/test';
  const testData = { data: 'test' };

  // POST request, should not be cached
  httpClient.post(testUrl, testData).subscribe(response => {
   expect(response).toEqual(testData);
   done();
  });

  const req = httpMock.expectOne(testUrl);
  expect(req.request.method).toBe('POST');
  req.flush(testData);

  // Ensure no cache for POST request
  expect(mockCacheService.get(testUrl)).toBeNull();
 });
});

拦截器文件(cache.interceptor.ts)

import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { HttpHandlerFn, HttpEvent, HttpResponse } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_SERVICE_TOKEN } from './cache.service';

export const cacheInterceptor: HttpInterceptorFn = (req, next: HttpHandlerFn): Observable<HttpEvent<any>> => {
  const cacheService = inject(CACHE_SERVICE_TOKEN);

  if (req.method !== 'GET') {
    console.log(`Skipping non-GET request: ${req.method} ${req.url}`);
    return next(req);
  }

  console.log(`Handling GET request: ${req.url}`);
  const cachedResponse = cacheService.get(req.url);
  if (cachedResponse) {
    console.log(`Returning cached response for ${req.url}`);
    return of(new HttpResponse({ status: 200, body: cachedResponse }));
  }

  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        console.log(`Caching response for ${req.url}`);
        cacheService.set(req.url, event.body);
      }
    })
  );
};

缓存服务文件(cache.service.ts)

import { Injectable, InjectionToken } from '@angular/core';

export interface ICacheService {
  get(url: string): any | null;
  set(url: string, response: any): void;
}

interface CacheEntry {
  url: string;
  response: any;
  lastRead: number;
}

@Injectable({ providedIn: 'root' })
export class CacheService implements ICacheService {
  private cache = new Map<string, CacheEntry>();
  private readonly maxAge = 300000; // 5 minutes

  get(url: string): any | null {
    const cached = this.cache.get(url);

    if (!cached) {
      return null;
    }

    const isExpired = (Date.now() - cached.lastRead) > this.maxAge;
    if (isExpired) {
      this.cache.delete(url);
      return null;
    }

    return cached.response;
  }

  set(url: string, response: any): void {
    const entry: CacheEntry = { url, response, lastRead: Date.now() };
    this.cache.set(url, entry);
    this.cleanup();
  }

  private cleanup() {
    const expired = Date.now() - this.maxAge;
    this.cache.forEach((entry, url) => {
      if (entry.lastRead < expired) {
        this.cache.delete(url);
      }
    });
  }
}

export const CACHE_SERVICE_TOKEN = new InjectionToken<ICacheService>('CACHE_SERVICE_TOKEN');

模拟缓存服务(mock-cache.service.ts)

import { ICacheService } from '../cache.service';

export class MockCacheService implements ICacheService {
  private cache = new Map<string, any>();

  get(url: string): any | null {
    return this.cache.get(url) || null;
  }

  set(url: string, response: any): void {
    this.cache.set(url, response);
  }
}

应用配置(app.config.ts)

import { ApplicationConfig, ErrorHandler, importProvidersFrom } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { MatSnackBarModule } from '@angular/material/snack-bar';

import { routes } from './app.routes';
import { errorHandlerInterceptor } from './server-error.interceptor';
import { GlobalErrorHandler } from './global-error-handler';
import { retryInterceptor } from './retry.interceptor';
import { cacheInterceptor } from './cache.interceptor';
import { CACHE_SERVICE_TOKEN, CacheService } from './cache.service';


export const appConfig: ApplicationConfig = {
  providers: [
    { provide: ErrorHandler, useClass: GlobalErrorHandler }, 
    { provide: CACHE_SERVICE_TOKEN, useClass: CacheService},
    importProvidersFrom([MatSnackBarModule, BrowserAnimationsModule]),
    provideRouter(routes), 
    provideClientHydration(), 
    provideAnimationsAsync(),
    provideHttpClient(withInterceptors([errorHandlerInterceptor, retryInterceptor, cacheInterceptor]))]
};

测试失败结果

FAIL  src/app/cache.interceptor.spec.ts
  CacheInterceptor
    × should cache GET requests (78 ms)
    × should not cache non-GET requests (17 ms)

  ● CacheInterceptor › should cache GET requests

    Expected one matching request for criteria "Match URL: /test", found none.

      39 |   });
      40 |
    > 41 |   const req = httpMock.expectOne(testUrl);
         |                        ^
      42 |   expect(req.request.method).toBe('GET');
      43 |   req.flush(testData);

问题根源与解决方案

问题原因

测试模块中错误地使用HTTP_INTERCEPTORS令牌注册函数式拦截器(HttpInterceptorFn)。Angular 14+引入的函数式拦截器必须通过provideHttpClient(withInterceptors([...]))来配置,而HTTP_INTERCEPTORS仅适用于传统的类式拦截器(实现HttpInterceptor接口)。错误的注册方式导致拦截器未被正确加载,请求未经过拦截器逻辑就直接被处理,进而引发测试断言失败。

修复步骤

修改测试文件中的TestBed.configureTestingModule配置,移除HTTP_INTERCEPTORS相关配置,改用withInterceptors注册函数式拦截器:

beforeEach(() => {
  mockCacheService = new MockCacheService();

  TestBed.configureTestingModule({
    providers: [
      // 用withInterceptors注册函数式拦截器
      provideHttpClient(withInterceptors([cacheInterceptor])),
      provideHttpClientTesting(),
      { provide: CACHE_SERVICE_TOKEN, useValue: mockCacheService }
    ]
  });

  httpMock = TestBed.inject(HttpTestingController);
  httpClient = TestBed.inject(HttpClient);
});

验证修改

  1. 第一个测试中,首次GET请求会经过拦截器,由于缓存为空,会触发真实的HTTP请求,httpMock.expectOne(testUrl)会匹配到请求,后续缓存响应;第二次GET请求会直接返回缓存数据,httpMock.expectNone(testUrl)断言通过。
  2. 第二个测试中,POST请求会被拦截器跳过缓存逻辑,直接触发HTTP请求,mockCacheService.get(testUrl)会返回null,断言通过。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.20 18:34:50