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); });
验证修改
- 第一个测试中,首次GET请求会经过拦截器,由于缓存为空,会触发真实的HTTP请求,
httpMock.expectOne(testUrl)会匹配到请求,后续缓存响应;第二次GET请求会直接返回缓存数据,httpMock.expectNone(testUrl)断言通过。 - 第二个测试中,POST请求会被拦截器跳过缓存逻辑,直接触发HTTP请求,
mockCacheService.get(testUrl)会返回null,断言通过。
内容的提问来源于stack exchange,提问作者Ravi Mill
相关产品推荐
相关产品推荐

