1. 服务测试为什么是 Angular 项目的生死线
先问一个扎心的问题:你所在的项目,是组件测试多还是服务测试多?
我见过的绝大多数团队,测试覆盖率看着挺高,一查全是组件测试——模板渲染对了没、按钮点击响应了没、输入框绑定对不对。服务层的测试少得可怜,甚至根本没有。为什么?因为大多数人的惯性思维是:服务不就是调个后端接口吗?后端接口联调的时候测过就行了,单测里覆盖它有什么意义?
这个想法在项目初期确实没毛病。后端接口是通的,你的服务代码也就薄薄一层,把http.get包装一下返回出去,几乎没什么逻辑。但是项目一旦进入生产就绪阶段,事情就完全不一样了:
第一,服务层是数据出入口,是所有业务逻辑的集散地。组件拿到的数据都是从服务来的,你在服务里做的数据处理、错误封装、缓存策略、重试逻辑,这些才是真正的业务核心。组件测试只能覆盖界面表现,覆盖不了数据流。第二,服务层一旦出问题,影响的是整个功能模块,而不是一个组件。第三,服务层的逻辑复杂度会随着项目演进急剧上升,今天你只是返回一个数组,明天你可能要做二级缓存、请求合并、错误转换、字段映射。如果没有服务层测试兜底,这些逻辑一改就崩,而且崩得毫无预兆。
我强烈建议每个 Angular 项目都把服务测试作为核心测试策略的一部分,而不是可有可无的加分项。这一篇我就聚焦最核心的 HTTP 服务测试——用HttpClientTestingModule把后端依赖彻底隔离,把请求拦截在本地,像做单元实验一样掌控每一个请求和响应。
这批内容放进我的生产就绪系列里,正好是第五篇,前四篇分别讲了模块架构、懒加载路由、RxJS 响应式状态管理和构建优化。如果你还没看,建议按顺序刷一遍,因为服务测试的很多实践要建立在之前的基础之上。不过如果你现在只对测试感兴趣,这篇文章足够独立,直接照着做就行。
2. HttpClientTestingModule 到底帮你解决了什么
要理解HttpClientTestingModule,先得明白一个痛点:HttpClient是 Angular 依赖注入体系里的一个服务,它的真实实现会发起真实的网络请求。如果你在测试里注入真正的HttpClient,那测试就跑到了浏览器或者 Node 环境里去发真实 HTTP 请求,本地测试依赖后端服务,后端一抖,测试就红,这根本没法做单元测试。
真实场景中,你的后端服务往往在多个环境间切换——本地、测试、预发、生产。单元测试跑在一个受控的、确定性的环境里,不应该依赖网络。传统 mock 的做法是手动创建一个假的服务替代HttpClient,但这种方式侵入性太强,你要么改服务的构造函数,要么用overrideProvider把整个服务替换掉——这等于没测真实代码,而是在测一个模拟对象。
HttpClientTestingModule走的是一条更聪明的路线:它不替换你的服务类,也不拦截你的应用代码——它替换的是HttpClient背后的 HTTP 后端处理器。用大白话说,HttpClientTestingModule把真实网络层的"水管"给拆了,接了一根可控的"实验室水管",你的HttpClient还是那个HttpClient,http.get()还是那个http.get(),请求照样发出,只不过发到实验室管道里被测试代码截获了。
这是它的核心优势——你测的是真实的服务类、真实的HttpClient调用链、真实的请求参数构建,只是把请求的终点从远端服务器换成了本地测试控制器。
具体由两个关键角色构成:
一个是HttpClientTestingModule本身,它是一个特殊的 NgModule,作为测试环境里HttpClient的依赖替代品。你把它 import 进TestBed的配置里,Angular 的依赖注入系统就会自动使用测试后端处理器。
另一个是HttpTestingController,它才是你真正操作的对象。测试用例里通过inject或者依赖注入拿到它,然后调用它的expectOne()来断言"有一个请求发生",再调用flush()来模拟后端返回响应。
模拟一个完整的请求生命周期只需要四步:
- 准备被测服务,注入依赖。
- 调用被测服务的方法,触发
HttpClient发起请求。 - 从
HttpTestingController里取出这个请求,验证它的方法、URL、请求头、请求体是否符合预期。 - 对该请求调用
flush()返回模拟数据,或者调用error()模拟异常。
这套机制下,时间控制完全掌握在测试手里。后端响应快还是慢、返回成功还是失败、返回顺序怎么样,全由你说了算。这才是单元测试应该有的样子——确定性、隔离性、可重复性。
3. 搭建服务测试环境的完整过程
先说结论:Angular 官方推荐用TestBed.configureTestingModule配置测试模块,绝大多数 HTTP 服务测试的基础结构都长一个样。
来,我们先把测试环境搭起来。假设你有一个非常典型的用户服务UserService:
@Injectable({ providedIn: 'root' }) export class UserService { constructor(private http: HttpClient) {} getUser(id: number): Observable<User> { return this.http.get<User>(`/api/users/${id}`); } updateUser(id: number, user: Partial<User>): Observable<User> { return this.http.put<User>(`/api/users/${id}`, user); } }对应的测试文件长这样:
import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { UserService } from './user.service'; describe('UserService', () => { let service: UserService; let httpTestingController: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [UserService] }); service = TestBed.inject(UserService); httpTestingController = TestBed.inject(HttpTestingController); }); afterEach(() => { httpTestingController.verify(); }); });我稍微解释一下这里面的几个关键点,因为它们确实容易踩坑。
第一,HttpClientTestingModule放在imports数组里,它自己会提供测试环境所需的所有依赖。如果你的被测服务还依赖其他服务,而这些服务又依赖HttpClient,不需要额外处理——因为HttpClient已经在测试环境里被替换成测试后端的实现了,整个依赖链都能正常工作。
第二,providers: [UserService]这里,如果你的服务用的是providedIn: 'root',甚至不需要写这一行,直接TestBed.inject(UserService)就能拿到。但是写上更明确,也让阅读测试的人一眼看出被测对象是谁。有些团队喜欢把服务放到providers里,即使它已经是 root 级别——这纯粹是代码风格问题,不涉及功能差异,但建议统一。
第三,afterEach里的httpTestingController.verify()极其重要。它的作用是验证测试结束后没有未处理的请求。如果服务发了请求但你没有在测试里expectOne并处理它,verify就会报错失常。这能帮你识别漏测场景——比如某个方法触发了额外的 API 调用而你完全没意识到。我在生产项目中不止一次靠这行代码发现过"原以为只发一个请求结果发了两个"的隐藏 bug。
有一点需要注意:TestBed.configureTestingModule每次调用beforeEach都会重新创建一个完整的测试模块,这意味着服务实例和控制器实例在每个测试用例里都是全新的、互相隔离的。这种隔离性正是单元测试所需要的。如果某个测试需要共享状态,也不要用模块级共享来偷懒,那是测试之间互相污染的源头。
4. 实战:从最简单的成功响应写起
环境配置好了,就该写第一个测试了。这是新手最想跳过但恰恰不能跳过的环节——因为接下来所有复杂的请求测试都是在这个基础上叠加的。
最简单的服务测试,测的是"调用服务方法后,发出了一个符合预期的 HTTP 请求,并且返回了正确解析的数据"。
it('should fetch user by id', () => { const mockUser: User = { id: 42, name: '张三', email: 'zhangsan@example.com' }; service.getUser(42).subscribe(user => { expect(user).toEqual(mockUser); }); const req = httpTestingController.expectOne('/api/users/42'); expect(req.request.method).toBe('GET'); req.flush(mockUser); });这个测试虽然短,但里面每一行都有讲究。我们逐步拆解。
先看service.getUser(42).subscribe(...)。这里必须调用subscribe,因为HttpClient返回的是一个冷 Observable——不订阅就不会发请求。如果只写service.getUser(42)不订阅,断言expectOne会因为根本没有请求产生而直接失败。这类失误几乎每个 Angular 开发者都踩过,我自己早期写测试也经常忘。
再看httpTestingController.expectOne('/api/users/42')。这行代码做了两件事:断言恰好有一个请求发出,并且这个请求的 URL 匹配。如果没有任何请求,会报Expected one matching request for criteria "...", found none;如果有多个匹配请求,会报Expected one matching request for criteria "...", found multiple。这就保证了测试的精确性——你以为只发了一个请求,实际可能发了好几个,这个断言会毫不留情地暴露出来。
expect(req.request.method).toBe('GET')是额外断言请求方法。URL 匹配并不代表方法匹配,同一个 URL 可能存在 GET 和 POST 两种请求。我在实际项目里见过expectOne(url)匹配成功但方法完全不对的情况,所以建议把方法断言也加上,作为规范。
最后req.flush(mockUser)是模拟后端返回响应。传入的数据对象会被HttpClient当作 HTTP 响应的响应体处理,自动走 JSON 解析流程。如果你的服务里写了pipe(map(...))对响应做处理,这个时候就会执行,订阅回调拿到的就是处理后的结果。
关于subscribe断言的位置,再补充一个重点:subscribe里的expect断言写在flush之前,这是刻意为之。HttpClient的响应是异步的,flush会触发这个异步流程,但订阅回调是在响应数据到达后才执行的。你可能会想,那岂不是断言在flush之前就开始执行了?这里的执行顺序是:expectOne同步返回请求 →req.flush(mockUser)触发响应流 → 订阅回调同步收到数据 → 执行断言。整个流程同步完成,没有时序问题。这正是HttpTestingController的巨大优势——测试代码可以同步控制异步行为,不需要fakeAsync或者waitForAsync。
但这里有个前提:req.flush()触发响应时,flush后面的代码是在订阅回调之后执行的。如果订阅回调抛异常,会导致测试失败,这是正常的——我们要的就是这个效果。
响应体传什么值也值得讲究。如果服务返回的是数组:
it('should fetch all users', () => { const mockUsers: User[] = [ { id: 1, name: '张三' }, { id: 2, name: '李四' } ]; service.getUsers().subscribe(users => { expect(users.length).toBe(2); expect(users).toEqual(mockUsers); }); const req = httpTestingController.expectOne('/api/users'); req.flush(mockUsers); });最容易被忽略的场景是——服务里如果有map操作把响应体转换成了别的结构,你的 mock 数据必须是对应"后端原始响应"的结构,而不是最终返回给组件的结构。比如说:
getUserName(id: number): Observable<string> { return this.http.get<User>(`/api/users/${id}`).pipe( map(user => user.name) ); }这个服务的 mock 响应应该是一个完整的User对象,断言才拿name字段作为结果。如果你的 mock 数据直接给了'张三'字符串,user.name就是undefined,测试会挂。
5. 错误场景与异常分支的覆盖方法
很多团队的服务测试只测成功路径,错误分支完全空白。这其实等同于只测试了程序的一半路径——而且是最乐观的一半。生产环境里网络抖动、后端 5xx、超时、参数错误,这些才是真实常态。如果服务层对错误有封装逻辑,不测试根本不知道封装是否正确。
先看标准错误处理:
@Injectable({ providedIn: 'root' }) export class UserService { constructor(private http: HttpClient) {} getUser(id: number): Observable<User> { return this.http.get<User>(`/api/users/${id}`).pipe( catchError(error => { console.error('Failed to fetch user', error); return throwError(() => new Error('用户加载失败,请稍后重试')); }) ); } }测试错误分支的标准写法:
it('should handle error when fetching user fails', () => { const errorMessage = '用户加载失败,请稍后重试'; service.getUser(42).subscribe({ next: () => fail('should have failed'), error: error => { expect(error.message).toBe(errorMessage); } }); const req = httpTestingController.expectOne('/api/users/42'); req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' }); });注意这里的关键点:
第一,订阅回调要用完整观察者对象,next回调里调用fail('should have failed')。fail是 Jasmine 的全局方法,调用即抛错,测试直接失败。这样做是防呆——如果服务错误处理有 bug,错误没有抛出来而是走进了next,你就知道服务在异常情况下还错误地把响应当作成功数据处理了,这比断言error回调更重要。
第二,req.flush('Server Error', {...})里的第二个参数是状态信息。HttpClientTestingModule允许你构造任意 HTTP 状态码的错误响应。状态 400 表示客户端错误,500 表示服务端错误,503 表示服务不可用,这些都可以分别测试,验证服务对不同状态码的响应是否符合预期。
第三,catchError之后的throwError在网络异常和 HTTP 错误上都能被触发。HTTP 错误通过flush带状态码模拟,网络错误(请求没发出去就断了)则可以用另一种方式处理——实际上HttpTestingController里模拟网络异常的标准姿势是调用req.error而不是req.flush:
it('should handle network error', () => { service.getUser(42).subscribe({ next: () => fail('should have failed'), error: (error: HttpErrorResponse) => { expect(error.status).toBe(0); expect(error.error).toBeInstanceOf(ProgressEvent); } }); const req = httpTestingController.expectOne('/api/users/42'); req.error(new ProgressEvent('network error'), { status: 0, statusText: 'Unknown Error' }); });注意req.error的第一个参数是ProgressEvent。这是浏览器原生类型,用来表示网络层面发生的错误。HttpErrorResponse里status为 0 表示请求没能到达服务器——这是区分 HTTP 错误和网络错误的关键特征。如果你的服务里针对这两种错误场景有分支处理逻辑(比如网络错误提示"请检查网络",HTTP 错误提示"服务器开小差了"),两个分支都要测到。
还有一个细节:req.error的第二参数和req.flush的第二参数格式一致,都是可选的状态信息对象。如果不传,status默认为 0,但这会导致服务端无法区分具体情况,建议始终显式传入。
再说一个容易踩的坑:catchError的返回值类型。如果你在catchError里of(null)或者of(defaultValue),那么在错误场景下服务是正常返回走next回调的,你就不应该用fail('should have failed'),而应该断言返回值。两种错误处理策略对应完全不同的测试写法,写之前先确认服务实现采用的是哪种策略。
6. 请求匹配晋级:精确匹配、多请求序列与响应顺序控制
到了这一节,前面那些"单个请求 + 单个响应"的基础玩法就不够用了。真实世界里,一个复杂页面加载可能同时发出多个请求,或者一个流程里按顺序串行调用好几个接口。HttpTestingController对这些场景同样有完备的应对方案,但需要你在理解机制的基础上灵活运用。
6.1 精确匹配请求的方法
先说expectOne的参数。前面我传的是字符串 URL,其实它支持三种形式:
第一种是字符串精确匹配 URL。注意是精确匹配,不是包含关系。'/api/users'匹配不了'/api/users/42'。写的时候必须和请求的实际路径完全一致。如果你的基地址是通过API_BASE_URL依赖注入令牌拼的,测试里拼 URL 也必须用同一个值。
第二种是正则表达式匹配。这个在处理动态 URL 时特别好用:
const req = httpTestingController.expectOne(/\/api\/users\/\d+$/);这要求请求 URL 必须匹配正则模式。好处是你不用关心具体数字,只关心格式,测试更具弹性。但坏处是正则写不好容易误匹配,要额外注意锚点符号,避免匹配到带?查询参数或者路径后缀的请求。
第三种是自定义匹配函数:
const req = httpTestingController.expectOne( (request) => request.method === 'GET' && request.url.startsWith('/api/users') && request.params.has('active') );这种写法最灵活,可以在一个地方同时验证方法、URL、查询参数、请求头。当你的项目里请求封装比较复杂,URL 拼了很多查询参数时,建议直接用匹配函数,一次把多个维度全部断言清楚。
还有一个小技巧:match()方法是返回数组的匹配方式,不要求恰好匹配一个。比如测试并发请求场景时,你可以先match找出所有匹配请求,再用expect断言请求数量:
it('should fire multiple concurrent requests', () => { service.getUsers().subscribe(); service.getAdmins().subscribe(); const reqs = httpTestingController.match(/\/api\/users/); expect(reqs.length).toBe(2); });6.2 串行请求的响应顺序控制
对于串行按顺序发请求的服务,你要注意一个微妙的问题:flush的执行顺序会影响数据流。
loadUserAndPosts(userId: number): Observable<{ user: User; posts: Post[] }> { return this.http.get<User>(`/api/users/${userId}`).pipe( switchMap(user => { return this.http.get<Post[]>(`/api/users/${userId}/posts`).pipe( map(posts => ({ user, posts })) ); }) ); }对应测试:
it('should load user and then posts', () => { const mockUser: User = { id: 1, name: '张三' }; const mockPosts: Post[] = [{ id: 10, title: 'Angular 测试指南' }]; let result: { user: User; posts: Post[] } | undefined; service.loadUserAndPosts(1).subscribe(data => { result = data; }); const firstReq = httpTestingController.expectOne('/api/users/1'); firstReq.flush(mockUser); const secondReq = httpTestingController.expectOne('/api/users/1/posts'); secondReq.flush(mockPosts); expect(result).toEqual({ user: mockUser, posts: mockPosts }); });这里的关键是必须先 flush 第一个请求、触发switchMap内部产生第二个请求,然后才能expectOne拿到第二个请求。如果你在两个 flush 之前连续expectOne两次,第一次expectOne('/api/users/1')匹配成功返回请求对象,第二次expectOne('/api/users/1/posts')会因为第二个请求还没产生而直接报错。因为在串行链中,第二个请求只有在第一个响应的数据流被接收之后才会生成。
如果你不确定到底发了几个请求,用httpTestingController.match(/\/api\//)能返回一个数组,这比expectOne更灵活,可以作为排障辅助。但正式断言尽量用expectOne,因为它自带"数量必须唯一"的校验。
6.3 并发请求的场景
并发请求下,响应顺序控制要更小心:
it('should handle concurrent requests', () => { const result: number[] = []; service.getScore(1).subscribe(v => result.push(v)); service.getScore(2).subscribe(v => result.push(v)); const firstReq = httpTestingController.expectOne('/api/scores/1'); const secondReq = httpTestingController.expectOne('/api/scores/2'); secondReq.flush(200); firstReq.flush(100); expect(result).toEqual([200, 100]); });上面这个测试确定了异步响应顺序:后 flush 的请求先被订阅到。如果服务逻辑里对返回顺序有要求(比如按顺序推送到 UI),这样的测试就能验证时序是否满足需求。不过注意这不是HttpClientTestingModule的默认保证——默认情况下多个请求的响应顺序取决于 flush 的调用顺序,这也正是它可控性强的表现。
7. HTTP 状态码、请求头与其他细节的低级坑
前面六节的内容已经覆盖了主流程,但实际写服务测试时满地都是细节坑。我把这几个高频问题单独拿出来讲——每个都是真实代码里撞过墙的,不是网上抄来的理论。
7.1 响应类型与泛型的关系
HttpClient的泛型参数只是 TypeScript 层面的类型标记,运行时并不校验。你flush的数据结构如果跟泛型不匹配,运行时不会报错,但测试断言阶段一定会暴露问题。
常见坑是:服务里写了this.http.get<User>(...),测试里 flush 的数据带了额外字段或者少了必填字段,导致后续map操作取字段失败,报undefined。定位这类问题最快的方法是把 mock 数据严格对齐真实的响应结构——最好直接从接口文档或者实际后端返回里拷贝样例,而不是手写,减少脑补不一致。
HttpClient的responseType也需要留意。如果你设置了responseType: 'text',flush的时候应该传字符串而不是对象;如果设置了responseType: 'blob',传的应该是Blob实例。类型错了不会立即报错,但断言阶段会莫名失败,排查时间往往比解决问题还多。
7.2 请求头与查询参数的断言
很多服务方法会在请求里带HttpHeaders或者HttpParams。expectOne的 URL 匹配无法校验这些细节,所以你需要在拿到req后显式断言:
it('should send auth token in header', () => { service.getSecret().subscribe(); const req = httpTestingController.expectOne('/api/secrets'); expect(req.request.headers.has('Authorization')).toBeTrue(); expect(req.request.headers.get('Authorization')).toBe('Bearer test-token'); });HttpParams的断言也一样,注意HttpParams是不可变对象,不要试图直接修改它。查参数用法:
expect(req.request.params.get('page')).toBe('1'); expect(req.request.params.get('size')).toBe('20');7.3 如何构造“无响应体”的请求
DELETE 请求和 POST 请求经常不需要响应体。服务端可能返回 204 No Content。测试时:
it('should delete user', () => { service.deleteUser(42).subscribe(result => { expect(result).toBeNull(); }); const req = httpTestingController.expectOne('/api/users/42'); expect(req.request.method).toBe('DELETE'); req.flush(null); });如果服务端返回空响应体,flush(null)是正确姿势。如果你 flush{}, 那么HttpClient会尝试把空对象解析成响应体,行为可能和预期不一致——具体表现是订阅回调拿到空对象而不是 null。
7.4 重试逻辑的测试
如果服务里通过retry操作符做了重试,测试逻辑会有变化:
getDataWithRetry(): Observable<Data> { return this.http.get<Data>('/api/data').pipe( retry(2) ); } it('should retry twice on failure', () => { let attempts = 0; service.getDataWithRetry().subscribe({ error: () => {} }); // 第一次尝试 let req = httpTestingController.expectOne('/api/data'); req.flush('fail', { status: 500, statusText: 'Server Error' }); attempts++; // 第二次尝试 req = httpTestingController.expectOne('/api/data'); req.flush('fail', { status: 500, statusText: 'Server Error' }); attempts++; // 第三次尝试 req = httpTestingController.expectOne('/api/data'); req.flush('fail', { status: 500, statusText: 'Server Error' }); attempts++; expect(attempts).toBe(3); });重试逻辑的测试关键是"每次失败后都会产生新请求,每次都要用expectOne捕捉并 flush"。网络抖动、临时故障、后端重启都是生产环境里真实存在的场景,重试逻辑有测试兜底,改代码时底气才足。
8. 时间相关的异步测试:fakeAsync 与 flush 的配合
HttpClientTestingModule默认是同步 flush 的,怎么扯上时间了?你可能会问。
答案是:服务里往往不只有HttpClient,还有delay、debounceTime、setInterval、setTimeout这类时间依赖的操作。这些操作在标准测试环境里会引入真实的时间等待,导致测试变慢甚至挂起。此时需要用fakeAsync来控制时间。
import { fakeAsync, tick } from '@angular/core/testing'; it('should simulate delayed response', fakeAsync(() => { let received: any; service.getData() .pipe(delay(1000)) .subscribe(data => { received = data; }); const req = httpTestingController.expectOne('/api/data'); req.flush({ value: 42 }); // 此时数据还没到达订阅 expect(received).toBeUndefined(); // 快进 1000ms tick(1000); expect(received).toEqual({ value: 42 }); }));这个测试验证了:flush只是模拟响应返回,但服务里如果对数据流做了delay操作,订阅回调依然要等时间过去才执行。tick(1000)把虚拟时钟快进 1 秒,响应数据才真正送达。
实际项目里更常见的场景是轮询请求:
it('should poll every 5 seconds', fakeAsync(() => { service.startPolling().subscribe(); let req = httpTestingController.expectOne('/api/status'); expect(req.request.method).toBe('GET'); req.flush({ status: 'up' }); // 快进 5 秒,触发第二次请求 tick(5000); req = httpTestingController.expectOne('/api/status'); req.flush({ status: 'up' }); service.stopPolling(); }));注意fakeAsync里不要直接调用真实异步操作,比如用Promise.resolve().then()链式调用的逻辑,fakeAsync管不了它们,会导致测试失败。如果你的服务内部用了firstValueFrom或者toPromise转成了 Promise,那还是老老实实用waitForAsync或者async包装,而不是fakeAsync。什么时候用哪种,取决于被测代码内部是不是 Promise 链。
fakeAsync还会敏锐地捕捉未完成的定时器和异步任务。如果测试结束时有挂起的setInterval或者未执行的setTimeout,它会直接报错。这能强制你清理测试环境,对代码质量反而是好事——至少帮助我抓出过忘了清除轮询定时器的 bug。
9. 高频报错对照表:从报错信息到根因的速查路径
下面这些报错,每一个都是真实项目里出现过的。我把典型报错、根因分析和解决方案整理成一个速查表,建议直接收藏。
| 报错信息 | 根因 | 解决方案 |
|---|---|---|
Expected one matching request for criteria "...", found none | 请求没有发出,或 URL 不匹配,或忘记 subscribe | 先确认服务方法有没有被调用、是否订阅;再确认 URL 完全一致(含查询参数) |
Expected one matching request for criteria "...", found multiple | 同一个请求触发了多次 | 改用match()找出全部请求,确认数量是否符合预期 |
Error: No test request was expected(出自verify()) | 测试结束了还有请求未被expectOne和flush处理 | 在afterEach调用httpTestingController.verify()会自动兜底暴露 |
Cannot read properties of undefined (reading 'map') | mock 响应结构不匹配,后面的数据转换出错 | 检查 mock 数据是否与真实响应结构一致 |
Error: 1 timer(s) still in the queue | fakeAsync测试内未清理定时器 | 在测试末尾清除定时器,或者使用discardPeriodicTasks()、tick消化掉 |
对比下来,最常见的根因集中在两点:要么忘了订阅,要么写的 URL 和实际请求不一致。URL 不一致往往是因为没有打印实际请求的 URL——如果不好判断,可以在测试里加一句console.log(req.request.url)临时排查,或者干脆用匹配函数写正则,降低对精确字符串的记忆负担。
verify()报“多出来的请求”这个问题我要单独说一下。它的触发时机是我前面提到的afterEach。如果你的某个测试里调用了多个 API,但只expectOne了其中一个,另外一个没有被取走,verify()就会提示你。这不是语法错误,而是测试逻辑漏了场景。此时如果你的接口本来就是这个服务方法的一部分,说明你漏测了某个请求分支——检查一下服务方法是不是有switchMap、mergeMap、forkJoin这类会并发产生多个请求的操作符。
10. 把服务测试集成进团队的开发流程里
写到这里,HttpClientTestingModule的用法和技巧基本覆盖完了。但我还想聊聊更实际的问题——怎么让服务测试真正落地到团队日常开发里,而不是变成 README 里的一纸空文。
我推荐几个推行策略,全是亲身验证过有效的方式:
第一,把服务测试写进“完成定义”。我们团队里合入 PR 前,除了代码 review 和构建通过,还必须保证服务测试覆盖新增和修改的逻辑。不是要求覆盖率必须达到百分之百,而是要求"新增逻辑必须有对应测试"。这个门槛在实践中最有效,因为它直接绑定在本次改动上,而不是遥远的总覆盖率指标。
第二,抽取公共的测试工具方法。很多服务的请求模式非常相似——按 ID 查详情、列表、分页查询,它们的测试样板代码高度重复。可以封装几个 helper 函数,比如mockGetRequest(controller, url, response)。这些 helper 沉淀起来以后,团队写测试的效率会显著提升,也更容易保持测试风格统一。
第三,CI 里跑带覆盖率的服务测试。ng test --watch=false --browsers=ChromeHeadless --code-coverage是不会打开的,只是把覆盖率报告存档。真正要盯的是全量测试能否稳定通过,以及覆盖率趋势变化。服务测试最怕的不是写不出来,而是写得越来越臃肿但没人维护,最终变成 CI 里的红色信号。
第四,警惕测试只测"快乐路径"。我在评审代码时,一定会问一个问题:这个服务的异常分支测试了吗?网络错误测试了吗?超时测试了吗?如果服务代码里写了catchError、retry、switchMap,但测试文件里全是flush成功响应,基本可以断定这些逻辑没有测试保障——这比不写测试更危险,因为看起来好像测了。
最后再分享几个小技巧
收尾之前,把我这些年写 Angular 服务测试攒下的几个小经验一股脑分享出来。
第一个技巧:测试里不要直接断言req.request.url等于完整字符串,除非你确定后端路径不会变。用正则表达式或者startsWith断言前缀,能让接口版本更新或路径微调时少改很多测试。当然,反过来如果你要严格守住接口契约,那么精确匹配也没错——就看团队对契约稳定性的要求了。
第二个技巧:mock 数据尽量从真实接口响应里抓,不要自己凭感觉造。很多时候你以为的字段结构跟后端实际返回的字段结构不一致,测试通过了拿到生产环境反而崩。从真实响应里抓一份数据回来贴着用,测试才能真正扮演"契约校验者"的角色。
第三个技巧:如果你在写 Angular 服务测试的时候,发现某个服务特别难测——构造参数多、依赖一堆其他服务、内部逻辑牵扯全局状态——那通常不是测试方法的问题,而是服务设计本身有问题。测试的难度是设计质量的温度计。难测的服务往往意味着职责过重、耦合过深。这时候比起硬写一个复杂测试,更值得做的是回头审视服务设计,拆一拆、理一理。
第四个技巧:充分利用ng test --watch的迭代模式。写测试不是一次把整个文件写完再跑,而是写完一个用例马上跑一遍。红着绿着都无所谓,重要的是让反馈循环变短。在一个大型 Angular 项目里,HttpClientTestingModule测试的运行速度几乎瞬间完成,不像端到端测试要起浏览器、等网络,这让服务测试成为日常开发里反馈最快的自动化测试手段。
服务测试做到位,Angular 项目的生产就绪程度才真正算得上完整。数据结构、错误分支、时序逻辑、重试策略、轮询机制——这些构建真实系统必不可少的部分,在这套测试机制的兜底下,可以放心大胆地演进重构。这篇指南作为系列第五篇,服务层的测试基础算是铺平了。下一篇我打算聊聊依赖注入令牌与多环境配置的测试策略,那也是服务层进阶绕不开的关键一环。