在 Angular 中,测试是一等公民。Angular 官方提供了完整的测试工具链,让你能够对组件、服务、管道、指令等进行单元测试和集成测试。
📦 核心测试工具
Angular 项目默认使用以下测试工具:
| 工具 | 作用 |
|---|---|
| Jasmine | 行为驱动开发(BDD)测试框架,提供 describe、it、expect 等语法 |
| Karma | 测试运行器,可以在真实浏览器中运行测试,并生成覆盖率报告 |
| Angular TestBed | Angular 提供的测试工具,用于创建测试模块、注入服务、模拟依赖 |
| Protractor | Angular 应用的端到端(E2E)测试框架(Angular 15+ 已弃用,推荐 Cypress 或 Playwright) |
🗂️ 测试文件位置和命名规范
Angular CLI 生成的文件会自动附带测试文件,命名规范为 .spec.ts:
text
src/
├── app/
│ ├── app.component.ts
│ ├── app.component.spec.ts ← 组件测试
│ ├── services/
│ │ ├── user.service.ts
│ │ └── user.service.spec.ts ← 服务测试
│ └── pipes/
│ ├── filter.pipe.ts
│ └── filter.pipe.spec.ts ← 管道测试
🧪 运行测试的命令
bash
# 运行所有单元测试(监听文件变化,自动重跑)
ng test
# 运行一次并生成覆盖率报告
ng test --code-coverage
# 运行指定文件的测试(需要配合 --include)
ng test --include='**/app.component.spec.ts'
# 端到端测试(Angular 15+ 需要额外配置)
ng e2e
测试运行后会打开浏览器窗口(默认 Chrome),并在终端显示结果。
📝 编写测试的基本语法
1. 基础 Jasmine 语法
typescript
describe('测试套件名称', () => {
// 每个测试用例执行前运行
beforeEach(() => {
// 初始化操作
});
// 单个测试用例
it('应该返回 true', () => {
const result = true;
expect(result).toBe(true); // 断言
});
});
2. 常用断言(Matchers)
typescript
expect(value).toBe(expected); // 严格相等 (===)
expect(value).toEqual(expected); // 深度相等(对象内容)
expect(value).toBeTruthy(); // 真值
expect(value).toBeFalsy(); // 假值
expect(value).toBeNull(); // null
expect(value).toBeUndefined(); // undefined
expect(value).toContain('子字符串'); // 数组或字符串包含
expect(array).toHaveSize(3); // 数组长度
expect(fn).toHaveBeenCalled(); // 函数被调用
expect(fn).toHaveBeenCalledWith(1, 2); // 函数被调用的参数
🧩 组件测试(Component Testing)
基本组件测试示例
typescript
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
let component: AppComponent;
let fixture: ComponentFixture<AppComponent>;
// 每个测试前准备
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
}).compileComponents();
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // 触发变更检测
});
it('应该创建组件', () => {
expect(component).toBeTruthy();
});
it('应该显示标题', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello');
});
});
模拟用户交互
typescript
import { By } from '@angular/platform-browser';
it('点击按钮应该更新内容', () => {
const button = fixture.debugElement.query(By.css('button')).nativeElement;
button.click();
fixture.detectChanges(); // 更新视图
const content = fixture.debugElement.query(By.css('.content')).nativeElement;
expect(content.textContent).toContain('已点击');
});
🔧 服务测试(Service Testing)
使用 TestBed 注入服务
typescript
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule], // 替换真实 HttpClient
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // 确保没有未处理的 HTTP 请求
});
it('应该获取用户列表', () => {
const mockUsers = [{ id: 1, name: '张三' }];
service.getUsers().subscribe(users => {
expect(users).toEqual(mockUsers);
});
// 模拟 HTTP 请求返回数据
const req = httpMock.expectOne('https://api.example.com/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers); // 返回模拟数据
});
});
🎭 模拟依赖(Mocking)
使用 spy 模拟方法
typescript
it('应该调用 UserService 的 getUsers 方法', () => {
const userService = TestBed.inject(UserService);
spyOn(userService, 'getUsers').and.returnValue(of([{ id: 1, name: 'Mock User' }]));
// 执行组件逻辑
component.loadUsers();
expect(userService.getUsers).toHaveBeenCalled();
expect(component.users).toEqual([{ id: 1, name: 'Mock User' }]);
});
使用 jasmine.createSpyObj 模拟完整服务
typescript
const mockService = jasmine.createSpyObj('UserService', ['getUsers', 'saveUser']);
mockService.getUsers.and.returnValue(of([]));
TestBed.configureTestingModule({
providers: [
{ provide: UserService, useValue: mockService }
]
});
🧪 测试覆盖率
查看覆盖率报告
bash
ng test --code-coverage
运行后会在 /coverage 目录生成 HTML 报告,打开 index.html 即可查看:
| 指标 | 说明 |
|---|---|
| Statements | 语句覆盖率 |
| Branches | 分支覆盖率(if/else、switch等) |
| Functions | 函数覆盖率 |
| Lines | 行覆盖率 |
企业项目通常要求核心代码覆盖率达到 80% 以上。
📊 测试最佳实践
-
每个组件/服务都应有对应的
.spec.ts文件 -
使用
beforeEach统一准备测试环境,避免重复代码 -
测试要独立 :每个
it块不应依赖其他测试的执行结果 -
模拟外部依赖:HTTP 请求、路由、第三方库都应被 Mock
-
测试行为而非实现:测试"做什么",而不是"怎么做"
-
保持测试快速:避免在单元测试中发起真实 HTTP 请求
-
定期运行测试:结合 CI/CD,每次提交代码自动运行测试
🚀 高级测试技术
测试异步代码
typescript
import { fakeAsync, tick } from '@angular/core/testing';
it('应该处理异步操作', fakeAsync(() => {
component.loadData();
tick(1000); // 模拟等待 1000ms
expect(component.data).toBeDefined();
}));
测试路由跳转
typescript
import { Router } from '@angular/router';
import { Location } from '@angular/common';
it('应该跳转到详情页', fakeAsync(() => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
router.navigate(['/detail', 1]);
tick();
expect(location.path()).toBe('/detail/1');
}));
测试输入/输出(@Input / @Output)
typescript
@Component({
template: `<app-child [name]="'Test'" (emitEvent)="onEvent($event)"></app-child>`
})
class TestHostComponent {
onEvent(data: any) {}
}
it('应该接收输入并触发输出', () => {
const hostFixture = TestBed.createComponent(TestHostComponent);
const childComponent = hostFixture.debugElement.query(By.directive(ChildComponent)).componentInstance;
expect(childComponent.name).toBe('Test');
spyOn(hostFixture.componentInstance, 'onEvent');
childComponent.emitEvent.emit({ data: 'Hello' });
expect(hostFixture.componentInstance.onEvent).toHaveBeenCalledWith({ data: 'Hello' });
});
📖 总结
| 测试类型 | 工具 | 重点 |
|---|---|---|
| 单元测试 | Jasmine + Karma | 测试独立的函数、服务、管道、组件 |
| 集成测试 | TestBed | 测试组件之间的交互、路由、依赖注入 |
| 端到端测试 | Cypress / Playwright | 模拟真实用户操作,测试完整流程 |
Angular 的测试生态非常完善,官方建议在开发过程中持续编写和维护测试,这能显著提高代码质量和项目可维护性。🚀