测试
使用 Ionic CLI 生成 @ionic/angular 应用时,会自动配置好单元测试和端到端测试环境。这与 Angular CLI 使用的配置相同。关于测试 Angular 应用的详细信息,请参考 Angular 测试指南。
测试原则
测试应用时,需要记住:测试可以揭示系统中是否存在缺陷,但无法证明任何一个非平凡的系统是完全没有缺陷的。因此,测试的目标不是为了验证代码的正确性,而是为了发现代码中的问题。这是一个微妙但很重要的区别。
如果我们试图证明代码是正确的,往往会倾向于只走“快乐路径”。但如果我们以发现问题为目标,则更可能全面地测试代码,找出其中潜藏的 Bug。
此外,最好从项目一开始就进行测试。这样可以在早期发现问题,修复起来也更容易。同时,当系统添加新功能时,也能更有信心地进行代码重构。
单元测试
单元测试是在隔离环境下执行代码的单一单元(如组件、页面、服务、管道等)。隔离是通过注入模拟对象(mock objects)来替代代码的真实依赖项实现的。模拟对象让测试能够精细控制依赖项的返回值,也可以用来验证哪些依赖项被调用过以及传入了什么参数。
编写良好的单元测试会通过 describe() 回调函数来描述被测试的代码单元及其功能。代码单元的需求和功能则通过 it() 回调函数进行测试。当我们将 describe() 和 it() 的描述文本连起来读时,它们应该构成一个有意义的短语。将嵌套的 describe() 和最终的 it() 描述拼接起来,就形成了一个完整描述测试用例的句子。
由于单元测试在隔离环境中执行代码,因此运行速度快、结果稳定,并且可以实现很高的代码覆盖率。
使用模拟对象
单元测试是在隔离环境中测试代码模块。为此,我们推荐使用 Jasmine (https://jasmine.github.io/)。Jasmine 可以创建模拟对象(在 Jasmine 中称为 "spies")来在测试时代替 真实的依赖项。使用模拟对象时,测试可以控制对这些依赖项调用的返回值,使当前测试不受依赖项本身变更的影响。这也简化了测试设置,让测试只需关注被测模块内部的代码。
使用模拟对象还允许测试通过 toHaveBeenCalled* 系列函数来查询该模拟对象是否被调用以及如何被调用。测试应尽可能具体地使用这些函数,在验证方法是否被调用时,优先使用 toHaveBeenCalledTimes 而不是 toHaveBeenCalled。也就是说,expect(mock.foo).toHaveBeenCalledTimes(1) 优于 expect(mock.foo).toHaveBeenCalled()。但在验证某个方法未被调用时,则应采用相反的建议(即使用 expect(mock.foo).not.toHaveBeenCalled())。
在 Jasmine 中创建模拟对象有两种常用方法。可以从头开始使用 jasmine.createSpy 和 jasmine.createSpyObj 构建模拟对象,也可以使用 spyOn() 和 spyOnProperty() 在现有对象上安装 spies。
使用 jasmine.createSpy 和 jasmine.createSpyObj
jasmine.createSpyObj 从头开始创建一个完整的模拟对象,并在创建时定义一组模拟方法。它的优点是非常简单,无需在测试中构建或注入任何东西。缺点是使用此函数创建的对象可能与真实对象不匹配。
jasmine.createSpy 类似,但它创建一个独立的模拟函数。
使用 spyOn() 和 spyOnProperty()
spyOn() 将一个 spy 安装到现有对象上。这种技术的优点是,如果尝试监听一个在对象上不存在的方法,会抛出异常。这可以防止测试模拟不存在的方法。缺点是需要一个已完全成形的对象作为起点,这可能会增加测试设置的工作量。
spyOnProperty() 与此类似,区别在于它监听的是属性而不是方法。
通用测试结构
单元测试放在 spec 文件中,每个实体(组件、页面、服务、管道等)对应一个 spec 文件。spec 文件与它们所测试的源代码文件放在同一目录下,并以源文件命名。例如,如果项目中有一个名为 WeatherService 的服务,其代码位于 weather.service.ts 文件中,那么测试代码就位于同一文件夹下的 weather.service.spec.ts 文件中。
spec 文件本身包含一个外层的 describe 调用来定义整体测试。其内部嵌套着其他的 describe 调用,用于定义主要的功能区域。每个 describe 调用可以包含设置和清理代码(通常通过 beforeEach 和 afterEach 处理)、更多的 describe 调用以形成层次化的功能分解,以及定义单个测试用例的 it 调用。
describe 和 it 调用都包含一个描述性的文本标签。在编写良好的测试中,describe 和 it 调用与它们的标签组合起来构成恰当的短语,而通过组合 describe 和 it 标签形成的每个测试用例的完整标签,则构成一个完整的句子。
例如:
describe('计算服务', () => {
describe('除法功能', () => {
it('能够正确计算 4 / 2', () => {});
it('会拒绝除以零的操作', () => {});
...
});
describe('乘法功能', () => {
...
});
});
外层 describe 说明正在测试 计算服务,内层 describe 明确说明了正在测试的具体功能,而 it 则描述了测试用例是什么。运行时,每个测试用例的完整标签是一个有意义的句子(计算服务 除法功能 会拒绝除以零的操作)。
页面和组件
页面本质上是 Angular 组件。因此,页面和组件都应遵循 Angular 的组件测试指南进行测试。
由于页面和组件包含 TypeScript 代码和 HTML 模板标记,因此可以同时进行组件类测试和组件 DOM 测试。创建页面时,生成的模板测试如下所示:
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TabsPage } from './tabs.page';
describe('TabsPage', () => {
let component: TabsPage;
let fixture: ComponentFixture<TabsPage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TabsPage],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(TabsPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('应该成功创建组件', () => {
expect(component).toBeTruthy();
});
});
进行组件类测试时,通过 component = fixture.componentInstance; 定义的组件对象来访问组件实例。进行 DOM 测试时,则使用 fixture.nativeElement 属性。这是组件的实际 HTMLElement,允许测试使用标准的 HTML API 方法(如 HTMLElement.querySelector)来检查 DOM。
服务
服务通常可以分为两大类:实用工具服务(执行计算和其他操作)和数据服务(主要执行 HTTP 操作和数据操作)。
基础服务测试
测试大多数服务的建议方法是:实例化服务,并手动为其依赖项注入模拟对象。这样,就可以在隔离环境中测试代码。
假设有一个服务,它包含一个方法,该方法接收一组工时记录卡并计算净工资。再假设税务计算是由另一个服务处理的,并且当前服务依赖于它。这个工资服务的测试可以这样写:
import { PayrollService } from './payroll.service';
describe('PayrollService', () => {
let service: PayrollService;
let taxServiceSpy;
beforeEach(() => {
taxServiceSpy = jasmine.createSpyObj('TaxService', {
federalIncomeTax: 0, // 联邦所得税
stateIncomeTax: 0, // 州所得税
socialSecurity: 0, // 社会保障税
medicare: 0 // 医疗保险税
});
service = new PayrollService(taxServiceSpy);
});
describe('净工资计算', () => {
...
});
});
这种方式允许测试通过模拟设置(如 taxServiceSpy.federalIncomeTax.and.returnValue(73.24))来控制各项税务计算的返回值。这使得“净工资”测试独立于税务计算逻辑。当税法变更时,只需要修改与税务服务相关的代码和测试。净工资的测试可以继续照常运行,因为这些测试不关心税是怎么算的,只关心数值是否被正确应用。
通过 ionic g service name 生成服务时,创建的基础代码会使用 Angular 的测试工具并设置一个测试模块。但这并非绝对必要。不过,可以保留这些代码,以便手动构建服务或像下面这样注入服务:
import { TestBed, inject } from '@angular/core/testing';
import { PayrollService } from './payroll.service';
import { TaxService } from './tax.service';
describe('PayrolService', () => {
let taxServiceSpy;
beforeEach(() => {
taxServiceSpy = jasmine.createSpyObj('TaxService', {
federalIncomeTax: 0,
stateIncomeTax: 0,
socialSecurity: 0,
medicare: 0,
});
TestBed.configureTestingModule({
providers: [PayrollService, { provide: TaxService, useValue: taxServiceSpy }],
});
});
it('通过注入方式进行测试', inject([PayrollService], (service: PayrollService) => {
expect(service).toBeTruthy();
}));
it('通过手动构建方式进行测试', () => {
const service = new PayrollService(taxServiceSpy);
expect(service).toBeTruthy();
});
});