跳至主要内容
版本: v8

测试

使用 Ionic CLI 生成 @ionic/angular 应用时,会自动为应用设置单元测试和端到端测试。这与 Angular CLI 使用的设置相同。有关测试 Angular 应用的详细信息,请参阅 Angular 测试指南

测试原则

测试应用时,最好记住,测试可以显示系统中是否存在缺陷。但是,不可能证明任何非平凡系统完全没有缺陷。因此,测试的目标不是验证代码是否正确,而是找出代码中的问题。这是一个细微但重要的区别。

如果我们着手证明代码是正确的,我们更有可能坚持代码中的正常路径。如果我们着手找出问题,我们更有可能更充分地执行代码并找出潜伏在那里的错误。

最好从一开始就测试应用。这允许在缺陷更容易修复的早期阶段发现它们。这也允许在向系统添加新功能时放心地重构代码。

单元测试

单元测试独立于系统其余部分,对单个代码单元(组件、页面、服务、管道等)进行测试。隔离是通过注入模拟对象来代替代码的依赖项来实现的。模拟对象允许测试对依赖项的输出进行细粒度控制。模拟对象还允许测试确定哪些依赖项已被调用以及传递给它们的内容。

编写良好的单元测试的结构是这样的:代码单元及其包含的功能通过 describe() 回调进行描述。代码单元及其功能的要求通过 it() 回调进行测试。当读取 describe()it() 回调的描述时,它们有意义地形成一个短语。当读取嵌套的 describe() 和最终的 it() 的描述时,它们形成了一个完整地描述测试用例的句子。

由于单元测试独立地对代码进行测试,因此它们速度快,健壮,并允许实现高代码覆盖率。

使用模拟

单元测试独立地对代码模块进行测试。为了促进这一点,我们建议使用 Jasmine (https://jasmine.org.cn/)。Jasmine 创建模拟对象(Jasmine 称之为“间谍”)来代替测试期间的依赖项。使用模拟对象时,测试可以控制对该依赖项的调用的返回值,从而使当前测试独立于对依赖项的更改。这也使测试设置更容易,允许测试仅关注被测试模块中的代码。

使用模拟还允许测试查询模拟以确定它是否被调用以及如何被调用,方法是使用 toHaveBeenCalled* 函数集。测试应该尽可能具体地使用这些函数,在测试方法是否被调用时,优先调用 toHaveBeenCalledTimes 而不是 toHaveBeenCalled。也就是说,expect(mock.foo).toHaveBeenCalledTimes(1)expect(mock.foo).toHaveBeenCalled() 更好。在测试某事是否未被调用时,应该遵循相反的建议(expect(mock.foo).not.toHaveBeenCalled())。

在 Jasmine 中创建模拟对象有两种常见方法。模拟对象可以使用 jasmine.createSpyjasmine.createSpyObj 从头开始构建,或者可以使用 spyOn()spyOnProperty() 将间谍安装到现有对象上。

使用 jasmine.createSpyjasmine.createSpyObj

jasmine.createSpyObj 从头开始创建一个完整的模拟对象,并在创建时定义一组模拟方法。这样做的好处在于它非常简单。不需要构建或注入任何内容到测试中。使用此函数的缺点是它允许创建可能与真实对象不匹配的对象。

jasmine.createSpy 类似,但它创建一个独立的模拟函数。

使用 spyOn()spyOnProperty()

spyOn() 在现有对象上安装间谍。使用此技术的优点是,如果尝试在对象上间谍一个不存在的方法,则会引发异常。这可以防止测试模拟不存在的方法。缺点是测试需要一个完整的对象才能开始,这可能会增加所需的测试设置量。

spyOnProperty() 类似,区别在于它间谍的是属性而不是方法。

通用测试结构

单元测试包含在 spec 文件中,每个实体(组件、页面、服务、管道等)都有一个 spec 文件。spec 文件与它们正在测试的源代码并排放置,并以它们的名称命名。例如,如果项目有一个名为 WeatherService 的服务,那么它的代码位于名为 weather.service.ts 的文件中,测试位于名为 weather.service.spec.ts 的文件中。这两个文件都在同一个文件夹中。

spec 文件本身包含单个 describe 调用,该调用定义了整个测试。在其内部嵌套的是其他 describe 调用,这些调用定义了主要的功能区域。每个 describe 调用都可以包含设置和拆卸代码(通常通过 beforeEachafterEach 调用处理)、更多形成功能层次结构的 describe 调用,以及定义单个测试用例的 it 调用。

describeit 调用还包含一个描述性文本标签。在结构良好的测试中,describeit 调用与其标签结合起来形成正确的短语,并且每个测试用例的完整标签(通过结合 describeit 标签形成)构成一个完整的句子。

例如

describe('Calculation', () => {
describe('divide', () => {
it('calculates 4 / 2 properly' () => {});
it('cowardly refuses to divide by zero' () => {});
...
});

describe('multiply', () => {
...
});
});

外部 describe 调用声明正在测试 Calculation 服务,内部 describe 调用声明了正在测试的确切功能,而 it 调用声明了测试用例。运行时,每个测试用例的完整标签是一个有意义的句子(Calculation 除法怯懦地拒绝被零除)。

页面和组件

页面只是 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('should create', () => {
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('net pay calculations', () => {
...
});
});

这允许测试通过模拟设置(例如 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('does some test where it is injected', inject([PayrollService], (service: PayrollService) => {
expect(service).toBeTruthy();
}));

it('does some test where it is manually built', () => {
const service = new PayrollService(taxServiceSpy);
expect(service).toBeTruthy();
});
});

