在 Angular 开发中,测试不是可选项而是必选项。一个完善的测试体系能让你在代码变更时保持信心,确保新功能不会破坏现有逻辑。Angular 官方推荐的测试方案基于 Karma 和 Jasmine 的组合,这套方案经过多年实战检验,已经成为 Angular 生态的标准配置。
为什么选择 Karma + Jasmine?这要从它们的分工说起。Karma 就像是一个高效的测试执行引擎,它负责启动浏览器、加载测试代码、执行测试并收集结果。而 Jasmine 则是测试的"语法糖",提供了一套清晰的行为驱动开发(BDD)风格的 API,让你能用接近自然语言的方式描述测试用例。
提示:虽然 Angular 也支持其他测试框架(如 Jest),但 Karma + Jasmine 是官方默认方案,文档和社区支持最完善,适合作为入门选择。
在开始之前,确保你的开发环境满足以下要求:
验证安装是否成功:
bash复制node -v
npm -v
ng version
Angular CLI 已经帮我们做好了所有测试相关的初始配置。新建项目时,只需一个简单的命令:
bash复制ng new angular-test-demo --strict
这里的 --strict 标志会启用更严格的类型检查和代码风格规则,这对保持代码质量很有帮助。创建完成后,项目结构中将包含以下关键测试文件:
karma.conf.js - Karma 主配置文件src/test.ts - 测试入口文件angular.json 中的 test 配置节src/app/app.component.spec.ts - 示例组件测试文件让我们深入看看这些自动生成的配置文件:
karma.conf.js 核心配置项:
javascript复制module.exports = function(config) {
config.set({
frameworks: ['jasmine'], // 使用 Jasmine 测试框架
browsers: ['Chrome'], // 使用 Chrome 浏览器运行测试
files: [
'src/test.ts' // 测试入口文件
],
// ...其他配置
});
};
angular.json 中的测试配置节:
json复制"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"styles": ["src/styles.css"],
"scripts": []
}
}
这些配置已经足够应对大多数测试场景,通常你不需要修改它们。
Jasmine 的测试语法非常直观,主要由以下几个关键部分组成:
typescript复制describe('测试套件名称', () => {
let variable: Type;
beforeEach(() => {
// 每个测试用例前的初始化代码
});
it('测试用例描述', () => {
// 测试逻辑
expect(actual).toBe(expected);
});
afterEach(() => {
// 每个测试用例后的清理代码
});
});
Jasmine 提供了丰富的断言方法,以下是最常用的几种:
| 方法 | 用途 | 示例 |
|---|---|---|
toBe() |
严格相等比较 | expect(1 + 1).toBe(2) |
toEqual() |
深度对象比较 | expect({a:1}).toEqual({a:1}) |
toBeTruthy() |
检查真值 | expect('hello').toBeTruthy() |
toContain() |
检查包含关系 | expect([1,2,3]).toContain(2) |
toThrow() |
检查是否抛出异常 | expect(fn).toThrow() |
理解 Jasmine 的生命周期钩子对编写有效的测试至关重要:
beforeAll() - 在整个测试套件开始前执行一次afterAll() - 在整个测试套件结束后执行一次beforeEach() - 在每个测试用例开始前执行afterEach() - 在每个测试用例结束后执行TestBed 是 Angular 测试的核心工具,它模拟了 Angular 的运行时环境。基本使用模式如下:
typescript复制beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent], // 对于独立组件
// declarations: [AppComponent], // 对于非独立组件
// providers: [MyService] // 如果需要服务
}).compileComponents();
});
一个完整的组件测试通常包含以下步骤:
示例代码:
typescript复制it('should display title', () => {
const fixture = TestBed.createComponent(AppComponent);
const component = fixture.componentInstance;
const element = fixture.nativeElement;
component.title = 'Test Title';
fixture.detectChanges();
expect(element.querySelector('h1').textContent).toContain('Test Title');
});
对于有 @Input() 和 @Output() 的组件,测试方法如下:
typescript复制// 测试 @Input()
it('should accept input', () => {
const fixture = TestBed.createComponent(MyComponent);
const component = fixture.componentInstance;
component.someInput = 'test value';
fixture.detectChanges();
expect(component.someProperty).toEqual('test value');
});
// 测试 @Output()
it('should emit output', () => {
const fixture = TestBed.createComponent(MyComponent);
const component = fixture.componentInstance;
let emittedValue: any;
component.someOutput.subscribe((value) => emittedValue = value);
component.doSomething();
expect(emittedValue).toBeDefined();
});
Angular 提供了几种处理异步测试的方式:
async/await 方式:
typescript复制it('should handle async operation', async () => {
const fixture = TestBed.createComponent(AsyncComponent);
const component = fixture.componentInstance;
await component.loadData();
fixture.detectChanges();
expect(component.data).toBeDefined();
});
fakeAsync/tick 方式:
typescript复制it('should handle timers', fakeAsync(() => {
const fixture = TestBed.createComponent(TimerComponent);
const component = fixture.componentInstance;
component.startTimer();
tick(1000); // 快进时间
expect(component.counter).toBe(1);
}));
使用 HttpClientTestingModule 来模拟 HTTP 请求:
typescript复制import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
describe('DataService', () => {
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [DataService]
});
httpMock = TestBed.inject(HttpTestingController);
});
it('should fetch data', () => {
const service = TestBed.inject(DataService);
const testData = {id: 1, name: 'Test'};
service.getData(1).subscribe(data => {
expect(data).toEqual(testData);
});
const req = httpMock.expectOne('api/data/1');
expect(req.request.method).toBe('GET');
req.flush(testData);
});
afterEach(() => {
httpMock.verify();
});
});
生成测试覆盖率报告:
bash复制ng test --code-coverage
优化覆盖率的一些技巧:
istanbul ignore 注释合理排除无需测试的代码忘记调用 detectChanges()
修改组件属性后必须调用 fixture.detectChanges() 才能更新视图
异步操作未正确处理
确保使用 async/await 或 fakeAsync 处理异步代码
测试顺序依赖
每个测试用例应该是独立的,避免依赖执行顺序
内存泄漏
在 afterEach 中清理订阅和资源
--watch=false 关闭文件监视--browsers=ChromeHeadless 运行无头浏览器测试fit() 或 fdescribe() 聚焦特定测试debugger 语句console.log(fixture.nativeElement.innerHTML) 查看渲染结果在 Angular 项目中应用测试金字塔:
测试代码应该和生产代码保持相同质量标准:
以创建一个计数器组件为例:
第一步:编写测试
typescript复制describe('CounterComponent', () => {
it('should start with 0', () => {
const fixture = TestBed.createComponent(CounterComponent);
expect(fixture.componentInstance.count).toBe(0);
});
it('should increment count', () => {
const fixture = TestBed.createComponent(CounterComponent);
const component = fixture.componentInstance;
component.increment();
expect(component.count).toBe(1);
});
});
第二步:实现组件
typescript复制@Component({
standalone: true,
template: `{{count}}`
})
export class CounterComponent {
count = 0;
increment() {
this.count++;
}
}
第三步:扩展测试和功能
typescript复制it('should reset count', () => {
const fixture = TestBed.createComponent(CounterComponent);
const component = fixture.componentInstance;
component.count = 5;
component.reset();
expect(component.count).toBe(0);
});
虽然通常不建议测试样式,但有时需要验证样式类是否存在:
typescript复制it('should apply active class', () => {
const fixture = TestBed.createComponent(MyComponent);
const element = fixture.nativeElement;
fixture.componentInstance.isActive = true;
fixture.detectChanges();
expect(element.querySelector('div').classList).toContain('active');
});
对于使用 <ng-content> 的组件:
typescript复制it('should project content', () => {
TestBed.overrideComponent(MyComponent, {
set: {
template: '<ng-content></ng-content>'
}
});
const fixture = TestBed.createComponent(MyComponent);
fixture.componentRef.setInput('someInput', 'value');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Projected Content');
});
使用 RouterTestingModule 测试路由组件:
typescript复制import { RouterTestingModule } from '@angular/router/testing';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: 'detail/:id', component: DetailComponent }
])
],
declarations: [DetailComponent]
});
});
it('should navigate to detail', fakeAsync(() => {
const router = TestBed.inject(Router);
const fixture = TestBed.createComponent(DetailComponent);
router.navigate(['/detail', 1]);
tick();
expect(fixture.componentInstance.id).toBe('1');
}));
yaml复制name: Angular CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: '16.x'
- run: npm ci
- run: npm test -- --watch=false --browsers=ChromeHeadless
[component-name].component.spec.ts[service-name].service.spec.ts[pipe-name].pipe.spec.ts[directive-name].directive.spec.tstypescript复制export function createComponent<T>(component: Type<T>): {
fixture: ComponentFixture<T>,
component: T,
element: HTMLElement
} {
const fixture = TestBed.createComponent(component);
return {
fixture,
component: fixture.componentInstance,
element: fixture.nativeElement
};
}
typescript复制export function createTestUser(overrides = {}): User {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
...overrides
};
}
测试一个简单的登录表单组件:
typescript复制describe('LoginFormComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule, LoginFormComponent]
}).compileComponents();
});
it('should validate email format', () => {
const { component } = createComponent(LoginFormComponent);
const emailControl = component.form.controls['email'];
emailControl.setValue('invalid-email');
expect(emailControl.valid).toBeFalse();
emailControl.setValue('valid@example.com');
expect(emailControl.valid).toBeTrue();
});
it('should emit submit event', () => {
const { component, fixture } = createComponent(LoginFormComponent);
spyOn(component.submitted, 'emit');
component.form.setValue({
email: 'test@example.com',
password: 'password'
});
fixture.nativeElement.querySelector('form').dispatchEvent(new Event('submit'));
expect(component.submitted.emit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password'
});
});
});
测试有依赖的服务:
typescript复制describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
UserService,
{ provide: API_URL, useValue: 'https://api.example.com' }
]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
it('should get user by id', () => {
const mockUser = { id: 1, name: 'Test User' };
service.getUser(1).subscribe(user => {
expect(user).toEqual(mockUser);
});
const req = httpMock.expectOne('https://api.example.com/users/1');
expect(req.request.method).toBe('GET');
req.flush(mockUser);
});
});
使用 Karma 的 --report-slower-than 参数识别慢测试:
bash复制ng test --report-slower-than=500
将大型测试套件拆分为多个文件:
code复制/src/app/
/components/
large-component/
large-component.component.ts
large-component.component.spec.ts
large-component.template.spec.ts
large-component.logic.spec.ts
使用工厂函数创建测试数据:
typescript复制function createTestProduct(overrides = {}): Product {
return {
id: 1,
name: 'Test Product',
price: 99.99,
...overrides
};
}
安装 eslint-plugin-jasmine 添加测试代码的 lint 规则:
bash复制npm install eslint-plugin-jasmine --save-dev
配置 .eslintrc.json:
json复制{
"plugins": ["jasmine"],
"env": {
"jasmine": true
},
"rules": {
"jasmine/no-focused-tests": "error",
"jasmine/no-disabled-tests": "warn"
}
}
确保测试代码也遵循统一的代码风格,在 .prettierrc 中添加:
json复制{
"overrides": [
{
"files": "*.spec.ts",
"options": {
"printWidth": 120
}
}
]
}
合理的覆盖率目标:
注意:不要盲目追求 100% 覆盖率,关键业务逻辑应该优先保证高覆盖率。
定期组织团队内的测试相关分享:
虽然 Karma 是官方默认方案,但 Jest 也获得了越来越多的关注:
安装配置:
bash复制ng add @briebug/jest-schematic
优势:
Angular 团队正在开发基于 @angular/core/testing 的新测试方案,特点包括:
Protractor 已被弃用,推荐转向:
这些新工具提供了更现代化的 API 和更好的开发者体验。
@testing-library/angular - 更接近用户视角的测试工具ng-mocks - 简化测试中的 mock 创建jasmine-marbles - 测试 RxJS 流的工具在实际项目中应用 Angular 测试体系多年,我总结了以下几点深刻体会:
测试驱动设计:编写测试前先思考组件/服务的 API 设计,往往能发现更好的抽象方式。测试不是事后的补充,而是设计过程的一部分。
测试可读性:测试代码的可读性甚至比生产代码更重要。一个好的测试应该像文档一样清晰地表达组件的预期行为。
测试稳定性:避免测试中的随机性和不确定性。每个测试应该在任何环境下都能产生相同的结果。flakey 测试(时好时坏的测试)会严重损害团队对测试的信心。
测试维护成本:要像对待生产代码一样重视测试代码的重构。当实现变化时,如果测试难以修改,这往往说明测试过度依赖实现细节而非行为。
测试与开发体验:好的测试应该提升而非降低开发效率。当添加新功能时,如果测试成为阻碍而非助力,就需要重新评估测试策略。
一个特别有用的实践是定期进行"测试代码审查",专门审查测试代码的质量。这不仅能提高测试质量,也是团队分享测试技巧的好机会。