在 Angular 开发中,服务层测试一直是个让人头疼的问题。记得我刚接触 Angular 时,每次跑测试都要等半天,后来才发现是因为测试里调用了真实接口。这种测试方式存在三个致命问题:
首先,测试速度慢得令人发指。一个简单的 GET 请求测试可能要等上几秒钟,当你有几十个测试用例时,等测试跑完都能喝完一杯咖啡了。其次,测试结果极不稳定——今天能过的测试,明天可能就因为后端服务宕机而失败。最糟糕的是,你可能会在测试数据库中留下一堆垃圾数据,比如重复创建的测试用户。
提示:单元测试的核心原则是隔离性,我们只测试当前服务的业务逻辑,不应该依赖任何外部系统。
HttpClientTestingModule 就是 Angular 团队为我们准备的解决方案。它提供了两个核心工具:
我们先来看一个典型的用户服务实现。这个服务封装了三个常用操作:
typescript复制@Injectable({ providedIn: 'root' })
export class UserService {
private readonly apiUrl = 'https://api.example.com/users';
constructor(private http: HttpClient) {}
// 获取用户列表
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}
// 获取单个用户
getUserById(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
// 创建用户
createUser(user: Partial<User>): Observable<User> {
return this.http.post<User>(this.apiUrl, user);
}
}
测试环境的搭建有几个关键点需要注意:
typescript复制beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpTestingController = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpTestingController.verify();
});
这里特别要强调的是 afterEach 中的 verify() 调用。这个检查能确保没有"漏网之鱼"的请求未被处理。我曾在项目中遇到过因为忘记写这个验证,导致一些测试莫名其妙失败的情况。
让我们深入看看如何测试一个获取用户列表的 GET 请求:
typescript复制it('应该正确获取用户列表', () => {
// 准备测试数据
const mockUsers: User[] = [
{ id: 1, name: '测试用户1', email: 'test1@example.com' },
{ id: 2, name: '测试用户2', email: 'test2@example.com' }
];
// 调用服务方法
service.getUsers().subscribe(users => {
expect(users).toEqual(mockUsers);
expect(users.length).toBe(2);
});
// 拦截请求
const req = httpTestingController.expectOne(service.apiUrl);
// 验证请求方法
expect(req.request.method).toBe('GET');
// 模拟响应
req.flush(mockUsers);
});
这个测试案例展示了完整的测试流程:
对于带参数的请求,我们有两种验证方式:
typescript复制// 方式1:精确匹配URL
const req = httpTestingController.expectOne(`${service.apiUrl}/1`);
// 方式2:使用正则表达式匹配动态URL
const req = httpTestingController.expectOne(/https:\/\/api.example.com\/users\/\d+/);
我个人的经验是,对于简单的固定URL使用第一种方式更直观,而对于复杂的动态路由,正则表达式会更灵活。
POST 请求测试有几个额外需要注意的地方:
typescript复制it('应该正确创建用户', () => {
const newUser = { name: '新用户', email: 'new@example.com' };
const createdUser = { id: 3, ...newUser };
service.createUser(newUser).subscribe(user => {
expect(user).toEqual(createdUser);
});
const req = httpTestingController.expectOne(service.apiUrl);
// 验证请求方法
expect(req.request.method).toBe('POST');
// 验证请求体
expect(req.request.body).toEqual(newUser);
// 模拟响应
req.flush(createdUser);
});
特别要注意的是对请求体(body)的验证。我曾经遇到过因为请求体字段拼写错误导致的bug,通过这里的验证可以及早发现这类问题。
一个健壮的测试套件必须包含异常场景的测试:
typescript复制it('应该处理服务器错误', () => {
service.getUsers().subscribe({
next: () => fail('请求应该失败'),
error: (error) => {
expect(error.status).toBe(500);
}
});
const req = httpTestingController.expectOne(service.apiUrl);
// 模拟服务器错误
req.flush('服务器错误', {
status: 500,
statusText: 'Internal Server Error'
});
});
除了HTTP错误,我们还需要模拟网络层面的错误:
typescript复制it('应该处理网络错误', () => {
service.getUsers().subscribe({
next: () => fail('请求应该失败'),
error: (error) => {
expect(error instanceof ErrorEvent).toBeTruthy();
}
});
const req = httpTestingController.expectOne(service.apiUrl);
// 模拟网络错误
req.error(new ErrorEvent('Network error'));
});
有时候我们需要测试请求超时的情况:
typescript复制it('应该处理请求超时', (done) => {
// 配置模拟延迟
const delayedResponse = () => of(null).pipe(delay(1000));
// 这里需要在实际服务中使用timeout操作符
service.getUsersWithTimeout().subscribe({
next: () => fail('请求应该超时'),
error: (error) => {
expect(error.name).toBe('TimeoutError');
done();
}
});
const req = httpTestingController.expectOne(service.apiUrl);
// 使用定时器模拟延迟响应
setTimeout(() => {
req.flush(mockUsers);
}, 1500);
});
对于实现了重试机制的服务,我们可以这样测试:
typescript复制it('应该重试失败的请求', () => {
let attempt = 0;
service.getUsersWithRetry().subscribe();
// 第一次请求模拟失败
const req1 = httpTestingController.expectOne(service.apiUrl);
req1.flush(null, { status: 503, statusText: 'Service Unavailable' });
// 第二次请求模拟成功
const req2 = httpTestingController.expectOne(service.apiUrl);
req2.flush(mockUsers);
});
如果你的应用使用了HTTP拦截器,测试时需要特别注意:
typescript复制it('应该应用授权拦截器', () => {
service.getUsers().subscribe();
const req = httpTestingController.expectOne(service.apiUrl);
// 验证拦截器是否添加了Authorization头
expect(req.request.headers.has('Authorization')).toBeTruthy();
req.flush(mockUsers);
});
在实际项目中,我遇到过几个典型问题:
测试报错:有未处理的请求
这是因为忘记调用httpTestingController.verify(),或者在测试中漏掉了某个请求的expectOne匹配。
测试通过但实际代码有问题
确保你的测试中包含了足够的断言,不仅要验证响应数据,还要验证请求方法、URL和请求体。
异步测试问题
如果测试涉及异步操作,记得使用fakeAsync/tick或者async/await来处理。
内存泄漏
长时间运行的测试套件可能会因为未清理的订阅导致内存泄漏。确保在afterEach中取消所有订阅。
要获得高质量的测试覆盖,建议:
我在实际项目中发现,使用HttpClientTestingModule后,服务层的测试覆盖率可以轻松达到90%以上,而且测试运行速度极快,通常在几秒钟内就能完成数百个测试用例。