跳到主要内容

测试

测试是保证代码质量的重要手段。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

测试最佳实践

  1. 测试行为而非实现:关注组件的外部行为,而非内部实现细节
  2. 使用模拟对象:隔离被测单元,避免依赖外部系统
  3. 保持测试独立:每个测试应该独立运行,不依赖其他测试
  4. 测试边界情况:不仅测试正常情况,还要测试异常和边界情况
  5. 保持测试简洁:每个测试只验证一个行为
  6. 使用有意义的描述:测试描述应该清晰表达测试意图

小结

  1. 单元测试使用 TestBed 创建测试环境,测试组件、服务和管道
  2. 组件测试需要创建 ComponentFixture 并触发变更检测
  3. HTTP 测试使用 HttpClientTestingModule 模拟 HTTP 请求
  4. E2E 测试使用 Playwright 测试完整的用户流程
  5. 代码覆盖率帮助发现未测试的代码

Angular 教程到此结束!你已经学习了 Angular 的核心概念和常用功能。继续实践,构建更多项目来巩固所学知识。