测试 HTTP 数据服务

大多数执行 HTTP 操作的服务将使用 Angular 的 HttpClient 服务来执行这些操作。对于此类测试,建议使用 Angular 的 HttpClientTestingModule。有关此模块的详细文档,请参阅 Angular 的 Angular 的测试 HTTP 请求 指南。

此类测试的基本设置如下所示

import { HttpBackend, HttpClient } from '@angular/common/http';
import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed, inject } from '@angular/core/testing';

import { IssTrackingDataService } from './iss-tracking-data.service';

describe('IssTrackingDataService', () => {
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
let issTrackingDataService: IssTrackingDataService;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [IssTrackingDataService],
});

httpClient = TestBed.get(HttpClient);
httpTestingController = TestBed.get(HttpTestingController);
issTrackingDataService = new IssTrackingDataService(httpClient);
});

it('exists', inject([IssTrackingDataService], (service: IssTrackingDataService) => {
expect(service).toBeTruthy();
}));

describe('location', () => {
it('gets the location of the ISS now', () => {
issTrackingDataService.location().subscribe((x) => {
expect(x).toEqual({ longitude: -138.1719, latitude: 44.4423 });
});
const req = httpTestingController.expectOne('http://api.open-notify.org/iss-now.json');
expect(req.request.method).toEqual('GET');
req.flush({
iss_position: { longitude: '-138.1719', latitude: '44.4423' },
timestamp: 1525950644,
message: 'success',
});
httpTestingController.verify();
});
});
});

管道

管道就像一个具有特定定义接口的服务。它是一个包含一个公共方法 transform 的类,该方法操作输入值(和其他可选参数)以创建渲染在页面上的输出。要测试管道:实例化管道,调用 transform 方法,并验证结果。

作为简单示例,让我们看一下接受一个 Person 对象并格式化姓名的管道。为了简单起见,假设 PersonidfirstNamelastNamemiddleInitial 组成。管道要求将姓名打印为“Last, First M.”,处理不存在姓、名或中间名的情况。这样的测试可能如下所示

import { NamePipe } from './name.pipe';

import { Person } from '../../models/person';

describe('NamePipe', () => {
let pipe: NamePipe;
let testPerson: Person;

beforeEach(() => {
pipe = new NamePipe();
testPerson = {
id: 42,
firstName: 'Douglas',
lastName: 'Adams',
middleInitial: 'N',
};
});

it('exists', () => {
expect(pipe).toBeTruthy();
});

it('formats a full name properly', () => {
expect(pipe.transform(testPerson)).toBeEqual('Adams, Douglas N.');
});

it('handles having no middle initial', () => {
delete testPerson.middleInitial;
expect(pipe.transform(testPerson)).toBeEqual('Adams, Douglas');
});

it('handles having no first name', () => {
delete testPerson.firstName;
expect(pipe.transform(testPerson)).toBeEqual('Adams N.');
});

it('handles having no last name', () => {
delete testPerson.lastName;
expect(pipe.transform(testPerson)).toBeEqual('Douglas N.');
});
});

通过使用该管道的组件和页面中的 DOM 测试来练习管道也是有益的。

端到端测试

端到端测试用于验证应用程序作为一个整体是否正常工作,并且通常包括与实时数据的连接。单元测试侧重于隔离的代码单元,因此可以对应用程序逻辑进行低级测试,而端到端测试侧重于各种用户故事或使用场景,对数据在应用程序中的整体流进行高级测试。单元测试试图发现应用程序逻辑中的问题,而端到端测试试图发现这些单个单元组合使用时出现的问题。端到端测试揭示了应用程序整体架构中的问题。

由于端到端测试练习用户故事并涵盖整个应用程序而不是单个代码模块,因此端到端测试存在于项目中的自己的应用程序中,与主应用程序本身的代码分离。大多数端到端测试通过自动执行与应用程序的常见用户交互并检查 DOM 来确定这些交互的结果来进行操作。

