測試
當使用 Ionic CLI 產生 @ionic/angular
應用程式時,會自動設定應用程式的單元測試和端對端測試。這與 Angular CLI 使用的設定相同。有關測試 Angular 應用程式的詳細資訊,請參閱Angular 測試指南。
測試原則
在測試應用程式時,最好記住測試可以顯示系統中是否存在缺陷。但是,不可能證明任何非平凡的系統完全沒有缺陷。因此,測試的目標不是驗證程式碼是否正確,而是找出程式碼中的問題。這是一個微妙但重要的區別。
如果我們著手證明程式碼是正確的,我們更有可能堅持程式碼中的「順利路徑」。如果我們著手尋找問題,我們更有可能更全面地執行程式碼,並找到潛藏在那裡的錯誤。
最好也從一開始就開始測試應用程式。這允許在流程早期發現缺陷,而此時更容易修復。這也允許在將新功能新增至系統時,放心地重構程式碼。
單元測試
單元測試會隔離執行單個程式碼單元(元件、頁面、服務、管道等),使其與系統的其餘部分分離。隔離是透過注入模擬物件以取代程式碼的相依性來實現的。模擬物件允許測試對相依性的輸出進行細粒度控制。模擬也允許測試判斷已呼叫哪些相依性以及已傳遞給它們的內容。
撰寫良好的單元測試的結構是,程式碼單元及其包含的功能透過 describe()
回呼來描述。程式碼單元及其功能的要求透過 it()
回呼來測試。當讀取 describe()
和 it()
回呼的描述時,它們作為一個短語是有意義的。當將巢狀 describe()
和最後的 it()
的描述串連在一起時,它們會形成一個完全描述測試案例的句子。
由於單元測試會隔離執行程式碼,因此它們速度快、穩健,並允許高度的程式碼覆蓋率。
使用模擬
單元測試會隔離執行程式碼模組。為了促進這一點,我們建議使用 Jasmine (https://jasmine.dev.org.tw/)。Jasmine 建立模擬物件(Jasmine 稱之為「間諜」)來取代測試期間的相依性。當使用模擬物件時,測試可以控制對該相依性的呼叫傳回的值,使目前的測試獨立於對相依性所做的變更。這也使測試設定更容易,允許測試僅關注正在測試的模組中的程式碼。
使用模擬也允許測試查詢模擬,以判斷是否已呼叫它以及如何透過 toHaveBeenCalled*
函數集呼叫它。測試應盡可能使用這些函數,在測試是否已呼叫方法時,傾向於呼叫 toHaveBeenCalledTimes
而不是呼叫 toHaveBeenCalled
。也就是說,expect(mock.foo).toHaveBeenCalledTimes(1)
比 expect(mock.foo).toHaveBeenCalled()
更好。當測試是否沒有呼叫某個內容時,應遵循相反的建議 (expect(mock.foo).not.toHaveBeenCalled()
)。
在 Jasmine 中建立模擬物件有兩種常見方法。可以使用 jasmine.createSpy
和 jasmine.createSpyObj
從頭開始建構模擬物件,或使用 spyOn()
和 spyOnProperty()
將間諜安裝到現有的物件上。
使用 jasmine.createSpy
和 jasmine.createSpyObj
jasmine.createSpyObj
從頭開始建立完整的模擬物件,並在建立時定義一組模擬方法。這很有用,因為它非常簡單。無需建構或將任何內容注入到測試中。使用此函數的缺點是,它允許建立可能與真實物件不符的物件。
jasmine.createSpy
類似,但它會建立獨立的模擬函數。
使用 spyOn()
和 spyOnProperty()
spyOn()
將間諜安裝到現有的物件上。使用此技術的優點是,如果嘗試對物件上不存在的方法進行間諜活動,則會引發例外。這可以防止測試模擬不存在的方法。缺點是測試需要從一開始就完全形成的物件,這可能會增加所需的測試設定量。
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('Calculation', () => {
describe('divide', () => {
it('calculates 4 / 2 properly' () => {});
it('cowardly refuses to divide by zero' () => {});
...
});
describe('multiply', () => {
...
});
});
外部 describe
呼叫聲明正在測試 Calculation
服務,內部 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('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
物件並格式化名稱的管道。為了簡單起見,假設 Person
由 id
、firstName
、lastName
和 middleInitial
組成。管道的要求是將名稱列印為「姓氏, 名稱 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('test@test.com');
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('test@test.com');
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.ts
和 environment.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
檔案才能使用此檔案。這是一個分層的過程。請依照下面列出的 XPath 來新增所需的設定。
在 /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
的設定,該設定會將開發伺服器目標指向上面定義的 test
伺服器設定。
"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"
},
測試清理
如果端對端測試以任何方式修改資料,在測試完成後將資料重置為已知狀態會很有幫助。其中一種方法是
- 建立一個執行清理的端點。
- 在
protractor.conf.js
檔案匯出的config
物件中新增一個onCleanUp()
函式。
以下是一個範例
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));
}