1. Angular 测试架构
- TestBed Angular 的测试模块,模拟 Angular 依赖注入环境。用于创建组件、注入服务、配置测试模块。是所有 Angular 单元测试的基础。
-
ComponentFixture
TestBed.createComponent() 返回的对象,包含组件实例(
fixture.componentInstance)和 DOM 元素(fixture.nativeElement)。 -
detectChanges()
触发 Angular 变更检测周期,使组件模板根据最新数据更新。每次修改组件属性后,需要调用
fixture.detectChanges()才能看到 DOM 更新。 - Jasmine Angular 默认的 JavaScript 测试框架,提供 describe/it/expect/beforeEach 等 API。
-
Karma
Angular 默认的测试运行器,在真实浏览器中运行 Jasmine 测试。通过
ng test启动。
2. 服务单元测试(Mock HttpClient)
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService, User } from './user.service';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule], // 用 Mock HTTP 替代真实请求
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // 确保没有未处理的请求
});
it('should fetch users', () => {
const mockUsers: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'user' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'admin' },
];
let result: User[] = [];
service.getUsers().subscribe(users => result = users);
// 拦截 HTTP 请求并返回 mock 数据
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers); // 模拟服务器响应
expect(result.length).toBe(2);
expect(result[0].name).toBe('Alice');
});
it('should handle HTTP error', () => {
let error: any;
service.getUsers().subscribe({
error: (err) => error = err
});
const req = httpMock.expectOne('/api/users');
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
expect(error.status).toBe(500);
});
});
3. 组件单元测试
// login.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { LoginComponent } from './login.component';
import { AuthService } from '../auth.service';
import { of, throwError } from 'rxjs';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
let authServiceSpy: jasmine.SpyObj<AuthService>;
beforeEach(async () => {
// 创建服务的 spy(Mock 对象)
authServiceSpy = jasmine.createSpyObj('AuthService', ['login']);
await TestBed.configureTestingModule({
imports: [LoginComponent, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authServiceSpy }
]
}).compileComponents();
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // 触发 ngOnInit
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should show validation errors when form is invalid', () => {
// 让字段 dirty(模拟用户操作)
component.form.get('email')!.markAsTouched();
fixture.detectChanges();
const errorEl = fixture.debugElement.query(By.css('.error'));
expect(errorEl).toBeTruthy();
expect(errorEl.nativeElement.textContent).toContain('必填');
});
it('should call auth.login on valid submit', () => {
authServiceSpy.login.and.returnValue(of({ id: 1, name: 'Alice' } as any));
component.form.setValue({
email: 'alice@example.com',
password: 'Password123'
});
fixture.detectChanges();
const btn = fixture.debugElement.query(By.css('button[type="submit"]'));
btn.nativeElement.click();
expect(authServiceSpy.login).toHaveBeenCalledOnceWith({
email: 'alice@example.com',
password: 'Password123'
});
});
it('should disable submit button when form is invalid', () => {
const btn = fixture.debugElement.query(By.css('button[type="submit"]'));
expect(btn.nativeElement.disabled).toBe(true);
});
});
4. 测试带 Signal 的组件
// counter.component.spec.ts
describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
let component: CounterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent]
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should start at 0', () => {
expect(component.count()).toBe(0);
expect(fixture.nativeElement.querySelector('p').textContent).toContain('0');
});
it('should increment on button click', () => {
const btn = fixture.nativeElement.querySelector('button:first-of-type');
btn.click();
fixture.detectChanges(); // Signal 变化后刷新视图
expect(component.count()).toBe(1);
});
});
5. E2E 测试:Playwright
Angular 17+ 官方推荐使用 Playwright 进行端到端测试,替代旧版 Protractor。
# 安装 Playwright
npm init playwright@latest
# 或通过 Angular 原理图安装
ng add @playwright/test
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('正确凭据登录成功', async ({ page }) => {
await page.goto('http://localhost:4200/login');
// 填写表单
await page.fill('input[name="email"]', 'admin@example.com');
await page.fill('input[name="password"]', 'Admin123!');
await page.click('button[type="submit"]');
// 验证跳转到仪表盘
await page.waitForURL('**/dashboard');
await expect(page.locator('h1')).toContainText('仪表盘');
});
test('错误密码显示错误提示', async ({ page }) => {
await page.goto('http://localhost:4200/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error-toast')).toBeVisible();
await expect(page.locator('.error-toast')).toContainText('用户名或密码错误');
});
});
6. 测试覆盖率
# 运行测试并生成覆盖率报告
ng test --code-coverage
# 覆盖率报告位于:coverage/my-app/index.html
# 包含:语句、分支、函数、行覆盖率
在 angular.json 中配置覆盖率阈值,确保代码质量:
// angular.json
"test": {
"options": {
"codeCoverage": true,
"codeCoverageExclude": ["src/environments/**"]
}
}
// karma.conf.js — 设置覆盖率阈值
coverageReporter: {
thresholds: {
emitWarning: false,
global: {
statements: 80,
branches: 70,
functions: 80,
lines: 80
}
}
}
本章小结:Angular 内置完善的测试工具链。TestBed + HttpClientTestingModule 让服务测试简单可靠,ComponentFixture + detectChanges 完成组件测试,Playwright 提供现代 E2E 能力。最后一章讲性能优化与部署。