测试结构

当生成 @ionic/angular 应用程序时,会在 e2e 文件夹中生成一个默认的端到端测试应用程序。此应用程序使用 Protractor 控制浏览器,并使用 Jasmine 来构建和执行测试。该应用程序最初包含四个文件

  • protractor.conf.js - Protractor 配置文件
  • tsconfig.e2e.json - 测试应用程序的特定 TypeScript 配置
  • src/app.po.ts - 一个页面对象,包含用于导航应用程序、查询 DOM 中的元素以及操作页面上元素的方法
  • src/app.e2e-spec.ts - 一个测试脚本

页面对象

端到端测试通过自动执行与应用程序的常见用户交互,等待应用程序响应,并检查 DOM 来确定交互的结果来进行操作。这涉及大量的 DOM 操作和检查。如果所有操作都是手动完成的,那么测试将非常脆弱,难以阅读和维护。

页面对象将单个页面的 HTML 封装在 TypeScript 类中,提供测试脚本用来与应用程序交互的 API。将 DOM 操作逻辑封装在页面对象中,使测试更易读,更容易理解,从而降低了测试的维护成本。创建精心设计的页面对象是创建高质量和可维护的端到端测试的关键。

基本页面对象

许多测试依赖于诸如等待页面可见、在输入框中输入文本以及单击按钮之类的操作。用于执行此操作的方法保持一致,只有用于获取适当 DOM 元素的 CSS 选择器会发生变化。因此,将此逻辑抽象到可以由其他页面对象使用的基类中是有意义的。

以下是一个实现所有页面对象都需要支持的一些基本方法的示例。

import { browser, by, element, ExpectedConditions } from 'protractor';

export class PageObjectBase {
private path: string;
protected tag: string;

constructor(tag: string, path: string) {
this.tag = tag;
this.path = path;
}

load() {
return browser.get(this.path);
}

rootElement() {
return element(by.css(this.tag));
}

waitUntilInvisible() {
browser.wait(ExpectedConditions.invisibilityOf(this.rootElement()), 3000);
}

waitUntilPresent() {
browser.wait(ExpectedConditions.presenceOf(this.rootElement()), 3000);
}

waitUntilNotPresent() {
browser.wait(ExpectedConditions.not(ExpectedConditions.presenceOf(this.rootElement())), 3000);
}

waitUntilVisible() {
browser.wait(ExpectedConditions.visibilityOf(this.rootElement()), 3000);
}

getTitle() {
return element(by.css(`${this.tag} ion-title`)).getText();
}

protected enterInputText(sel: string, text: string) {
const el = element(by.css(`${this.tag} ${sel}`));
const inp = el.element(by.css('input'));
inp.sendKeys(text);
}

protected enterTextareaText(sel: string, text: string) {
const el = element(by.css(`${this.tag} ${sel}`));
const inp = el.element(by.css('textarea'));
inp.sendKeys(text);
}

protected clickButton(sel: string) {
const el = element(by.css(`${this.tag} ${sel}`));
browser.wait(ExpectedConditions.elementToBeClickable(el));
el.click();
}
}
每页抽象

应用程序中的每个页面都有自己的页面对象类,该类抽象化该页面上的元素。如果使用基本页面对象类,则创建页面对象主要涉及为特定于该页面的元素创建自定义方法。通常,这些自定义元素会利用基类中的方法来执行所需的工作。

这是一个用于简单但典型的登录页面的页面对象示例。请注意,许多方法(例如 enterEMail())调用基类中的方法来执行大部分工作。

import { browser, by, element, ExpectedConditions } from 'protractor';
import { PageObjectBase } from './base.po';

export class LoginPage extends PageObjectBase {
constructor() {
super('app-login', '/login');
}

waitForError() {
browser.wait(ExpectedConditions.presenceOf(element(by.css('.error'))), 3000);
}

getErrorMessage() {
return element(by.css('.error')).getText();
}

enterEMail(email: string) {
this.enterInputText('#email-input', email);
}

enterPassword(password: string) {
this.enterInputText('#password-input', password);
}

clickSignIn() {
this.clickButton('#signin-button');
}
}

测试脚本

与单元测试类似,端到端测试脚本由嵌套的 describe()it() 函数组成。对于端到端测试,describe() 函数通常表示特定场景,it() 函数表示在该场景中执行操作时应用程序应表现出的特定行为。

与单元测试类似,describe()it() 函数中使用的标签在与“describe”或“it”一起使用以及串联起来构成完整的测试用例时都应该有意义。

这是一个示例端到端测试脚本,它练习了一些典型的登录场景。

