14 - 测试
📖 学习目标
通过本章节学习,您将掌握:
- Angular测试基础
- 单元测试
- 集成测试
- 端到端测试
- 测试最佳实践
- 测试工具和技巧
🎯 核心概念
1. 测试金字塔
/\
/ \ E2E Tests (少量)
/____\
/ \ Integration Tests (适量)
/________\
/ \ Unit Tests (大量)
/____________\
2. 测试类型
- 单元测试:测试单个组件、服务或函数
- 集成测试:测试多个组件或服务之间的交互
- 端到端测试:测试完整的用户流程
3. 测试工具
- Jasmine:测试框架
- Karma:测试运行器
- Protractor:E2E测试工具
- Cypress:现代E2E测试工具
🏗️ 测试环境配置
1. 基本配置
typescript
// karma.conf.js
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-headless'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// Jasmine配置
},
clearContext: false
},
jasmineHtmlReporter: {
suppressAll: true
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};
2. 测试脚本
json
// package.json
{
"scripts": {
"test": "ng test",
"test:watch": "ng test --watch",
"test:coverage": "ng test --code-coverage",
"e2e": "ng e2e",
"e2e:headless": "ng e2e --headless"
}
}
🔧 单元测试
1. 组件测试
typescript
// user-card.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserCardComponent } from './user-card.component';
import { User } from '../models/user';
describe('UserCardComponent', () => {
let component: UserCardComponent;
let fixture: ComponentFixture<UserCardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [UserCardComponent]
}).compileComponents();
fixture = TestBed.createComponent(UserCardComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display user information', () => {
const user: User = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'user'
};
component.user = user;
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('h3').textContent).toContain('John Doe');
expect(compiled.querySelector('.email').textContent).toContain('john@example.com');
});
it('should emit edit event when edit button is clicked', () => {
const user: User = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'user'
};
component.user = user;
fixture.detectChanges();
spyOn(component.edit, 'emit');
const editButton = fixture.nativeElement.querySelector('.btn-edit');
editButton.click();
expect(component.edit.emit).toHaveBeenCalledWith(user);
});
it('should not show actions when showActions is false', () => {
component.showActions = false;
fixture.detectChanges();
const actions = fixture.nativeElement.querySelector('.actions');
expect(actions).toBeFalsy();
});
});
2. 服务测试
typescript
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';
import { User } from '../models/user';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should get users', () => {
const mockUsers: User[] = [
{ id: 1, name: 'John Doe', email: 'john@example.com', role: 'user' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'admin' }
];
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: Partial<User> = {
name: 'New User',
email: 'new@example.com',
role: 'user'
};
const createdUser: User = {
id: 3,
...newUser
} as User;
service.createUser(newUser).subscribe(user => {
expect(user).toEqual(createdUser);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush(createdUser);
});
it('should handle error when getting users fails', () => {
service.getUsers().subscribe(
() => fail('should have failed'),
error => {
expect(error.status).toBe(500);
}
);
const req = httpMock.expectOne('/api/users');
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
});
});
3. 管道测试
typescript
// capitalize.pipe.spec.ts
import { CapitalizePipe } from './capitalize.pipe';
describe('CapitalizePipe', () => {
let pipe: CapitalizePipe;
beforeEach(() => {
pipe = new CapitalizePipe();
});
it('should create', () => {
expect(pipe).toBeTruthy();
});
it('should capitalize first letter', () => {
expect(pipe.transform('hello')).toBe('Hello');
});
it('should handle empty string', () => {
expect(pipe.transform('')).toBe('');
});
it('should handle null and undefined', () => {
expect(pipe.transform(null)).toBe('');
expect(pipe.transform(undefined)).toBe('');
});
it('should handle already capitalized string', () => {
expect(pipe.transform('Hello')).toBe('Hello');
});
});
🔗 集成测试
1. 组件集成测试
typescript
// user-list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';
import { of } from 'rxjs';
describe('UserListComponent Integration', () => {
let component: UserListComponent;
let fixture: ComponentFixture<UserListComponent>;
let userService: jasmine.SpyObj<UserService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('UserService', ['getUsers', 'deleteUser']);
await TestBed.configureTestingModule({
declarations: [UserListComponent],
imports: [HttpClientTestingModule],
providers: [
{ provide: UserService, useValue: spy }
]
}).compileComponents();
fixture = TestBed.createComponent(UserListComponent);
component = fixture.componentInstance;
userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
});
it('should load users on init', () => {
const mockUsers = [
{ id: 1, name: 'John Doe', email: 'john@example.com', role: 'user' }
];
userService.getUsers.and.returnValue(of(mockUsers));
component.ngOnInit();
expect(userService.getUsers).toHaveBeenCalled();
expect(component.users).toEqual(mockUsers);
});
it('should delete user and reload list', () => {
const mockUsers = [
{ id: 1, name: 'John Doe', email: 'john@example.com', role: 'user' }
];
userService.getUsers.and.returnValue(of(mockUsers));
userService.deleteUser.and.returnValue(of(null));
component.ngOnInit();
component.deleteUser(1);
expect(userService.deleteUser).toHaveBeenCalledWith(1);
expect(userService.getUsers).toHaveBeenCalledTimes(2);
});
});
2. 路由测试
typescript
// app-routing.module.spec.ts
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { AppRoutingModule } from './app-routing.module';
describe('AppRoutingModule', () => {
let router: Router;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppRoutingModule, RouterTestingModule]
});
router = TestBed.inject(Router);
});
it('should navigate to home', async () => {
await router.navigate(['/']);
expect(router.url).toBe('/home');
});
it('should navigate to about', async () => {
await router.navigate(['/about']);
expect(router.url).toBe('/about');
});
it('should navigate to user detail with id', async () => {
await router.navigate(['/user', '123']);
expect(router.url).toBe('/user/123');
});
});
🎯 端到端测试
1. Cypress配置
typescript
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:4200',
supportFile: 'cypress/support/e2e.ts',
specPattern: 'cypress/e2e/**/*.cy.ts',
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true
}
});
2. E2E测试示例
typescript
// user-management.cy.ts
describe('User Management', () => {
beforeEach(() => {
cy.visit('/users');
});
it('should display user list', () => {
cy.get('[data-cy=user-list]').should('be.visible');
cy.get('[data-cy=user-card]').should('have.length.greaterThan', 0);
});
it('should create new user', () => {
cy.get('[data-cy=create-user-btn]').click();
cy.get('[data-cy=user-form]').should('be.visible');
cy.get('[data-cy=name-input]').type('New User');
cy.get('[data-cy=email-input]').type('newuser@example.com');
cy.get('[data-cy=role-select]').select('user');
cy.get('[data-cy=submit-btn]').click();
cy.get('[data-cy=success-message]').should('be.visible');
cy.get('[data-cy=user-card]').should('contain', 'New User');
});
it('should edit user', () => {
cy.get('[data-cy=user-card]').first().find('[data-cy=edit-btn]').click();
cy.get('[data-cy=name-input]').clear().type('Updated Name');
cy.get('[data-cy=submit-btn]').click();
cy.get('[data-cy=success-message]').should('be.visible');
cy.get('[data-cy=user-card]').first().should('contain', 'Updated Name');
});
it('should delete user', () => {
cy.get('[data-cy=user-card]').first().find('[data-cy=delete-btn]').click();
cy.get('[data-cy=confirm-dialog]').should('be.visible');
cy.get('[data-cy=confirm-btn]').click();
cy.get('[data-cy=success-message]').should('be.visible');
});
it('should search users', () => {
cy.get('[data-cy=search-input]').type('John');
cy.get('[data-cy=search-btn]').click();
cy.get('[data-cy=user-card]').should('contain', 'John');
});
});
🛠️ 测试工具和技巧
1. 测试辅助函数
typescript
// test-utils.ts
import { ComponentFixture } from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
export class TestUtils {
static clickElement(fixture: ComponentFixture<any>, selector: string): void {
const element = fixture.debugElement.query(By.css(selector));
element.triggerEventHandler('click', null);
fixture.detectChanges();
}
static setInputValue(fixture: ComponentFixture<any>, selector: string, value: string): void {
const input = fixture.debugElement.query(By.css(selector));
input.nativeElement.value = value;
input.nativeElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
}
static getElementText(fixture: ComponentFixture<any>, selector: string): string {
const element = fixture.debugElement.query(By.css(selector));
return element.nativeElement.textContent.trim();
}
static hasClass(fixture: ComponentFixture<any>, selector: string, className: string): boolean {
const element = fixture.debugElement.query(By.css(selector));
return element.nativeElement.classList.contains(className);
}
}
2. 模拟数据工厂
typescript
// test-data-factory.ts
import { User } from '../models/user';
export class TestDataFactory {
static createUser(overrides: Partial<User> = {}): User {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
};
}
static createUsers(count: number): User[] {
return Array.from({ length: count }, (_, index) =>
this.createUser({
id: index + 1,
name: `User ${index + 1}`,
email: `user${index + 1}@example.com`
})
);
}
}
3. 异步测试
typescript
// async-testing.spec.ts
import { fakeAsync, tick } from '@angular/core/testing';
describe('Async Testing', () => {
it('should handle async operations with fakeAsync', fakeAsync(() => {
let value = 0;
setTimeout(() => {
value = 1;
}, 1000);
expect(value).toBe(0);
tick(1000);
expect(value).toBe(1);
}));
it('should handle promises', async () => {
const promise = Promise.resolve('test');
const result = await promise;
expect(result).toBe('test');
});
it('should handle observables', (done) => {
const observable = of('test');
observable.subscribe(value => {
expect(value).toBe('test');
done();
});
});
});
📊 测试覆盖率
1. 覆盖率配置
typescript
// angular.json
{
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"codeCoverage": true,
"codeCoverageExclude": [
"src/**/*.spec.ts",
"src/**/*.module.ts",
"src/main.ts",
"src/polyfills.ts"
]
}
}
}
2. 覆盖率报告
bash
# 生成覆盖率报告
ng test --code-coverage
# 查看覆盖率报告
open coverage/index.html
🎮 实践练习
练习1:为现有组件编写测试
为之前创建的用户卡片组件编写完整的测试套件,包括:
- 组件创建测试
- 属性绑定测试
- 事件发射测试
- 条件渲染测试
练习2:编写服务测试
为用户服务编写测试,包括:
- HTTP请求测试
- 错误处理测试
- 数据转换测试
- 缓存功能测试
📚 测试最佳实践
1. 测试命名
typescript
// 好的测试命名
describe('UserService', () => {
describe('getUsers', () => {
it('should return users when API call succeeds', () => {
// 测试实现
});
it('should return empty array when API call fails', () => {
// 测试实现
});
});
});
// 避免的测试命名
describe('UserService', () => {
it('should work', () => {
// 测试实现
});
});
2. 测试结构
typescript
// AAA模式:Arrange, Act, Assert
it('should calculate total price correctly', () => {
// Arrange - 准备测试数据
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 3 }
];
// Act - 执行被测试的操作
const total = calculateTotal(items);
// Assert - 验证结果
expect(total).toBe(35);
});
3. 测试隔离
typescript
describe('UserComponent', () => {
let component: UserComponent;
let fixture: ComponentFixture<UserComponent>;
beforeEach(() => {
// 每个测试前重新创建组件
fixture = TestBed.createComponent(UserComponent);
component = fixture.componentInstance;
});
afterEach(() => {
// 清理工作
fixture.destroy();
});
});
✅ 学习检查
完成本章节后,请确认您能够:
- [ ] 编写单元测试
- [ ] 编写集成测试
- [ ] 编写端到端测试
- [ ] 使用测试工具和技巧
- [ ] 理解测试最佳实践
- [ ] 生成和分析测试覆盖率
🚀 下一步
完成本章节学习后,请继续学习15-性能优化。