Angular提供了强大的测试工具链,使用Jasmine作为测试框架,Karma作为测试运行器。本章将详细介绍如何编写组件和服务的单元测试。
ng test 启动测试。
// karma.conf.js - Karma配置文件
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// 可以在这里配置Jasmine选项
},
clearContext: false // 在浏览器中保留测试结果
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/my-app'),
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
});
};
// app.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { By } from '@angular/platform-browser';
describe('AppComponent', () => {
let component: AppComponent;
let fixture: ComponentFixture<AppComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('应该创建组件', () => {
expect(component).toBeTruthy();
});
it(`应该包含标题 'my-app'`, () => {
expect(component.title).toEqual('my-app');
});
it('应该渲染标题', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.content h1')?.textContent)
.toContain('my-app app is running!');
});
it('点击按钮应该增加计数', () => {
const button = fixture.debugElement.query(By.css('button'));
expect(component.count).toBe(0);
button.triggerEventHandler('click', null);
fixture.detectChanges();
expect(component.count).toBe(1);
const countElement = fixture.debugElement.query(By.css('.count'));
expect(countElement.nativeElement.textContent).toContain('1');
});
});
// user-item.component.spec.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
@Component({
selector: 'app-user-item',
template: `
<div class="user-item">
<span>{{user.name}}</span>
<button (click)="onDelete()">删除</button>
</div>
`
})
export class UserItemComponent {
@Input() user: any;
@Output() delete = new EventEmitter<number>();
onDelete() {
this.delete.emit(this.user.id);
}
}
describe('UserItemComponent', () => {
let component: UserItemComponent;
let fixture: ComponentFixture<UserItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [UserItemComponent]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(UserItemComponent);
component = fixture.componentInstance;
component.user = { id: 1, name: '张三' };
fixture.detectChanges();
});
it('应该正确显示用户名', () => {
const span = fixture.nativeElement.querySelector('span');
expect(span.textContent).toContain('张三');
});
it('点击删除按钮应该触发事件', () => {
spyOn(component.delete, 'emit');
const button = fixture.nativeElement.querySelector('button');
button.click();
expect(component.delete.emit).toHaveBeenCalledWith(1);
});
it('应该根据输入更新显示', () => {
component.user = { id: 2, name: '李四' };
fixture.detectChanges();
const span = fixture.nativeElement.querySelector('span');
expect(span.textContent).toContain('李四');
});
});
// async-data.component.spec.ts
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { AsyncDataComponent } from './async-data.component';
describe('AsyncDataComponent', () => {
let component: AsyncDataComponent;
let fixture: ComponentFixture<AsyncDataComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AsyncDataComponent]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(AsyncDataComponent);
component = fixture.componentInstance;
});
it('应该处理Promise数据 - 使用async/await', async () => {
await component.loadData();
expect(component.data).toBe('加载的数据');
});
it('应该处理Promise数据 - 使用fakeAsync', fakeAsync(() => {
component.loadData();
tick(1000); // 模拟时间流逝
expect(component.data).toBe('加载的数据');
}));
it('应该处理定时器', fakeAsync(() => {
component.startTimer();
expect(component.timerValue).toBe(0);
tick(1000);
expect(component.timerValue).toBe(1);
tick(2000);
expect(component.timerValue).toBe(3);
component.stopTimer();
tick(1000);
expect(component.timerValue).toBe(3); // 停止后不再增加
}));
});
// user.service.spec.ts
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;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // 确保没有未处理的HTTP请求
});
it('应该被创建', () => {
expect(service).toBeTruthy();
});
it('应该获取用户列表', () => {
const mockUsers = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
];
service.getUsers().subscribe(users => {
expect(users.length).toBe(2);
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('应该创建新用户', () => {
const newUser = { name: '王五' };
const createdUser = { id: 3, name: '王五' };
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('应该处理HTTP错误', () => {
service.getUsers().subscribe({
next: () => fail('应该返回错误'),
error: (error) => {
expect(error.status).toBe(404);
expect(error.statusText).toBe('Not Found');
}
});
const req = httpMock.expectOne('api/users');
req.flush('Not Found', {
status: 404,
statusText: 'Not Found'
});
});
});
// auth.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
import { LocalStorageService } from './local-storage.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
// 创建模拟服务
class MockLocalStorageService {
private store: { [key: string]: string } = {};
setItem(key: string, value: string): void {
this.store[key] = value;
}
getItem(key: string): string | null {
return this.store[key] || null;
}
removeItem(key: string): void {
delete this.store[key];
}
}
describe('AuthService', () => {
let service: AuthService;
let localStorageService: LocalStorageService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AuthService,
{ provide: LocalStorageService, useClass: MockLocalStorageService }
]
});
service = TestBed.inject(AuthService);
localStorageService = TestBed.inject(LocalStorageService);
});
it('应该登录并保存token', () => {
const token = 'fake-jwt-token';
spyOn(localStorageService, 'setItem');
service.login('user', 'pass').subscribe(result => {
expect(result).toBeTrue();
expect(localStorageService.setItem).toHaveBeenCalledWith('auth_token', token);
});
});
it('应该登出并清除token', () => {
spyOn(localStorageService, 'removeItem');
service.logout();
expect(localStorageService.removeItem).toHaveBeenCalledWith('auth_token');
expect(service.isLoggedIn()).toBeFalse();
});
it('应该检查登录状态', () => {
// 模拟已登录状态
spyOn(localStorageService, 'getItem').and.returnValue('fake-token');
expect(service.isLoggedIn()).toBeTrue();
// 模拟未登录状态
(localStorageService.getItem as jasmine.Spy).and.returnValue(null);
expect(service.isLoggedIn()).toBeFalse();
});
});
// capitalize.pipe.spec.ts
import { CapitalizePipe } from './capitalize.pipe';
describe('CapitalizePipe', () => {
let pipe: CapitalizePipe;
beforeEach(() => {
pipe = new CapitalizePipe();
});
it('应该创建管道', () => {
expect(pipe).toBeTruthy();
});
it('应该转换字符串为首字母大写', () => {
expect(pipe.transform('angular')).toBe('Angular');
expect(pipe.transform('ANGULAR')).toBe('Angular');
expect(pipe.transform('aNgUlAr')).toBe('Angular');
});
it('应该处理空字符串', () => {
expect(pipe.transform('')).toBe('');
});
it('应该处理null和undefined', () => {
expect(pipe.transform(null as any)).toBe('');
expect(pipe.transform(undefined as any)).toBe('');
});
it('应该处理多个单词', () => {
expect(pipe.transform('angular framework')).toBe('Angular framework');
});
});
// highlight.directive.spec.ts
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';
// 创建测试组件
@Component({
template: `
<div>
<p appHighlight>默认高亮</p>
<p appHighlight="yellow">黄色高亮</p>
<p appHighlight [defaultColor]="'pink'">粉色高亮</p>
</div>
`
})
class TestComponent {}
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let debugElements: DebugElement[];
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TestComponent, HighlightDirective]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
debugElements = fixture.debugElement.queryAll(By.directive(HighlightDirective));
});
it('应该有3个元素使用了指令', () => {
expect(debugElements.length).toBe(3);
});
it('第一个元素应该有默认背景色', () => {
const bgColor = debugElements[0].nativeElement.style.backgroundColor;
expect(bgColor).toBe('rgb(255, 255, 0)'); // yellow
});
it('第二个元素应该有指定背景色', () => {
const bgColor = debugElements[1].nativeElement.style.backgroundColor;
expect(bgColor).toBe('rgb(255, 255, 0)'); // yellow
});
it('第三个元素应该有默认颜色输入', () => {
const directive = debugElements[2].injector.get(HighlightDirective);
expect(directive.defaultColor).toBe('pink');
});
it('鼠标进入应该改变背景色', () => {
const de = debugElements[0];
de.triggerEventHandler('mouseenter', null);
fixture.detectChanges();
expect(de.nativeElement.style.backgroundColor).toBe('rgb(173, 216, 230)'); // lightblue
});
it('鼠标离开应该恢复背景色', () => {
const de = debugElements[0];
// 先触发进入
de.triggerEventHandler('mouseenter', null);
fixture.detectChanges();
// 再触发离开
de.triggerEventHandler('mouseleave', null);
fixture.detectChanges();
expect(de.nativeElement.style.backgroundColor).toBe('rgb(255, 255, 0)'); // yellow
});
});
使用Istanbul收集测试覆盖率:
// package.json 脚本配置
{
"scripts": {
"test": "ng test",
"test:coverage": "ng test --code-coverage",
"test:watch": "ng test --watch",
"test:ci": "ng test --watch=false --browsers=ChromeHeadless"
}
}
运行 ng test --code-coverage 后,在 coverage/ 目录下查看HTML报告。
// 常用的测试工具和模式
import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
// 1. 模拟用户输入
it('应该响应用户输入', () => {
const input = fixture.debugElement.query(By.css('input'));
input.nativeElement.value = '新值';
input.nativeElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(component.value).toBe('新值');
});
// 2. 测试表单提交
it('应该提交表单数据', () => {
const form = fixture.debugElement.query(By.css('form'));
spyOn(component, 'onSubmit');
form.triggerEventHandler('submit', null);
expect(component.onSubmit).toHaveBeenCalled();
});
// 3. 测试路由导航
it('应该导航到详情页', () => {
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
component.viewDetails(1);
expect(router.navigate).toHaveBeenCalledWith(['/details', 1]);
});
// 4. 测试HTTP拦截器
it('应该添加认证头', () => {
const httpRequest = new HttpRequest('GET', '/api/data');
const next: HttpHandler = {
handle: jasmine.createSpy('handle')
};
interceptor.intercept(httpRequest, next);
expect(next.handle).toHaveBeenCalled();
const interceptedRequest = (next.handle as jasmine.Spy).calls.mostRecent().args[0];
expect(interceptedRequest.headers.has('Authorization')).toBeTrue();
});
TestBed.configureTestingModule() 配置测试模块HttpClientTestingModule 测试HTTP请求RouterTestingModule 测试路由ComponentFixtureAutoDetect 自动检测变更ChromeHeadless 无头浏览器