import { AppPage } from '../page-objects/pages/app.po';
import { AboutPage } from '../page-objects/pages/about.po';
import { CustomersPage } from '../page-objects/pages/customers.po';
import { LoginPage } from '../page-objects/pages/login.po';
import { MenuPage } from '../page-objects/pages/menu.po';
import { TasksPage } from '../page-objects/pages/tasks.po';

describe('Login', () => {
const about = new AboutPage();
const app = new AppPage();
const customers = new CustomersPage();
const login = new LoginPage();
const menu = new MenuPage();
const tasks = new TasksPage();

beforeEach(() => {
app.load();
});

describe('before logged in', () => {
it('displays the login screen', () => {
expect(login.rootElement().isDisplayed()).toEqual(true);
});

it('allows in-app navigation to about', () => {
menu.clickAbout();
about.waitUntilVisible();
login.waitUntilInvisible();
});

it('does not allow in-app navigation to tasks', () => {
menu.clickTasks();
app.waitForPageNavigation();
expect(login.rootElement().isDisplayed()).toEqual(true);
});

it('does not allow in-app navigation to customers', () => {
menu.clickCustomers();
app.waitForPageNavigation();
expect(login.rootElement().isDisplayed()).toEqual(true);
});

it('displays an error message if the login fails', () => {
login.enterEMail('[email protected]');
login.enterPassword('bogus');
login.clickSignIn();
login.waitForError();
expect(login.getErrorMessage()).toEqual('The password is invalid or the user does not have a password.');
});

it('navigates to the tasks page if the login succeeds', () => {
login.enterEMail('[email protected]');
login.enterPassword('testtest');
login.clickSignIn();
tasks.waitUntilVisible();
});
});

describe('once logged in', () => {
beforeEach(() => {
tasks.waitUntilVisible();
});

it('allows navigation to the customers page', () => {
menu.clickCustomers();
customers.waitUntilVisible();
tasks.waitUntilInvisible();
});

it('allows navigation to the about page', () => {
menu.clickAbout();
about.waitUntilVisible();
tasks.waitUntilInvisible();
});

it('allows navigation back to the tasks page', () => {
menu.clickAbout();
tasks.waitUntilInvisible();
menu.clickTasks();
tasks.waitUntilVisible();
});
});
});

配置

默认配置使用与开发相同的 environment.ts 文件。为了更好地控制端到端测试使用的数据,通常为测试创建一个特定的环境,并为测试使用该环境。本节显示了一种创建此配置的可能方法。

测试环境

设置测试环境涉及创建使用专用测试后端的新环境文件,更新 angular.json 文件以使用该环境,并修改 package.json 中的 e2e 脚本以指定 test 环境。

创建 environment.e2e.ts 文件

Angular environment.tsenvironment.prod.ts 文件通常用于存储信息,例如应用程序后端数据服务的基 URL。创建一个 environment.e2e.ts,它提供相同的信息,只是连接到专门用于测试的后端服务,而不是开发或生产后端服务。以下是一个示例

export const environment = {
production: false,
databaseURL: 'https://e2e-test-api.my-great-app.com',
projectId: 'my-great-app-e2e',
};
修改 angular.json 文件

需要修改 angular.json 文件以使用此文件。这是一个分层过程。按照下面列出的 XPaths 添加所需的配置。

/projects/app/architect/build/configurations 中添加一个名为 test 的配置,它执行文件替换

"test": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.e2e.ts"
}
]
}

/projects/app/architect/serve/configurations 中添加一个名为 test 的配置,它将浏览器目标指向上面定义的 test 构建配置。

"test": {
"browserTarget": "app:build:test"
}

/projects/app-e2e/architect/e2e/configurations 中添加一个名为 test 的配置,它将 dev server 目标指向上面定义的 test serve 配置。

"test": {
"devServerTarget": "app:serve:test"
}
修改 package.json 文件

修改 package.json 文件,以便 npm run e2e 使用 test 配置。

"scripts": {
"e2e": "ng e2e --configuration=test",
"lint": "ng lint",
"ng": "ng",
"start": "ng serve",
"test": "ng test",
"test:dev": "ng test --browsers=ChromeHeadlessCI",
"test:ci": "ng test --no-watch --browsers=ChromeHeadlessCI"
},

测试清理

如果端到端测试以任何方式修改数据,那么在测试完成后将数据重置为已知状态将很有帮助。一种方法是

  1. 创建一个执行清理的端点。
  2. onCleanUp() 函数添加到 protractor.conf.js 文件导出的 config 对象中。

以下是一个示例

onCleanUp() {
const axios = require('axios');
return axios
.post(
'https://e2e-test-api.my-great-app.com/purgeDatabase',
{}
)
.then(res => {
console.log(res.data);
})
.catch(err => console.log(err));
}