测试
测试是保证代码质量的重要手段。Angular 提供了完善的测试工具,包括用于单元测试的 TestBed 和用于端到端测试的 Playwright。本章将介绍 Angular 应用的测试方法。
单元测试基础
测试工具
Angular 使用以下工具进行测试:
- Jasmine:测试框架,提供 describe、it、expect 等函数
- Karma:测试运行器(Angular 17 前默认)
- Jest:现代测试框架(Angular 17+ 可选)
- TestBed:Angular 测试工具,创建测试模块
测试文件结构
Angular CLI 为每个组件、服务、管道生成对应的测试文件:
src/app/
├── user.component.ts
├── user.component.spec.ts # 测试文件
├── user.service.ts
└── user.service.spec.ts
基本测试结构
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should return users', () => {
const users = service.getUsers();
expect(users.length).toBeGreaterThan(0);
});
});
组件测试
测试简单组件
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent]
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display initial count', () => {
const el = fixture.nativeElement;
expect(el.querySelector('p').textContent).toContain('0');
});
it('should increment count', () => {
component.increment();
fixture.detectChanges();
const el = fixture.nativeElement;
expect(el.querySelector('p').textContent).toContain('1');
});
it('should increment on button click', () => {
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
expect(component.count()).toBe(1);
});
});
测试带服务的组件
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';
import { of } from 'rxjs';
describe('UserListComponent', () => {
let component: UserListComponent;
let fixture: ComponentFixture<UserListComponent>;
let mockUserService: jasmine.SpyObj<UserService>;
const mockUsers = [
{ id: 1, name: '张三', email: '[email protected]' },
{ id: 2, name: '李四', email: '[email protected]' }
];
beforeEach(async () => {
// 创建模拟服务
mockUserService = jasmine.createSpyObj('UserService', ['getUsers']);
mockUserService.getUsers.and.returnValue(of(mockUsers));
await TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [
{ provide: UserService, useValue: mockUserService }
]
}).compileComponents();
fixture = TestBed.createComponent(UserListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should display users', () => {
const el = fixture.nativeElement;
const items = el.querySelectorAll('li');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('张三');
});
it('should call getUsers on init', () => {
expect(mockUserService.getUsers).toHaveBeenCalled();
});
});
测试带输入输出的组件
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserCardComponent } from './user-card.component';
describe('UserCardComponent', () => {
let component: UserCardComponent;
let fixture: ComponentFixture<UserCardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCardComponent]
}).compileComponents();
fixture = TestBed.createComponent(UserCardComponent);
component = fixture.componentInstance;
});
it('should display user name', () => {
component.name = '张三';
component.age = 25;
fixture.detectChanges();
const el = fixture.nativeElement;
expect(el.textContent).toContain('张三');
expect(el.textContent).toContain('25');
});
it('should emit select event on click', () => {
spyOn(component.select, 'emit');
component.userId = 123;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
button.click();
expect(component.select.emit).toHaveBeenCalledWith(123);
});
});
服务测试
测试 HTTP 服务
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;
const mockUsers = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should fetch users', () => {
service.getUsers().subscribe(users => {
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should create user', () => {
const newUser = { name: '王五', email: '[email protected]' };
service.createUser(newUser).subscribe(response => {
expect(response).toEqual({ id: 3, ...newUser });
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush({ id: 3, ...newUser });
});
it('should handle error', () => {
service.getUser(999).subscribe({
next: () => fail('should have failed'),
error: (error) => {
expect(error.status).toBe(404);
}
});
const req = httpMock.expectOne('/api/users/999');
req.flush('Not found', { status: 404, statusText: 'Not Found' });
});
});
管道测试
import { TruncatePipe } from './truncate.pipe';
describe('TruncatePipe', () => {
let pipe: TruncatePipe;
beforeEach(() => {
pipe = new TruncatePipe();
});
it('should truncate long text', () => {
const result = pipe.transform('这是一段很长的文字用于测试', 10);
expect(result).toBe('这是一段很长的文字用于...');
});
it('should not truncate short text', () => {
const result = pipe.transform('短文字', 10);
expect(result).toBe('短文字');
});
it('should use custom suffix', () => {
const result = pipe.transform('这是一段很长的文字', 5, '……');
expect(result).toBe('这是一段……');
});
it('should handle empty string', () => {
expect(pipe.transform('')).toBe('');
expect(pipe.transform(null as any)).toBe('');
});
});
指令测试
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HighlightDirective } from './highlight.directive';
import { By } from '@angular/platform-browser';
@Component({
template: `<div appHighlight="yellow">测试内容</div>`
})
class TestComponent {}
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let des: DebugElement[];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HighlightDirective, TestComponent]
});
fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
des = fixture.debugElement.queryAll(By.directive(HighlightDirective));
});
it('should have highlight directive', () => {
expect(des.length).toBe(1);
});
it('should set background color on mouseenter', () => {
const div = des[0].nativeElement;
div.dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(div.style.backgroundColor).toBe('yellow');
});
it('should remove background color on mouseleave', () => {
const div = des[0].nativeElement;
div.dispatchEvent(new Event('mouseenter'));
div.dispatchEvent(new Event('mouseleave'));
fixture.detectChanges();
expect(div.style.backgroundColor).toBe('transparent');
});
});
运行测试
运行所有测试
ng test
运行特定测试文件
ng test --include="**/user.service.spec.ts"
生成代码覆盖率报告
ng test --code-coverage
覆盖率报告会生成在 coverage 目录下。
端到端测试
Angular 17+ 推荐使用 Playwright 进行端到端测试。
安装 Playwright
ng add @angular-devkit/build-angular
npm install -D @playwright/test
npx playwright install
配置 Playwright
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'ng serve',
url: 'http://localhost:4200',
reuseExistingServer: true,
},
});
编写 E2E 测试
import { test, expect } from '@playwright/test';
test.describe('User Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should display user list', async ({ page }) => {
await page.goto('/users');
await expect(page.locator('h1')).toHaveText('用户列表');
await expect(page.locator('ul li')).toHaveCount(3);
});
test('should add new user', async ({ page }) => {
await page.goto('/users');
await page.click('button:has-text("添加用户")');
await page.fill('input[name="name"]', '新用户');
await page.fill('input[name="email"]', '[email protected]');
await page.click('button:has-text("保存")');
await expect(page.locator('ul li:last-child')).toContainText('新用户');
});
test('should navigate to user detail', async ({ page }) => {
await page.goto('/users');
await page.click('ul li:first-child a');
await expect(page).toHaveURL(/\/users\/\d+$/);
await expect(page.locator('h1')).toContainText('用户详情');
});
});
运行 E2E 测试
npx playwright test
测试最佳实践
- 测试行为而非实现:关注组件的外部行为,而非内部实现细节
- 使用模拟对象:隔离被测单元,避免依赖外部系统
- 保持测试独立:每个测试应该独立运行,不依赖其他测试
- 测试边界情况:不仅测试正常情况,还要测试异常和边界情况
- 保持测试简洁:每个测试只验证一个行为
- 使用有意义的描述:测试描述应该清晰表达测试意图
小结
- 单元测试使用 TestBed 创建测试环境,测试组件、服务和管道
- 组件测试需要创建 ComponentFixture 并触发变更检测
- HTTP 测试使用 HttpClientTestingModule 模拟 HTTP 请求
- E2E 测试使用 Playwright 测试完整的用户流程
- 代码覆盖率帮助发现未测试的代码
Angular 教程到此结束!你已经学习了 Angular 的核心概念和常用功能。继续实践,构建更多项目来巩固所学知识。