本指南探討了一些常見的組件測(cè)試用例。
如果你要試驗(yàn)本指南中所講的應(yīng)用,請(qǐng)在瀏覽器中運(yùn)行它或下載并在本地運(yùn)行它。
在范例應(yīng)用中,?BannerComponent
?在 HTML 模板中展示了靜態(tài)的標(biāo)題文本。
在少許更改之后,?BannerComponent
?就會(huì)通過綁定組件的 ?title
?屬性來(lái)渲染動(dòng)態(tài)標(biāo)題。
@Component({
selector: 'app-banner',
template: '<h1>{{title}}</h1>',
styles: ['h1 { color: green; font-size: 350%}']
})
export class BannerComponent {
title = 'Test Tour of Heroes';
}
盡管這很小,但你還是決定要添加一個(gè)測(cè)試來(lái)確認(rèn)該組件實(shí)際顯示的是你認(rèn)為合適的內(nèi)容。
你將編寫一系列測(cè)試來(lái)檢查 ?<h1>
? 元素中包裹的 title 屬性插值綁定。
你可以修改 ?beforeEach
?以找到帶有標(biāo)準(zhǔn) HTML ?querySelector
?的元素,并把它賦值給 ?h1
?變量。
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
h1 = fixture.nativeElement.querySelector('h1');
});
對(duì)于你的第一個(gè)測(cè)試,你希望屏幕上顯示默認(rèn)的 ?title
?。你的直覺就是編寫一個(gè)能立即檢查 ?<h1>
? 的測(cè)試,就像這樣:
it('should display original title', () => {
expect(h1.textContent).toContain(component.title);
});
那個(gè)測(cè)試失敗了:
expected '' to contain 'Test Tour of Heroes'.
當(dāng) Angular 執(zhí)行變更檢測(cè)時(shí)就會(huì)發(fā)生綁定。
在生產(chǎn)環(huán)境中,當(dāng) Angular 創(chuàng)建一個(gè)組件,或者用戶輸入按鍵,或者異步活動(dòng)(比如 AJAX)完成時(shí),就會(huì)自動(dòng)進(jìn)行變更檢測(cè)。
該 ?TestBed.createComponent
? 不會(huì)觸發(fā)變化檢測(cè),修改后的測(cè)試可以證實(shí)這一點(diǎn):
it('no title in the DOM after createComponent()', () => {
expect(h1.textContent).toEqual('');
});
你必須通過調(diào)用 ?fixture.detectChanges()
? 來(lái)告訴 ?TestBed
?執(zhí)行數(shù)據(jù)綁定。只有這樣,?<h1>
? 才能擁有預(yù)期的標(biāo)題。
it('should display original title after detectChanges()', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
這里延遲變更檢測(cè)時(shí)機(jī)是故意而且有用的。這樣才能讓測(cè)試者在 Angular 啟動(dòng)數(shù)據(jù)綁定并調(diào)用生命周期鉤子之前,查看并更改組件的狀態(tài)。
這是另一個(gè)測(cè)試,它會(huì)在調(diào)用 ?fixture.detectChanges()
? 之前改變組件的 ?title
?屬性。
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});
?BannerComponent
?測(cè)試會(huì)經(jīng)常調(diào)用 ?detectChanges
?。一些測(cè)試人員更喜歡讓 Angular 測(cè)試環(huán)境自動(dòng)運(yùn)行變更檢測(cè)。
可以通過配置帶有 ?ComponentFixtureAutoDetect
?提供者的 ?TestBed
?來(lái)實(shí)現(xiàn)這一點(diǎn)。我們首先從測(cè)試工具函數(shù)庫(kù)中導(dǎo)入它:
import { ComponentFixtureAutoDetect } from '@angular/core/testing';
然后把它添加到測(cè)試模塊配置的 ?providers
?中:
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: true }
]
});
這里有三個(gè)測(cè)試來(lái)說明自動(dòng)變更檢測(cè)是如何工作的。
it('should display original title', () => {
// Hooray! No `fixture.detectChanges()` needed
expect(h1.textContent).toContain(comp.title);
});
it('should still see original title after comp.title change', () => {
const oldTitle = comp.title;
comp.title = 'Test Title';
// Displayed title is old because Angular didn't hear the change :(
expect(h1.textContent).toContain(oldTitle);
});
it('should display updated title after detectChanges', () => {
comp.title = 'Test Title';
fixture.detectChanges(); // detect changes explicitly
expect(h1.textContent).toContain(comp.title);
});
第一個(gè)測(cè)試顯示了自動(dòng)變更檢測(cè)的優(yōu)點(diǎn)。
第二個(gè)和第三個(gè)測(cè)試則揭示了一個(gè)重要的限制。該 Angular 測(cè)試環(huán)境不知道測(cè)試改變了組件的 ?title
?。?ComponentFixtureAutoDetect
?服務(wù)會(huì)響應(yīng)異步活動(dòng),比如 Promise、定時(shí)器和 DOM 事件。但卻看不見對(duì)組件屬性的直接同步更新。該測(cè)試必須用 ?fixture.detectChanges()
? 來(lái)觸發(fā)另一個(gè)變更檢測(cè)周期。
本指南中的范例總是會(huì)顯式調(diào)用 ?
detectChanges()
?,而不用困惑于測(cè)試夾具何時(shí)會(huì)或不會(huì)執(zhí)行變更檢測(cè)。更頻繁的調(diào)用 ?detectChanges()
? 毫無(wú)危害,沒必要只在非常必要時(shí)才調(diào)用它。
要模擬用戶輸入,你可以找到 input 元素并設(shè)置它的 ?value
?屬性。
你會(huì)調(diào)用 ?fixture.detectChanges()
? 來(lái)觸發(fā) Angular 的變更檢測(cè)。但還有一個(gè)重要的中間步驟。
Angular 并不知道你為 input 設(shè)置過 ?value
?屬性。在通過調(diào)用 ?dispatchEvent()
? 分發(fā) ?input
?事件之前,它不會(huì)讀取該屬性。緊接著你就調(diào)用了 ?detectChanges()
?。
下列例子說明了正確的順序。
it('should convert hero name to Title Case', () => {
// get the name's input and display elements from the DOM
const hostElement: HTMLElement = fixture.nativeElement;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// Dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(new Event('input'));
// Tell Angular to update the display binding through the title pipe
fixture.detectChanges();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
上面的 ?BannerComponent
?是用內(nèi)聯(lián)模板和內(nèi)聯(lián) css 定義的,它們分別是在 ?@Component.template
? 和 ?@Component.styles
? 屬性中指定的。
很多組件都會(huì)分別用 ?@Component.templateUrl
? 和 ?@Component.styleUrls
? 屬性來(lái)指定外部模板和外部 css,就像下面的 ?BannerComponent
?變體一樣。
@Component({
selector: 'app-banner',
templateUrl: './banner-external.component.html',
styleUrls: ['./banner-external.component.css']
})
這個(gè)語(yǔ)法告訴 Angular 編譯器要在組件編譯時(shí)讀取外部文件。
當(dāng)運(yùn)行 ?ng test
? 命令時(shí),這不是問題,因?yàn)樗鼤?huì)在運(yùn)行測(cè)試之前編譯應(yīng)用。
但是,如果在非 CLI 環(huán)境中運(yùn)行這些測(cè)試,那么這個(gè)組件的測(cè)試可能會(huì)失敗。比如,如果你在一個(gè) web 編程環(huán)境(比如 plunker 中運(yùn)行 ?BannerComponent
?測(cè)試,你會(huì)看到如下消息:
Error: This test module uses the component BannerComponent
which is using a "templateUrl" or "styleUrls", but they were never compiled.
Please call "TestBed.compileComponents" before your test.
當(dāng)運(yùn)行環(huán)境在測(cè)試過程中需要編譯源代碼時(shí),就會(huì)得到這條測(cè)試失敗的消息。
要解決這個(gè)問題,可以像下面 調(diào)用 compileComponents 小節(jié)中講的那樣調(diào)用 ?compileComponents()
?。
組件通常都有服務(wù)依賴。
?WelcomeComponent
?會(huì)向登錄用戶顯示一條歡迎信息。它可以基于注入進(jìn)來(lái)的 ?UserService
?的一個(gè)屬性了解到用戶是誰(shuí):
import { Component, OnInit } from '@angular/core';
import { UserService } from '../model/user.service';
@Component({
selector: 'app-welcome',
template: '<h3 class="welcome"><i>{{welcome}}</i></h3>'
})
export class WelcomeComponent implements OnInit {
welcome = '';
constructor(private userService: UserService) { }
ngOnInit(): void {
this.welcome = this.userService.isLoggedIn ?
'Welcome, ' + this.userService.user.name : 'Please log in.';
}
}
?WelcomeComponent
?擁有與該服務(wù)交互的決策邏輯,該邏輯讓這個(gè)組件值得測(cè)試。這是 spec 文件的測(cè)試模塊配置:
TestBed.configureTestingModule({
declarations: [ WelcomeComponent ],
// providers: [ UserService ], // NO! Don't provide the real service!
// Provide a test-double instead
providers: [ { provide: UserService, useValue: userServiceStub } ],
});
這次,除了聲明被測(cè)組件外,該配置還在 ?providers
?列表中加入了 ?UserService
?提供者。但它不是真正的 ?UserService
?。
待測(cè)組件不必注入真正的服務(wù)。事實(shí)上,如果它們是測(cè)試替身(stubs,fakes,spies 或 mocks),通常會(huì)更好。該測(cè)試規(guī)約的目的是測(cè)試組件,而不是服務(wù),使用真正的服務(wù)可能會(huì)遇到麻煩。
注入真正的 ?UserService
?可能是個(gè)噩夢(mèng)。真正的服務(wù)可能要求用戶提供登錄憑據(jù),并嘗試訪問認(rèn)證服務(wù)器。這些行為可能難以攔截。為它創(chuàng)建并注冊(cè)一個(gè)測(cè)試專用版來(lái)代替真正的 ?UserService
?要容易得多,也更安全。
這個(gè)特定的測(cè)試套件提供了 ?UserService
?的最小化模擬,它滿足了 ?WelcomeComponent
?及其測(cè)試的需求:
let userServiceStub: Partial<UserService>;
userServiceStub = {
isLoggedIn: true,
user: { name: 'Test User' },
};
這些測(cè)試需要訪問注入到 ?WelcomeComponent
?中的 ?UserService
?樁。
Angular 有一個(gè)分層注入系統(tǒng)。它具有多個(gè)層級(jí)的注入器,從 ?TestBed
?創(chuàng)建的根注入器開始,直到組件樹中的各個(gè)層級(jí)。
獲得注入服務(wù)的最安全的方式(始終有效),就是從被測(cè)組件的注入器中獲取它。組件注入器是測(cè)試夾具所提供的 ?DebugElement
?中的一個(gè)屬性。
// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);
你可能還可以通過 ?TestBed.inject()
? 來(lái)從根注入器獲得服務(wù)。這更容易記憶,也不那么啰嗦。但這只有當(dāng) Angular 要把根注入器中的服務(wù)實(shí)例注入測(cè)試組件時(shí)才是可行的。
在下面這個(gè)測(cè)試套件中,?UserService
?唯一的提供者是根測(cè)試模塊,因此可以安全地調(diào)用 ?TestBed.inject()
?,如下所示:
// UserService from the root injector
userService = TestBed.inject(UserService);
這里是完成的 ?beforeEach()
?,它使用了 ?TestBed.inject()
? :
let userServiceStub: Partial<UserService>;
beforeEach(() => {
// stub UserService for test purposes
userServiceStub = {
isLoggedIn: true,
user: { name: 'Test User' },
};
TestBed.configureTestingModule({
declarations: [ WelcomeComponent ],
providers: [ { provide: UserService, useValue: userServiceStub } ],
});
fixture = TestBed.createComponent(WelcomeComponent);
comp = fixture.componentInstance;
// UserService from the root injector
userService = TestBed.inject(UserService);
// get the "welcome" element by CSS selector (e.g., by class name)
el = fixture.nativeElement.querySelector('.welcome');
});
以下是一些測(cè)試:
it('should welcome the user', () => {
fixture.detectChanges();
const content = el.textContent;
expect(content)
.withContext('"Welcome ..."')
.toContain('Welcome');
expect(content)
.withContext('expected name')
.toContain('Test User');
});
it('should welcome "Bubba"', () => {
userService.user.name = 'Bubba'; // welcome message hasn't been shown yet
fixture.detectChanges();
expect(el.textContent).toContain('Bubba');
});
it('should request login if not logged in', () => {
userService.isLoggedIn = false; // welcome message hasn't been shown yet
fixture.detectChanges();
const content = el.textContent;
expect(content)
.withContext('not welcomed')
.not.toContain('Welcome');
expect(content)
.withContext('"log in"')
.toMatch(/log in/i);
});
首先是一個(gè)健全性測(cè)試;它確認(rèn)了樁服務(wù) ?UserService
?被調(diào)用過并能正常工作。
Jasmine 匹配器的第二個(gè)參數(shù)(比如 ?'expected name'
?)是一個(gè)可選的失敗標(biāo)簽。如果此期望失敗,Jasmine 就會(huì)把這個(gè)標(biāo)簽貼到期望失敗的消息中。在具有多個(gè)期望的測(cè)試規(guī)約中,它可以幫我們澄清出現(xiàn)了什么問題以及都有哪些期望失敗了。
當(dāng)該服務(wù)返回不同的值時(shí),其余的測(cè)試會(huì)確認(rèn)該組件的邏輯。第二個(gè)測(cè)試驗(yàn)證了更改用戶名的效果。當(dāng)用戶未登錄時(shí),第三個(gè)測(cè)試會(huì)檢查組件是否顯示了正確的消息。
在這個(gè)例子中,?AboutComponent
?模板托管了一個(gè) ?TwainComponent
?。?TwainComponent
?會(huì)顯示馬克·吐溫的名言。
template: `
<p class="twain"><i>{{quote | async}}</i></p>
<button type="button" (click)="getQuote()">Next quote</button>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>`,
注意:
組件的 ?quote
?屬性的值通過 ?AsyncPipe
?傳遞。這意味著該屬性會(huì)返回 ?Promise
?或 ?Observable
?。
在這個(gè)例子中,?TwainComponent.getQuote()
? 方法告訴你 ?quote
?屬性會(huì)返回一個(gè) ?Observable
?。
getQuote() {
this.errorMessage = '';
this.quote = this.twainService.getQuote().pipe(
startWith('...'),
catchError( (err: any) => {
// Wait a turn because errorMessage already set once this turn
setTimeout(() => this.errorMessage = err.message || err.toString());
return of('...'); // reset message to placeholder
})
);
該 ?TwainComponent
?從注入的 ?TwainService
?中獲取名言。該在服務(wù)能返回第一條名言之前,該服務(wù)會(huì)先返回一個(gè)占位流(?'...'
?)。
?catchError
?會(huì)攔截服務(wù)錯(cuò)誤,準(zhǔn)備一條錯(cuò)誤信息,并在流的成功通道上返回占位值。它必須等一拍(tick)才能設(shè)置 ?errorMessage
?,以免在同一個(gè)變更檢測(cè)周期內(nèi)更新此消息兩次。
這些都是你想要測(cè)試的特性。
在測(cè)試組件時(shí),只有該服務(wù)的公開 API 才有意義。通常,測(cè)試本身不應(yīng)該調(diào)用遠(yuǎn)程服務(wù)器。它們應(yīng)該模擬這樣的調(diào)用。這個(gè) ?app/twain/twain.component.spec.ts
? 中的環(huán)境準(zhǔn)備工作展示了一種方法:
beforeEach(() => {
testQuote = 'Test Quote';
// Create a fake TwainService object with a `getQuote()` spy
const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = twainService.getQuote.and.returnValue(of(testQuote));
TestBed.configureTestingModule({
declarations: [TwainComponent],
providers: [{provide: TwainService, useValue: twainService}]
});
fixture = TestBed.createComponent(TwainComponent);
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
仔細(xì)看一下這個(gè)間諜。
// Create a fake TwainService object with a `getQuote()` spy
const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = twainService.getQuote.and.returnValue(of(testQuote));
這個(gè)間諜的設(shè)計(jì)目標(biāo)是讓所有對(duì) ?getQuote
?的調(diào)用都會(huì)收到一個(gè)帶有測(cè)試名言的可觀察對(duì)象。與真正的 ?getQuote()
? 方法不同,這個(gè)間諜會(huì)繞過服務(wù)器,并返回一個(gè)立即同步提供可用值的可觀察對(duì)象。
雖然這個(gè) ?Observable
?是同步的,但你也可以用這個(gè)間諜編寫很多有用的測(cè)試。
同步 ?Observable
?的一個(gè)關(guān)鍵優(yōu)勢(shì)是,你通??梢园旬惒竭^程轉(zhuǎn)換成同步測(cè)試。
it('should show quote after component initialized', () => {
fixture.detectChanges(); // onInit()
// sync spy result shows testQuote immediately after init
expect(quoteEl.textContent).toBe(testQuote);
expect(getQuoteSpy.calls.any())
.withContext('getQuote called')
.toBe(true);
});
當(dāng)間諜的結(jié)果同步返回時(shí),?getQuote()
? 方法會(huì)在第一個(gè)變更檢測(cè)周期(Angular 在這里調(diào)用 ?ngOnInit
?)后立即更新屏幕上的消息。
你在測(cè)試錯(cuò)誤路徑時(shí)就沒有這么幸運(yùn)了。雖然服務(wù)間諜會(huì)同步返回一個(gè)錯(cuò)誤,但該組件方法會(huì)調(diào)用 ?setTimeout()
?。在值可用之前,測(cè)試必須等待 JavaScript 引擎的至少一個(gè)周期。因此,該測(cè)試必須是異步的。
要使用 ?fakeAsync()
? 功能,你必須在測(cè)試的環(huán)境設(shè)置文件中導(dǎo)入 ?zone.js/testing
?。如果是用 Angular CLI 創(chuàng)建的項(xiàng)目,那么其 ?src/test.ts
? 中已經(jīng)配置好了 ?zone-testing
?。
當(dāng)該服務(wù)返回 ?ErrorObservable
?時(shí),下列測(cè)試會(huì)對(duì)其預(yù)期行為進(jìn)行確認(rèn)。
it('should display error when TwainService fails', fakeAsync(() => {
// tell spy to return an error observable
getQuoteSpy.and.returnValue(throwError(() => new Error('TwainService test failure')));
fixture.detectChanges(); // onInit()
// sync spy errors immediately after init
tick(); // flush the component's setTimeout()
fixture.detectChanges(); // update errorMessage within setTimeout()
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/, );
expect(quoteEl.textContent)
.withContext('should show placeholder')
.toBe('...');
}));
注意:
?it()
? 函數(shù)會(huì)接收以下形式的參數(shù)。
fakeAsync(() => { /* test body */ })
通過在一個(gè)特殊的 ?fakeAsync test zone
?(譯注:Zone.js 的一個(gè)特例)中運(yùn)行測(cè)試體,?fakeAsync()
? 函數(shù)可以啟用線性編碼風(fēng)格。這個(gè)測(cè)試體看上去是同步的。沒有像 ?Promise.then()
? 這樣的嵌套語(yǔ)法來(lái)破壞控制流。
限制:如果測(cè)試體要進(jìn)行 ?
XMLHttpRequest
?(XHR)調(diào)用,則 ?fakeAsync()
? 函數(shù)無(wú)效。
你必須調(diào)用 ?tick()
? 來(lái)推進(jìn)(虛擬)時(shí)鐘。
調(diào)用 ?tick()
? 時(shí)會(huì)在所有掛起的異步活動(dòng)完成之前模擬時(shí)間的流逝。在這種情況下,它會(huì)等待錯(cuò)誤處理程序中的 ?setTimeout()
?。
?tick()
? 函數(shù)接受毫秒數(shù)(milliseconds) 和 tick 選項(xiàng)(tickOptions) 作為參數(shù),毫秒數(shù)(默認(rèn)值為 0)參數(shù)表示虛擬時(shí)鐘要前進(jìn)多少。比如,如果你在 ?fakeAsync()
? 測(cè)試中有一個(gè) ?setTimeout(fn, 100)
?,你就需要使用 ?tick(100)
? 來(lái)觸發(fā)其 fn 回調(diào)。tickOptions 是一個(gè)可選參數(shù),它帶有一個(gè)名為 ?processNewMacroTasksSynchronously
?的屬性(默認(rèn)為 true),表示在 tick 時(shí)是否要調(diào)用新生成的宏任務(wù)。
it('should run timeout callback with delay after call tick with millis', fakeAsync(() => {
let called = false;
setTimeout(() => {
called = true;
}, 100);
tick(100);
expect(called).toBe(true);
}));
?tick()
? 函數(shù)是你用 ?TestBed
?導(dǎo)入的 Angular 測(cè)試工具函數(shù)之一。它是 ?fakeAsync()
? 的伴生工具,你只能在 ?fakeAsync()
? 測(cè)試體內(nèi)調(diào)用它。
it('should run new macro task callback with delay after call tick with millis',
fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0);
// the nested timeout will also be triggered
expect(callback).toHaveBeenCalled();
}));
在這個(gè)例子中,我們有一個(gè)新的宏任務(wù)(嵌套的 setTimeout),默認(rèn)情況下,當(dāng) ?tick
?時(shí),setTimeout 的 ?outside
?和 ?nested
?都會(huì)被觸發(fā)。
it('should not run new macro task callback with delay after call tick with millis',
fakeAsync(() => {
function nestedTimer(cb: () => any): void {
setTimeout(() => setTimeout(() => cb()));
}
const callback = jasmine.createSpy('callback');
nestedTimer(callback);
expect(callback).not.toHaveBeenCalled();
tick(0, {processNewMacroTasksSynchronously: false});
// the nested timeout will not be triggered
expect(callback).not.toHaveBeenCalled();
tick(0);
expect(callback).toHaveBeenCalled();
}));
在某種情況下,你不希望在 tick 時(shí)觸發(fā)新的宏任務(wù),就可以使用 ?tick(milliseconds, {processNewMacroTasksSynchronously: false})
? 來(lái)要求不調(diào)用新的宏任務(wù)。
?fakeAsync()
? 可以模擬時(shí)間的流逝,以便讓你計(jì)算出 ?fakeAsync()
? 里面的日期差。
it('should get Date diff correctly in fakeAsync', fakeAsync(() => {
const start = Date.now();
tick(100);
const end = Date.now();
expect(end - start).toBe(100);
}));
Jasmine 還為模擬日期提供了 ?clock
?特性。而 Angular 會(huì)在 ?jasmine.clock().install()
? 于 ?fakeAsync()
? 方法內(nèi)調(diào)用時(shí)自動(dòng)運(yùn)行這些測(cè)試。直到調(diào)用了 ?jasmine.clock().uninstall()
? 為止。?fakeAsync()
? 不是必須的,如果嵌套它就拋出錯(cuò)誤。
默認(rèn)情況下,此功能處于禁用狀態(tài)。要啟用它,請(qǐng)?jiān)趯?dǎo)入 ?zone-testing
? 之前先設(shè)置全局標(biāo)志。
如果你使用的是 Angular CLI,請(qǐng)?jiān)?nbsp;?src/test.ts
? 中配置這個(gè)標(biāo)志。
(window as any)['__zone_symbol__fakeAsyncPatchLock'] = true;
import 'zone.js/testing';
describe('use jasmine.clock()', () => {
// need to config __zone_symbol__fakeAsyncPatchLock flag
// before loading zone.js/testing
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should auto enter fakeAsync', () => {
// is in fakeAsync now, don't need to call fakeAsync(testFn)
let called = false;
setTimeout(() => {
called = true;
}, 100);
jasmine.clock().tick(100);
expect(called).toBe(true);
});
});
?fakeAsync()
? 使用 RxJS 的調(diào)度器,就像使用 ?setTimeout()
? 或 ?setInterval()
? 一樣,但你需要導(dǎo)入 ?zone.js/plugins/zone-patch-rxjs-fake-async
? 來(lái)給 RxJS 調(diào)度器打補(bǔ)丁。
it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => {
// need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async'
// to patch rxjs scheduler
let result = '';
of('hello').pipe(delay(1000)).subscribe(v => {
result = v;
});
expect(result).toBe('');
tick(1000);
expect(result).toBe('hello');
const start = new Date().getTime();
let dateDiff = 0;
interval(1000).pipe(take(2)).subscribe(() => dateDiff = (new Date().getTime() - start));
tick(1000);
expect(dateDiff).toBe(1000);
tick(1000);
expect(dateDiff).toBe(2000);
}));
?fakeAsync()
? 默認(rèn)支持以下宏任務(wù)。
setTimeout
?setInterval
?requestAnimationFrame
?webkitRequestAnimationFrame
?mozRequestAnimationFrame
?如果你運(yùn)行其他宏任務(wù),比如 ?HTMLCanvasElement.toBlob()
?,就會(huì)拋出 "Unknown macroTask scheduled in fake async test" 錯(cuò)誤。
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { CanvasComponent } from './canvas.component';
describe('CanvasComponent', () => {
beforeEach(async () => {
await TestBed
.configureTestingModule({
declarations: [CanvasComponent],
})
.compileComponents();
});
it('should be able to generate blob data from canvas', fakeAsync(() => {
const fixture = TestBed.createComponent(CanvasComponent);
const canvasComp = fixture.componentInstance;
fixture.detectChanges();
expect(canvasComp.blobSize).toBe(0);
tick();
expect(canvasComp.blobSize).toBeGreaterThan(0);
}));
});
import { Component, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
@Component({
selector: 'sample-canvas',
template: '<canvas #sampleCanvas width="200" height="200"></canvas>',
})
export class CanvasComponent implements AfterViewInit {
blobSize = 0;
@ViewChild('sampleCanvas') sampleCanvas!: ElementRef;
ngAfterViewInit() {
const canvas: HTMLCanvasElement = this.sampleCanvas.nativeElement;
const context = canvas.getContext('2d')!;
context.clearRect(0, 0, 200, 200);
context.fillStyle = '#FF1122';
context.fillRect(0, 0, 200, 200);
canvas.toBlob(blob => {
this.blobSize = blob?.size ?? 0;
});
}
}
如果你想支持這種情況,就要在 ?beforeEach()
? 定義你要支持的宏任務(wù)。比如:
beforeEach(() => {
(window as any).__zone_symbol__FakeAsyncTestMacroTask = [
{
source: 'HTMLCanvasElement.toBlob',
callbackArgs: [{size: 200}],
},
];
});
注意:
要在依賴 Zone.js 的應(yīng)用中使用 ?<canvas>
? 元素,你需要導(dǎo)入 ?zone-patch-canvas
? 補(bǔ)丁(或者在 ?polyfills.ts
? 中,或者在用到 ?<canvas>
? 的那個(gè)文件中):
// Import patch to make async `HTMLCanvasElement` methods (such as `.toBlob()`) Zone.js-aware.
// Either import in `polyfills.ts` (if used in more than one places in the app) or in the component
// file using `HTMLCanvasElement` (if it is only used in a single file).
import 'zone.js/plugins/zone-patch-canvas';
你可能已經(jīng)對(duì)前面這些測(cè)試的測(cè)試覆蓋率感到滿意。
但是,你可能也會(huì)為另一個(gè)事實(shí)感到不安:真實(shí)的服務(wù)并不是這樣工作的。真實(shí)的服務(wù)會(huì)向遠(yuǎn)程服務(wù)器發(fā)送請(qǐng)求。服務(wù)器需要一定的時(shí)間才能做出響應(yīng),并且其響應(yīng)體肯定不會(huì)像前面兩個(gè)測(cè)試中一樣是立即可用的。
如果能像下面這樣從 ?getQuote()
? 間諜中返回一個(gè)異步的可觀察對(duì)象,你的測(cè)試就會(huì)更真實(shí)地反映現(xiàn)實(shí)世界。
// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));
異步可觀察對(duì)象可以由測(cè)試助手 ?asyncData
?生成。測(cè)試助手 ?asyncData
?是一個(gè)你必須自行編寫的工具函數(shù),當(dāng)然也可以從下面的范例代碼中復(fù)制它。
/**
* Create async observable that emits-once and completes
* after a JS engine turn
*/
export function asyncData<T>(data: T) {
return defer(() => Promise.resolve(data));
}
這個(gè)助手返回的可觀察對(duì)象會(huì)在 JavaScript 引擎的下一個(gè)周期中發(fā)送 ?data
?值。
RxJS 的defer()操作符返回一個(gè)可觀察對(duì)象。它的參數(shù)是一個(gè)返回 Promise 或可觀察對(duì)象的工廠函數(shù)。當(dāng)某個(gè)訂閱者訂閱 defer 生成的可觀察對(duì)象時(shí),defer 就會(huì)調(diào)用此工廠函數(shù)生成新的可觀察對(duì)象,并讓該訂閱者訂閱這個(gè)新對(duì)象。
?defer()
? 操作符會(huì)把 ?Promise.resolve()
? 轉(zhuǎn)換成一個(gè)新的可觀察對(duì)象,它和 ?HttpClient
?一樣只會(huì)發(fā)送一次然后立即結(jié)束(complete)。這樣,當(dāng)訂閱者收到數(shù)據(jù)后就會(huì)自動(dòng)取消訂閱。
還有一個(gè)類似的用來(lái)生成異步錯(cuò)誤的測(cè)試助手。
/**
* Create async observable error that errors
* after a JS engine turn
*/
export function asyncError<T>(errorObject: any) {
return defer(() => Promise.reject(errorObject));
}
現(xiàn)在,?getQuote()
? 間諜正在返回異步可觀察對(duì)象,你的大多數(shù)測(cè)試都必須是異步的。
下面是一個(gè) ?fakeAsync()
? 測(cè)試,用于演示你在真實(shí)世界中所期望的數(shù)據(jù)流。
it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent)
.withContext('should show placeholder')
.toBe('...');
tick(); // flush the observable to get the quote
fixture.detectChanges(); // update view
expect(quoteEl.textContent)
.withContext('should show quote')
.toBe(testQuote);
expect(errorMessage())
.withContext('should not show error')
.toBeNull();
}));
注意,quote 元素會(huì)在 ?ngOnInit()
? 之后顯示占位符 ?'...'
?。因?yàn)榈谝痪涿陨形吹絹?lái)。
要清除可觀察對(duì)象中的第一句名言,你可以調(diào)用 ?tick()
?。然后調(diào)用 ?detectChanges()
? 來(lái)告訴 Angular 更新屏幕。
然后,你可以斷言 quote 元素是否顯示了預(yù)期的文本。
要使用 ?waitForAsync()
? 函數(shù),你必須在 test 的設(shè)置文件中導(dǎo)入 ?zone.js/testing
?。如果你是用 Angular CLI 創(chuàng)建的項(xiàng)目,那就已經(jīng)在 ?src/test.ts
? 中導(dǎo)入過 ?zone-testing
? 了。
這是之前的 ?fakeAsync()
? 測(cè)試,用 ?waitForAsync()
? 工具函數(shù)重寫的版本。
it('should show quote after getQuote (waitForAsync)', waitForAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent)
.withContext('should show placeholder')
.toBe('...');
fixture.whenStable().then(() => { // wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage())
.withContext('should not show error')
.toBeNull();
});
}));
?waitForAsync()
? 工具函數(shù)通過把測(cè)試代碼安排到在特殊的異步測(cè)試區(qū)(async test zone)下運(yùn)行來(lái)隱藏某些用來(lái)處理異步的樣板代碼。你不需要把 Jasmine 的 ?done()
? 傳給測(cè)試并讓測(cè)試調(diào)用 ?done()
?,因?yàn)樗?nbsp;Promise 或者可觀察對(duì)象的回調(diào)函數(shù)中是 ?undefined
?。
但是,可以通過調(diào)用 ?fixture.whenStable()
? 函數(shù)來(lái)揭示本測(cè)試的異步性,因?yàn)樵摵瘮?shù)打破了線性的控制流。
在 ?waitForAsync()
? 中使用 ?intervalTimer()
?(比如 ?setInterval()
?)時(shí),別忘了在測(cè)試后通過 ?clearInterval()
? 取消這個(gè)定時(shí)器,否則 ?waitForAsync()
? 永遠(yuǎn)不會(huì)結(jié)束。
測(cè)試必須等待 ?getQuote()
? 可觀察對(duì)象發(fā)出下一句名言。它并沒有調(diào)用 ?tick()
?,而是調(diào)用了 ?fixture.whenStable()
?。
?fixture.whenStable()
? 返回一個(gè) Promise,它會(huì)在 JavaScript 引擎的任務(wù)隊(duì)列變空時(shí)解析。在這個(gè)例子中,當(dāng)可觀察對(duì)象發(fā)出第一句名言時(shí),任務(wù)隊(duì)列就會(huì)變?yōu)榭铡?/p>
測(cè)試會(huì)在該 Promise 的回調(diào)中繼續(xù)進(jìn)行,它會(huì)調(diào)用 ?detectChanges()
? 來(lái)用期望的文本更新 quote 元素。
雖然 ?waitForAsync()
? 和 ?fakeAsync()
? 函數(shù)可以大大簡(jiǎn)化 Angular 的異步測(cè)試,但你仍然可以回退到傳統(tǒng)技術(shù),并給 ?it
?傳一個(gè)以 ?done
? 回調(diào)為參數(shù)的函數(shù)。
但你不能在 ?waitForAsync()
? 或 ?fakeAsync()
? 函數(shù)中調(diào)用 ?done()
?,因?yàn)槟抢锏?nbsp;?done
?參數(shù)是 ?undefined
?。
現(xiàn)在,你要自己負(fù)責(zé)串聯(lián)各種 Promise、處理錯(cuò)誤,并在適當(dāng)?shù)臅r(shí)機(jī)調(diào)用 ?done()
?。
編寫帶有 ?done()
? 的測(cè)試函數(shù)要比用 ?waitForAsync()
? 和 ?fakeAsync()
? 的形式笨重。但是當(dāng)代碼涉及到像 ?setInterval
?這樣的 ?intervalTimer()
? 時(shí),它往往是必要的。
這里是上一個(gè)測(cè)試的另外兩種版本,用 ?done()
? 編寫。第一個(gè)訂閱了通過組件的 ?quote
?屬性暴露給模板的 ?Observable
?。
it('should show last quote (quote done)', (done: DoneFn) => {
fixture.detectChanges();
component.quote.pipe(last()).subscribe(() => {
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage())
.withContext('should not show error')
.toBeNull();
done();
});
});
RxJS 的 ?last()
? 操作符會(huì)在完成之前發(fā)出可觀察對(duì)象的最后一個(gè)值,它同樣是測(cè)試名言。?subscribe
?回調(diào)會(huì)調(diào)用 ?detectChanges()
? 來(lái)使用測(cè)試名言刷新的 quote 元素,方法與之前的測(cè)試一樣。
在某些測(cè)試中,你可能更關(guān)心注入的服務(wù)方法是如何被調(diào)的以及它返回了什么值,而不是屏幕顯示的內(nèi)容。
服務(wù)間諜,比如偽 ?TwainService
?上的 ?qetQuote()
? 間諜,可以給你那些信息,并對(duì)視圖的狀態(tài)做出斷言。
it('should show quote after getQuote (spy done)', (done: DoneFn) => {
fixture.detectChanges();
// the spy's most recent call returns the observable with the test quote
getQuoteSpy.calls.mostRecent().returnValue.subscribe(() => {
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage())
.withContext('should not show error')
.toBeNull();
done();
});
});
前面的 ?TwainComponent
?測(cè)試通過 ?asyncData
?和 ?asyncError
?工具函數(shù)模擬了一個(gè)來(lái)自 ?TwainService
?的異步響應(yīng)體可觀察對(duì)象。
你可以自己編寫這些簡(jiǎn)短易用的函數(shù)。不幸的是,對(duì)于很多常見的場(chǎng)景來(lái)說,它們太簡(jiǎn)單了??捎^察對(duì)象經(jīng)常會(huì)發(fā)送很多次,可能是在經(jīng)過一段顯著的延遲之后。組件可以用重疊的值序列和錯(cuò)誤序列來(lái)協(xié)調(diào)多個(gè)可觀察對(duì)象。
RxJS 彈珠測(cè)試是一種測(cè)試可觀察場(chǎng)景的好方法,它既簡(jiǎn)單又復(fù)雜。你很可能已經(jīng)看過用于說明可觀察對(duì)象是如何工作彈珠圖。彈珠測(cè)試使用類似的彈珠語(yǔ)言來(lái)指定測(cè)試中的可觀察流和期望值。
下面的例子用彈珠測(cè)試再次實(shí)現(xiàn)了 ?TwainComponent
?中的兩個(gè)測(cè)試。
首先安裝 npm 包 ?jasmine-marbles
?。然后導(dǎo)入你需要的符號(hào)。
import { cold, getTestScheduler } from 'jasmine-marbles';
獲取名言的完整測(cè)試方法如下:
it('should show quote after getQuote (marbles)', () => {
// observable test quote value and complete(), after delay
const q$ = cold('---x|', { x: testQuote });
getQuoteSpy.and.returnValue( q$ );
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent)
.withContext('should show placeholder')
.toBe('...');
getTestScheduler().flush(); // flush the observables
fixture.detectChanges(); // update view
expect(quoteEl.textContent)
.withContext('should show quote')
.toBe(testQuote);
expect(errorMessage())
.withContext('should not show error')
.toBeNull();
});
注意,這個(gè) Jasmine 測(cè)試是同步的。沒有 ?fakeAsync()
?。彈珠測(cè)試使用測(cè)試調(diào)度程序(scheduler)來(lái)模擬同步測(cè)試中的時(shí)間流逝。
彈珠測(cè)試的美妙之處在于對(duì)可觀察對(duì)象流的視覺定義。這個(gè)測(cè)試定義了一個(gè)冷可觀察對(duì)象,它等待三幀(?---
?),發(fā)出一個(gè)值(?x
?),并完成(?|
?)。在第二個(gè)參數(shù)中,你把值標(biāo)記(?x
?)映射到了發(fā)出的值(?testQuote
?)。
const q$ = cold('---x|', { x: testQuote });
這個(gè)彈珠庫(kù)會(huì)構(gòu)造出相應(yīng)的可觀察對(duì)象,測(cè)試程序把它用作 ?getQuote
?間諜的返回值。
當(dāng)你準(zhǔn)備好激活彈珠的可觀察對(duì)象時(shí),就告訴 ?TestScheduler
?把它準(zhǔn)備好的任務(wù)隊(duì)列刷新一下。
getTestScheduler().flush(); // flush the observables
這個(gè)步驟的作用類似于之前的 ?fakeAsync()
? 和 ?waitForAsync()
? 例子中的 ?tick()
? 和 ?whenStable()
? 測(cè)試。對(duì)這種測(cè)試的權(quán)衡策略與那些例子是一樣的。
下面是 ?getQuote()
? 錯(cuò)誤測(cè)試的彈珠測(cè)試版。
it('should display error when TwainService fails', fakeAsync(() => {
// observable error after delay
const q$ = cold('---#|', null, new Error('TwainService test failure'));
getQuoteSpy.and.returnValue( q$ );
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent)
.withContext('should show placeholder')
.toBe('...');
getTestScheduler().flush(); // flush the observables
tick(); // component shows error after a setTimeout()
fixture.detectChanges(); // update error message
expect(errorMessage())
.withContext('should display error')
.toMatch(/test failure/);
expect(quoteEl.textContent)
.withContext('should show placeholder')
.toBe('...');
}));
它仍然是異步測(cè)試,調(diào)用 ?fakeAsync()
? 和 ?tick()
?,因?yàn)樵摻M件在處理錯(cuò)誤時(shí)會(huì)調(diào)用 ?setTimeout()
?。
看看這個(gè)彈珠的可觀察定義。
const q$ = cold('---#|', null, new Error('TwainService test failure'));
這是一個(gè)冷可觀察對(duì)象,等待三幀,然后發(fā)出一個(gè)錯(cuò)誤,井號(hào)(?#
?)標(biāo)出了在第三個(gè)參數(shù)中指定錯(cuò)誤的發(fā)生時(shí)間。第二個(gè)參數(shù)為 null,因?yàn)樵摽捎^察對(duì)象永遠(yuǎn)不會(huì)發(fā)出值。
彈珠幀是測(cè)試時(shí)間線上的虛擬單位。每個(gè)符號(hào)(-,x,|,#)都表示經(jīng)過了一幀。
冷可觀察對(duì)象在你訂閱它之前不會(huì)產(chǎn)生值。你的大多數(shù)應(yīng)用中可觀察對(duì)象都是冷的。所有的 ?HttpClient
?方法返回的都是冷可觀察對(duì)象。
而熱的可觀察對(duì)象在訂閱它之前就已經(jīng)在生成了這些值。用來(lái)報(bào)告路由器活動(dòng)的 ?Router.events
? 可觀察對(duì)象就是一種熱可觀察對(duì)象。
RxJS 彈珠測(cè)試這個(gè)主題非常豐富,超出了本指南的范圍。你可以在網(wǎng)上了解它,先從其官方文檔開始。
具有輸入和輸出屬性的組件通常會(huì)出現(xiàn)在宿主組件的視圖模板中。宿主使用屬性綁定來(lái)設(shè)置輸入屬性,并使用事件綁定來(lái)監(jiān)聽輸出屬性引發(fā)的事件。
本測(cè)試的目標(biāo)是驗(yàn)證這些綁定是否如預(yù)期般工作。這些測(cè)試應(yīng)該設(shè)置輸入值并監(jiān)聽輸出事件。
?DashboardHeroComponent
?是這類組件的一個(gè)小例子。它會(huì)顯示由 ?DashboardComponent
?提供的一個(gè)英雄。點(diǎn)擊這個(gè)英雄就會(huì)告訴 ?DashboardComponent
?,用戶已經(jīng)選擇了此英雄。
?DashboardHeroComponent
?會(huì)像這樣內(nèi)嵌在 ?DashboardComponent
?模板中的:
<dashboard-hero *ngFor="let hero of heroes" class="col-1-4"
[hero]=hero (selected)="gotoDetail($event)" >
</dashboard-hero>
?DashboardHeroComponent
?出現(xiàn)在 ?*ngFor
? 復(fù)寫器中,把它的輸入屬性 ?hero
?設(shè)置為當(dāng)前的循環(huán)變量,并監(jiān)聽該組件的 ?selected
?事件。
這里是組件的完整定義:
@Component({
selector: 'dashboard-hero',
template: `
<button type="button" (click)="click()" class="hero">
{{hero.name | uppercase}}
</button>
`,
styleUrls: [ './dashboard-hero.component.css' ]
})
export class DashboardHeroComponent {
@Input() hero!: Hero;
@Output() selected = new EventEmitter<Hero>();
click() { this.selected.emit(this.hero); }
}
在測(cè)試一個(gè)組件時(shí),像這樣簡(jiǎn)單的場(chǎng)景沒什么內(nèi)在價(jià)值,但值得了解它。你可以繼續(xù)嘗試這些方法:
DashboardComponent
?來(lái)測(cè)試它。DashboardComponent
?的一個(gè)替代品來(lái)測(cè)試它。快速看一眼 ?DashboardComponent
?構(gòu)造函數(shù)就知道不建議采用第一種方法:
constructor(
private router: Router,
private heroService: HeroService) {
}
?DashboardComponent
?依賴于 Angular 的路由器和 ?HeroService
?。你可能不得不用測(cè)試替身來(lái)代替它們,這有很多工作。路由器看上去特別有挑戰(zhàn)性。
當(dāng)前的目標(biāo)是測(cè)試 ?DashboardHeroComponent
?,而不是 ?DashboardComponent
?,所以試試第二個(gè)和第三個(gè)選項(xiàng)。
這里是 spec 文件中環(huán)境設(shè)置部分的內(nèi)容。
TestBed
.configureTestingModule({declarations: [DashboardHeroComponent]})
fixture = TestBed.createComponent(DashboardHeroComponent);
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
comp.hero = expectedHero;
// trigger initial data binding
fixture.detectChanges();
注意這些設(shè)置代碼如何把一個(gè)測(cè)試英雄(?expectedHero
?)賦值給組件的 ?hero
?屬性的,它模仿了 ?DashboardComponent
?在其復(fù)寫器中通過屬性綁定來(lái)設(shè)置它的方式。
下面的測(cè)試驗(yàn)證了英雄名是通過綁定傳播到模板的。
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
因?yàn)槟0灏延⑿鄣拿謧鹘o了 ?UpperCasePipe
?,所以測(cè)試必須要讓元素值與其大寫形式的名字一致。
這個(gè)小測(cè)試演示了 Angular 測(cè)試會(huì)如何驗(yàn)證一個(gè)組件的可視化表示形式 - 這是組件類測(cè)試所無(wú)法實(shí)現(xiàn)的 - 成本相對(duì)較低,無(wú)需進(jìn)行更慢、更復(fù)雜的端到端測(cè)試。
單擊該英雄應(yīng)該會(huì)讓一個(gè)宿主組件(可能是 ?DashboardComponent
?)監(jiān)聽到 ?selected
?事件。
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.pipe(first()).subscribe((hero: Hero) => selectedHero = hero);
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
該組件的 ?selected
?屬性給消費(fèi)者返回了一個(gè) ?EventEmitter
?,它看起來(lái)像是 RxJS 的同步 ?Observable
?。該測(cè)試只有在宿主組件隱式觸發(fā)時(shí)才需要顯式訂閱它。
當(dāng)組件的行為符合預(yù)期時(shí),單擊此英雄的元素就會(huì)告訴組件的 ?selected
?屬性發(fā)出了一個(gè) ?hero
?對(duì)象。
該測(cè)試通過對(duì) ?selected
?的訂閱來(lái)檢測(cè)該事件。
前面測(cè)試中的 ?heroDe
?是一個(gè)指向英雄條目 ?<div>
? 的 ?DebugElement
?。
它有一些用于抽象與原生元素交互的 Angular 屬性和方法。這個(gè)測(cè)試會(huì)使用事件名稱 ?click
?來(lái)調(diào)用 ?DebugElement.triggerEventHandler
?。?click
?的事件綁定到了 ?DashboardHeroComponent.click()
?。
Angular 的 ?DebugElement.triggerEventHandler
? 可以用事件的名字觸發(fā)任何數(shù)據(jù)綁定事件。第二個(gè)參數(shù)是傳給事件處理器的事件對(duì)象。
該測(cè)試觸發(fā)了一個(gè) “click” 事件。
heroDe.triggerEventHandler('click');
測(cè)試程序假設(shè)(在這里應(yīng)該這樣)運(yùn)行時(shí)間的事件處理器(組件的 ?click()
? 方法)不關(guān)心事件對(duì)象。
其它處理器的要求比較嚴(yán)格。比如,?
RouterLink
?指令期望一個(gè)帶有 ?button
?屬性的對(duì)象,該屬性用于指出點(diǎn)擊時(shí)按下的是哪個(gè)鼠標(biāo)按鈕。如果不給出這個(gè)事件對(duì)象,?RouterLink
?指令就會(huì)拋出一個(gè)錯(cuò)誤。
下面這個(gè)測(cè)試改為調(diào)用原生元素自己的 ?click()
? 方法,它對(duì)于這個(gè)組件來(lái)說相當(dāng)完美。
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.pipe(first()).subscribe((hero: Hero) => selectedHero = hero);
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
點(diǎn)擊按鈕、鏈接或者任意 HTML 元素是很常見的測(cè)試任務(wù)。
把點(diǎn)擊事件的處理過程包裝到如下的 ?click()
? 輔助函數(shù)中,可以讓這項(xiàng)任務(wù)更一致、更簡(jiǎn)單:
/** Button events to pass to `DebugElement.triggerEventHandler` for RouterLink event handler */
export const ButtonClickEvents = {
left: { button: 0 },
right: { button: 2 }
};
/** Simulate element click. Defaults to mouse left-button click event. */
export function click(el: DebugElement | HTMLElement, eventObj: any = ButtonClickEvents.left): void {
if (el instanceof HTMLElement) {
el.click();
} else {
el.triggerEventHandler('click', eventObj);
}
}
第一個(gè)參數(shù)是用來(lái)點(diǎn)擊的元素。如果你愿意,可以將自定義的事件對(duì)象傳給第二個(gè)參數(shù)。 默認(rèn)的是(局部的)鼠標(biāo)左鍵事件對(duì)象,它被許多事件處理器接受,包括 RouterLink
指令。
?
click()
? 輔助函數(shù)不是Angular 測(cè)試工具之一。它是在本章的例子代碼中定義的函數(shù)方法,被所有測(cè)試?yán)铀?。如果你喜歡它,將它添加到你自己的輔助函數(shù)集。
下面是把前面的測(cè)試用 ?click
?輔助函數(shù)重寫后的版本。
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.pipe(first()).subscribe((hero: Hero) => selectedHero = hero);
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
});
前面的這些測(cè)試都是自己扮演宿主元素 ?DashboardComponent
?的角色。但是當(dāng) ?DashboardHeroComponent
?真的綁定到某個(gè)宿主元素時(shí)還能正常工作嗎?
固然,你也可以測(cè)試真實(shí)的 ?DashboardComponent
?。但要想這么做需要做很多準(zhǔn)備工作,特別是它的模板中使用了某些特性,如 ?*ngFor
?、 其它組件、布局 HTML、附加綁定、注入了多個(gè)服務(wù)的構(gòu)造函數(shù)、如何用正確的方式與那些服務(wù)交互等。
想出這么多需要努力排除的干擾,只是為了證明一點(diǎn) —— 可以造出這樣一個(gè)令人滿意的測(cè)試宿主:
@Component({
template: `
<dashboard-hero
[hero]="hero" (selected)="onSelected($event)">
</dashboard-hero>`
})
class TestHostComponent {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}
這個(gè)測(cè)試宿主像 ?DashboardComponent
?那樣綁定了 ?DashboardHeroComponent
?,但是沒有 ?Router
?、 沒有 ?HeroService
?,也沒有 ?*ngFor
?。
這個(gè)測(cè)試宿主使用其測(cè)試用的英雄設(shè)置了組件的輸入屬性 ?hero
?。它使用 ?onSelected
?事件處理器綁定了組件的 ?selected
?事件,其中把事件中發(fā)出的英雄記錄到了 ?selectedHero
?屬性中。
稍后,這個(gè)測(cè)試就可以輕松檢查 ?selectedHero
?以驗(yàn)證 ?DashboardHeroComponent.selected
? 事件確實(shí)發(fā)出了所期望的英雄。
這個(gè)測(cè)試宿主中的準(zhǔn)備代碼和獨(dú)立測(cè)試中的準(zhǔn)備過程類似:
TestBed
.configureTestingModule({declarations: [DashboardHeroComponent, TestHostComponent]})
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
這個(gè)測(cè)試模塊的配置信息有三個(gè)重要的不同點(diǎn):
DashboardHeroComponent
?和 ?TestHostComponent
?。TestHostComponent
?,而非 ?DashboardHeroComponent
?。TestHostComponent
?通過綁定機(jī)制設(shè)置了 ?DashboardHeroComponent.hero
?。?createComponent
?返回的 ?fixture
?里有 ?TestHostComponent
?實(shí)例,而非 ?DashboardHeroComponent
?組件實(shí)例。
當(dāng)然,創(chuàng)建 ?TestHostComponent
?有創(chuàng)建 ?DashboardHeroComponent
?的副作用,因?yàn)楹笳叱霈F(xiàn)在前者的模板中。英雄元素(?heroEl
?)的查詢語(yǔ)句仍然可以在測(cè)試 DOM 中找到它,盡管元素樹比以前更深。
這些測(cè)試本身和它們的孤立版本幾乎相同:
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
只有 selected 事件的測(cè)試不一樣。它確保被選擇的 ?DashboardHeroComponent
?英雄確實(shí)通過事件綁定被傳遞到宿主組件。
所謂路由組件就是指會(huì)要求 ?Router
?導(dǎo)航到其它組件的組件。?DashboardComponent
?就是一個(gè)路由組件,因?yàn)橛脩艨梢酝ㄟ^點(diǎn)擊儀表盤中的某個(gè)英雄按鈕來(lái)導(dǎo)航到 ?HeroDetailComponent
?。
路由確實(shí)很復(fù)雜。測(cè)試 ?DashboardComponent
?看上去有點(diǎn)令人生畏,因?yàn)樗鼱砍兜胶?nbsp;?HeroService
?一起注入進(jìn)來(lái)的 ?Router
?。
constructor(
private router: Router,
private heroService: HeroService) {
}
使用間諜來(lái) Mock ?HeroService
?是一個(gè)熟悉的故事。 但是 ?Router
?的 API 很復(fù)雜,并且與其它服務(wù)和應(yīng)用的前置條件糾纏在一起。它應(yīng)該很難進(jìn)行 Mock 吧?
慶幸的是,在這個(gè)例子中不會(huì),因?yàn)?nbsp;?DashboardComponent
?并沒有深度使用 ?Router
?。
gotoDetail(hero: Hero) {
const url = `/heroes/${hero.id}`;
this.router.navigateByUrl(url);
}
這是路由組件中的通例。一般來(lái)說,你應(yīng)該測(cè)試組件而不是路由器,應(yīng)該只關(guān)心組件有沒有根據(jù)給定的條件導(dǎo)航到正確的地址。
為這個(gè)組件的測(cè)試套件提供路由器的間諜就像提供 ?HeroService
?的間諜一樣簡(jiǎn)單。
const routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']);
const heroServiceSpy = jasmine.createSpyObj('HeroService', ['getHeroes']);
TestBed
.configureTestingModule({
providers: [
{provide: HeroService, useValue: heroServiceSpy}, {provide: Router, useValue: routerSpy}
]
})
下面這個(gè)測(cè)試會(huì)點(diǎn)擊正在顯示的英雄,并確認(rèn) ?Router.navigateByUrl
? 曾用所期待的 URL 調(diào)用過。
it('should tell ROUTER to navigate when hero clicked', () => {
heroClick(); // trigger click on first inner <div class="hero">
// args passed to router.navigateByUrl() spy
const spy = router.navigateByUrl as jasmine.Spy;
const navArgs = spy.calls.first().args[0];
// expecting to navigate to id of the component's first hero
const id = comp.heroes[0].id;
expect(navArgs)
.withContext('should nav to HeroDetail for first hero')
.toBe('/heroes/' + id);
});
路由目標(biāo)組件是指 ?Router
?導(dǎo)航到的目標(biāo)。它測(cè)試起來(lái)可能很復(fù)雜,特別是當(dāng)路由到的這個(gè)組件包含參數(shù)的時(shí)候。?HeroDetailComponent
?就是一個(gè)路由目標(biāo)組件,它是某個(gè)路由定義指向的目標(biāo)。
當(dāng)用戶點(diǎn)擊儀表盤中的英雄時(shí),?DashboardComponent
?會(huì)要求 ?Router
?導(dǎo)航到 ?heroes/:id
?。?:id
? 是一個(gè)路由參數(shù),它的值就是所要編輯的英雄的 ?id
?。
該 ?Router
?會(huì)根據(jù)那個(gè) URL 匹配到一個(gè)指向 ?HeroDetailComponent
?的路由。它會(huì)創(chuàng)建一個(gè)帶有路由信息的 ?ActivatedRoute
?對(duì)象,并把它注入到一個(gè) ?HeroDetailComponent
?的新實(shí)例中。
下面是 ?HeroDetailComponent
?的構(gòu)造函數(shù):
constructor(
private heroDetailService: HeroDetailService,
private route: ActivatedRoute,
private router: Router) {
}
?HeroDetailComponent
?組件需要一個(gè) ?id
?參數(shù),以便通過 ?HeroDetailService
?獲取相應(yīng)的英雄。該組件只能從 ?ActivatedRoute.paramMap
? 屬性中獲取這個(gè) ?id
?,這個(gè)屬性是一個(gè) ?Observable
?。
它不能僅僅引用 ?ActivatedRoute.paramMap
? 的 ?id
?屬性。該組件不得不訂閱 ?ActivatedRoute.paramMap
? 這個(gè)可觀察對(duì)象,要做好它在生命周期中隨時(shí)會(huì)發(fā)生變化的準(zhǔn)備。
ngOnInit(): void {
// get hero when `id` param changes
this.route.paramMap.subscribe(pmap => this.getHero(pmap.get('id')));
}
通過操縱注入到組件構(gòu)造函數(shù)中的這個(gè) ?ActivatedRoute
?,測(cè)試可以探查 ?HeroDetailComponent
?是如何對(duì)不同的 ?id
?參數(shù)值做出響應(yīng)的。
你已經(jīng)知道了如何給 ?Router
?和數(shù)據(jù)服務(wù)安插間諜。
不過對(duì)于 ?ActivatedRoute
?,你要采用另一種方式,因?yàn)椋?/p>
paramMap
?會(huì)返回一個(gè)能發(fā)出多個(gè)值的 ?Observable
?。convertToParamMap()
? 來(lái)創(chuàng)建 ?ParamMap
?。ActivatedRoute
?的測(cè)試替身。這些差異表明你需要一個(gè)可復(fù)用的樁類(stub)。
下面的 ?ActivatedRouteStub
?類就是作為 ?ActivatedRoute
?類的測(cè)試替身使用的。
import { convertToParamMap, ParamMap, Params } from '@angular/router';
import { ReplaySubject } from 'rxjs';
/**
* An ActivateRoute test double with a `paramMap` observable.
* Use the `setParamMap()` method to add the next `paramMap` value.
*/
export class ActivatedRouteStub {
// Use a ReplaySubject to share previous values with subscribers
// and pump new values into the `paramMap` observable
private subject = new ReplaySubject<ParamMap>();
constructor(initialParams?: Params) {
this.setParamMap(initialParams);
}
/** The mock paramMap observable */
readonly paramMap = this.subject.asObservable();
/** Set the paramMap observable's next value */
setParamMap(params: Params = {}) {
this.subject.next(convertToParamMap(params));
}
}
考慮把這類輔助函數(shù)放進(jìn)一個(gè)緊鄰 ?app
?文件夾的 ?testing
?文件夾。這個(gè)例子把 ?ActivatedRouteStub
?放在了 ?testing/activated-route-stub.ts
? 中。
下面的測(cè)試程序是演示組件在被觀察的 ?id
?指向現(xiàn)有英雄時(shí)的行為:
describe('when navigate to existing hero', () => {
let expectedHero: Hero;
beforeEach(async () => {
expectedHero = firstHero;
activatedRoute.setParamMap({id: expectedHero.id});
await createComponent();
});
it("should display that hero's name", () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
});
稍后會(huì)對(duì) ?
createComponent()
? 方法和 ?page
?對(duì)象進(jìn)行討論。不過目前,你只要憑直覺來(lái)理解就行了。
當(dāng)找不到 ?id
?的時(shí)候,組件應(yīng)該重新路由到 ?HeroListComponent
?。
測(cè)試套件的準(zhǔn)備代碼提供了一個(gè)和前面一樣的路由器間諜,它會(huì)充當(dāng)路由器的角色,而不用發(fā)起實(shí)際的導(dǎo)航。
這個(gè)測(cè)試中會(huì)期待該組件嘗試導(dǎo)航到 ?HeroListComponent
?。
describe('when navigate to non-existent hero id', () => {
beforeEach(async () => {
activatedRoute.setParamMap({id: 99999});
await createComponent();
});
it('should try to navigate back to hero list', () => {
expect(page.gotoListSpy.calls.any())
.withContext('comp.gotoList called')
.toBe(true);
expect(page.navigateSpy.calls.any())
.withContext('router.navigate called')
.toBe(true);
});
});
雖然本應(yīng)用沒有在缺少 ?id
?參數(shù)的時(shí)候,繼續(xù)導(dǎo)航到 ?HeroDetailComponent
?的路由,但是,將來(lái)它可能會(huì)添加這樣的路由。當(dāng)沒有 ?id
?時(shí),該組件應(yīng)該作出合理的反應(yīng)。
在本例中,組件應(yīng)該創(chuàng)建和顯示新英雄。新英雄的 ?id
?為零,?name
?為空。本測(cè)試程序確認(rèn)組件是按照預(yù)期的這樣做的:
describe('when navigate with no hero id', () => {
beforeEach(async () => {
await createComponent();
});
it('should have hero.id === 0', () => {
expect(component.hero.id).toBe(0);
});
it('should display empty hero name', () => {
expect(page.nameDisplay.textContent).toBe('');
});
});
組件的模板中通常還會(huì)有嵌套組件,嵌套組件的模板還可能包含更多組件。
這棵組件樹可能非常深,并且大多數(shù)時(shí)候在測(cè)試這棵樹頂部的組件時(shí),這些嵌套的組件都無(wú)關(guān)緊要。
比如,?AppComponent
?會(huì)顯示一個(gè)帶有鏈接及其 ?RouterLink
?指令的導(dǎo)航條。
<app-banner></app-banner>
<app-welcome></app-welcome>
<nav>
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/heroes">Heroes</a>
<a routerLink="/about">About</a>
</nav>
<router-outlet></router-outlet>
雖然 ?AppComponent
?類是空的,但你可能會(huì)希望寫個(gè)單元測(cè)試來(lái)確認(rèn)這些鏈接是否正確使用了 ?RouterLink
?指令。
要想驗(yàn)證這些鏈接,你不必用 ?Router
?進(jìn)行導(dǎo)航,也不必使用 ?<router-outlet>
? 來(lái)指出 ?Router
?應(yīng)該把路由目標(biāo)組件插入到什么地方。
而 ?BannerComponent
?和 ?WelcomeComponent
?(寫作 ?<app-banner>
? 和 ?<app-welcome>
?)也同樣風(fēng)馬牛不相及。
然而,任何測(cè)試,只要能在 DOM 中創(chuàng)建 ?AppComponent
?,也就同樣能創(chuàng)建這三個(gè)組件的實(shí)例。如果要?jiǎng)?chuàng)建它們,你就要配置 ?TestBed
?。
如果你忘了聲明它們,Angular 編譯器就無(wú)法在 ?AppComponent
?模板中識(shí)別出 ?<app-banner>
?、?<app-welcome>
? 和 ?<router-outlet>
? 標(biāo)記,并拋出一個(gè)錯(cuò)誤。
如果你聲明的這些都是真實(shí)的組件,那么也同樣要聲明它們的嵌套組件,并要為這棵組件樹中的任何組件提供要注入的所有服務(wù)。
如果只是想回答關(guān)于鏈接的一些簡(jiǎn)單問題,做這些顯然就太多了。
本節(jié)會(huì)講減少此類準(zhǔn)備工作的兩項(xiàng)技術(shù)。單獨(dú)使用或組合使用它們,可以讓這些測(cè)試聚焦于要測(cè)試的主要組件上。
這項(xiàng)技術(shù)中,你要為那些在測(cè)試中無(wú)關(guān)緊要的組件或指令創(chuàng)建和聲明一些測(cè)試樁。
@Component({selector: 'app-banner', template: ''})
class BannerStubComponent {
}
@Component({selector: 'router-outlet', template: ''})
class RouterOutletStubComponent {
}
@Component({selector: 'app-welcome', template: ''})
class WelcomeStubComponent {
}
這些測(cè)試樁的選擇器要和其對(duì)應(yīng)的真實(shí)組件一致,但其模板和類是空的。
然后在 ?TestBed
?的配置中那些真正有用的組件、指令、管道之后聲明它們。
TestBed
.configureTestingModule({
declarations: [
AppComponent, RouterLinkDirectiveStub, BannerStubComponent, RouterOutletStubComponent,
WelcomeStubComponent
]
})
?AppComponent
?是該測(cè)試的主角,因此當(dāng)然要用它的真實(shí)版本。
而 ?RouterLinkDirectiveStub
?是一個(gè)真實(shí)的 ?RouterLink
?的測(cè)試版,它能幫你對(duì)鏈接進(jìn)行測(cè)試。
其它都是測(cè)試樁。
第二種辦法就是把 ?NO_ERRORS_SCHEMA
?添加到 ?TestBed.schemas
? 的元數(shù)據(jù)中。
TestBed
.configureTestingModule({
declarations: [
AppComponent,
RouterLinkDirectiveStub
],
schemas: [NO_ERRORS_SCHEMA]
})
?NO_ERRORS_SCHEMA
?會(huì)要求 Angular 編譯器忽略不認(rèn)識(shí)的那些元素和屬性。
編譯器將會(huì)識(shí)別出 ?<app-root>
? 元素和 ?RouterLink
?屬性,因?yàn)槟阍?nbsp;?TestBed
?的配置中聲明了相應(yīng)的 ?AppComponent
?和 ?RouterLinkDirectiveStub
?。
但編譯器在遇到 ?<app-banner>
?、?<app-welcome>
? 或 ?<router-outlet>
? 時(shí)不會(huì)報(bào)錯(cuò)。它只會(huì)把它們渲染成空白標(biāo)簽,而瀏覽器會(huì)忽略這些標(biāo)簽。
你不用再提供樁組件了。
這些是進(jìn)行淺層測(cè)試要用到的技術(shù),之所以叫淺層測(cè)試是因?yàn)橹话緶y(cè)試所關(guān)心的這個(gè)組件模板中的元素。
?NO_ERRORS_SCHEMA
?方法在這兩者中比較簡(jiǎn)單,但也不要過度使用它。
?NO_ERRORS_SCHEMA
?還會(huì)阻止編譯器告訴你因?yàn)榈氖韬龌蚱磳戝e(cuò)誤而缺失的組件和屬性。你如果人工找出這些 bug 可能要浪費(fèi)幾個(gè)小時(shí),但編譯器可以立即捕獲它們。
樁組件方式還有其它優(yōu)點(diǎn)。雖然這個(gè)例子中的樁是空的,但你如果想要和它們用某種形式互動(dòng),也可以給它們一些裁剪過的模板和類。
在實(shí)踐中,你可以在準(zhǔn)備代碼中組合使用這兩種技術(shù),例子如下。
TestBed
.configureTestingModule({
declarations: [
AppComponent,
BannerStubComponent,
RouterLinkDirectiveStub
],
schemas: [NO_ERRORS_SCHEMA]
})
Angular 編譯器會(huì)為 ?<app-banner>
? 元素創(chuàng)建 ?BannerComponentStub
?,并把 ?RouterLinkStubDirective
?應(yīng)用到帶有 ?routerLink
?屬性的鏈接上,不過它會(huì)忽略 ?<app-welcome>
? 和 ?<router-outlet>
? 標(biāo)簽。
真實(shí)的 ?RouterLinkDirective
?太復(fù)雜了,而且與 ?RouterModule
?中的其它組件和指令有著千絲萬(wàn)縷的聯(lián)系。要在準(zhǔn)備階段 Mock 它以及在測(cè)試中使用它具有一定的挑戰(zhàn)性。
這段范例代碼中的 ?RouterLinkDirectiveStub
?用一個(gè)代用品替換了真實(shí)的指令,這個(gè)代用品用來(lái)驗(yàn)證 ?AppComponent
?中所用鏈接的類型。
@Directive({
selector: '[routerLink]'
})
export class RouterLinkDirectiveStub {
@Input('routerLink') linkParams: any;
navigatedTo: any = null;
@HostListener('click')
onClick() {
this.navigatedTo = this.linkParams;
}
}
這個(gè) URL 被綁定到了 ?[routerLink]
? 屬性,它的值流入了該指令的 ?linkParams
?屬性。
它的元數(shù)據(jù)中的 ?host
?屬性把宿主元素(即 ?AppComponent
?中的 ?<a>
? 元素)的 ?click
?事件關(guān)聯(lián)到了這個(gè)樁指令的 ?onClick
?方法。
點(diǎn)擊這個(gè)鏈接應(yīng)該觸發(fā) ?onClick()
? 方法,其中會(huì)設(shè)置該樁指令中的警示器屬性 ?navigatedTo
?。測(cè)試中檢查 ?navigatedTo
?以確認(rèn)點(diǎn)擊該鏈接確實(shí)如預(yù)期的那樣根據(jù)路由定義設(shè)置了該屬性。
路由器的配置是否正確和是否能按照那些路由定義進(jìn)行導(dǎo)航,是測(cè)試中一組獨(dú)立的問題。
再一步配置觸發(fā)了數(shù)據(jù)綁定的初始化,獲取導(dǎo)航鏈接的引用:
beforeEach(() => {
fixture.detectChanges(); // trigger initial data binding
// find DebugElements with an attached RouterLinkStubDirective
linkDes = fixture.debugElement.queryAll(By.directive(RouterLinkDirectiveStub));
// get attached link directive instances
// using each DebugElement's injector
routerLinks = linkDes.map(de => de.injector.get(RouterLinkDirectiveStub));
});
有三點(diǎn)特別重要:
By.directive
? 來(lái)定位一個(gè)帶附屬指令的鏈接元素。DebugElement
?包裝器。DebugElement
?都會(huì)導(dǎo)出該元素中的一個(gè)依賴注入器,其中帶有指定的指令實(shí)例。?AppComponent
?中要驗(yàn)證的鏈接如下:
<nav>
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/heroes">Heroes</a>
<a routerLink="/about">About</a>
</nav>
下面這些測(cè)試用來(lái)確認(rèn)那些鏈接是否如預(yù)期般連接到了 ?RouterLink
?指令中:
it('can get RouterLinks from template', () => {
expect(routerLinks.length)
.withContext('should have 3 routerLinks')
.toBe(3);
expect(routerLinks[0].linkParams).toBe('/dashboard');
expect(routerLinks[1].linkParams).toBe('/heroes');
expect(routerLinks[2].linkParams).toBe('/about');
});
it('can click Heroes link in template', () => {
const heroesLinkDe = linkDes[1]; // heroes link DebugElement
const heroesLink = routerLinks[1]; // heroes link directive
expect(heroesLink.navigatedTo)
.withContext('should not have navigated yet')
.toBeNull();
heroesLinkDe.triggerEventHandler('click');
fixture.detectChanges();
expect(heroesLink.navigatedTo).toBe('/heroes');
});
其實(shí)這個(gè)例子中的“click”測(cè)試誤入歧途了。它測(cè)試的重點(diǎn)其實(shí)是 ?RouterLinkDirectiveStub
?,而不是該組件。這是寫樁指令時(shí)常見的錯(cuò)誤。
在本章中,它有存在的必要。它演示了如何在不涉及完整路由器機(jī)制的情況下,如何找到 ?RouterLink
?元素、點(diǎn)擊它并檢查結(jié)果。要測(cè)試更復(fù)雜的組件,你可能需要具備這樣的能力,能改變視圖和重新計(jì)算參數(shù),或者當(dāng)用戶點(diǎn)擊鏈接時(shí),有能力重新安排導(dǎo)航選項(xiàng)。
用 ?RouterLink
?的樁指令進(jìn)行測(cè)試可以確認(rèn)帶有鏈接和 outlet 的組件的設(shè)置的正確性,確認(rèn)組件有應(yīng)該有的鏈接,確認(rèn)它們都指向了正確的方向。這些測(cè)試程序不關(guān)心用戶點(diǎn)擊鏈接時(shí),也不關(guān)心應(yīng)用是否會(huì)成功的導(dǎo)航到目標(biāo)組件。
對(duì)于這些有限的測(cè)試目標(biāo),使用 RouterLink 樁指令和 RouterOutlet 樁組件 是最佳選擇。依靠真正的路由器會(huì)讓它們很脆弱。它們可能因?yàn)榕c組件無(wú)關(guān)的原因而失敗。比如,一個(gè)導(dǎo)航守衛(wèi)可能防止沒有授權(quán)的用戶訪問 ?HeroListComponent
?。這并不是 ?AppComponent
?的過錯(cuò),并且無(wú)論該組件怎么改變都無(wú)法修復(fù)這個(gè)失敗的測(cè)試程序。
一組不同的測(cè)試程序可以探索當(dāng)存在影響守衛(wèi)的條件時(shí)(比如用戶是否已認(rèn)證和授權(quán)),該應(yīng)用是否如期望般導(dǎo)航。
?HeroDetailComponent
?是帶有標(biāo)題、兩個(gè)英雄字段和兩個(gè)按鈕的簡(jiǎn)單視圖。
但即使是這么簡(jiǎn)單的表單,其模板中也涉及到不少?gòu)?fù)雜性。
<div *ngIf="hero">
<h2><span>{{hero.name | titlecase}}</span> Details</h2>
<div>
<span>id: </span>{{hero.id}}</div>
<div>
<label for="name">name: </label>
<input id="name" [(ngModel)]="hero.name" placeholder="name" />
</div>
<button type="button" (click)="save()">Save</button>
<button type="button" (click)="cancel()">Cancel</button>
</div>
這些供練習(xí)用的組件需要 ……
即使是像這樣一個(gè)很小的表單,也能產(chǎn)生令人瘋狂的錯(cuò)綜復(fù)雜的條件設(shè)置和 CSS 元素選擇。
可以使用 ?Page
?類來(lái)征服這種復(fù)雜性。?Page
?類可以處理對(duì)組件屬性的訪問,并對(duì)設(shè)置這些屬性的邏輯進(jìn)行封裝。
下面是一個(gè)供 ?hero-detail.component.spec.ts
? 使用的 ?Page
?類
class Page {
// getter properties wait to query the DOM until called.
get buttons() {
return this.queryAll<HTMLButtonElement>('button');
}
get saveBtn() {
return this.buttons[0];
}
get cancelBtn() {
return this.buttons[1];
}
get nameDisplay() {
return this.query<HTMLElement>('span');
}
get nameInput() {
return this.query<HTMLInputElement>('input');
}
gotoListSpy: jasmine.Spy;
navigateSpy: jasmine.Spy;
constructor(someFixture: ComponentFixture<HeroDetailComponent>) {
// get the navigate spy from the injected router spy object
const routerSpy = someFixture.debugElement.injector.get(Router) as any;
this.navigateSpy = routerSpy.navigate;
// spy on component's `gotoList()` method
const someComponent = someFixture.componentInstance;
this.gotoListSpy = spyOn(someComponent, 'gotoList').and.callThrough();
}
//// query helpers ////
private query<T>(selector: string): T {
return fixture.nativeElement.querySelector(selector);
}
private queryAll<T>(selector: string): T[] {
return fixture.nativeElement.querySelectorAll(selector);
}
}
現(xiàn)在,用來(lái)操作和檢查組件的重要鉤子都被井然有序的組織起來(lái)了,可以通過 ?page
?實(shí)例來(lái)使用它們。
?createComponent
?方法會(huì)創(chuàng)建一個(gè) ?page
?對(duì)象,并在 ?hero
?到來(lái)時(shí)自動(dòng)填補(bǔ)空白。
/** Create the HeroDetailComponent, initialize it, set test variables */
function createComponent() {
fixture = TestBed.createComponent(HeroDetailComponent);
component = fixture.componentInstance;
page = new Page(fixture);
// 1st change detection triggers ngOnInit which gets a hero
fixture.detectChanges();
return fixture.whenStable().then(() => {
// 2nd change detection displays the async-fetched hero
fixture.detectChanges();
});
}
前面小節(jié)中的 HeroDetailComponent 測(cè)試示范了如何 ?createComponent
?,而 ?page
?讓這些測(cè)試保持簡(jiǎn)短而富有表達(dá)力。 而且還不用分心:不用等待承諾被解析,不必在 DOM 中找出元素的值才能進(jìn)行比較。
還有更多的 ?HeroDetailComponent
?測(cè)試可以證明這一點(diǎn)。
it("should display that hero's name", () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
it('should navigate when click cancel', () => {
click(page.cancelBtn);
expect(page.navigateSpy.calls.any())
.withContext('router.navigate called')
.toBe(true);
});
it('should save when click save but not navigate immediately', () => {
// Get service injected into component and spy on its`saveHero` method.
// It delegates to fake `HeroService.updateHero` which delivers a safe test result.
const hds = fixture.debugElement.injector.get(HeroDetailService);
const saveSpy = spyOn(hds, 'saveHero').and.callThrough();
click(page.saveBtn);
expect(saveSpy.calls.any())
.withContext('HeroDetailService.save called')
.toBe(true);
expect(page.navigateSpy.calls.any())
.withContext('router.navigate not called')
.toBe(false);
});
it('should navigate when click save and save resolves', fakeAsync(() => {
click(page.saveBtn);
tick(); // wait for async save to complete
expect(page.navigateSpy.calls.any())
.withContext('router.navigate called')
.toBe(true);
}));
it('should convert hero name to Title Case', () => {
// get the name's input and display elements from the DOM
const hostElement: HTMLElement = fixture.nativeElement;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// Dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(new Event('input'));
// Tell Angular to update the display binding through the title pipe
fixture.detectChanges();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
如果你只想使用 CLI 的 ?
ng test
? 命令來(lái)運(yùn)行測(cè)試,那么可以忽略這一節(jié)。
如果你在非 CLI 環(huán)境中運(yùn)行測(cè)試,這些測(cè)試可能會(huì)報(bào)錯(cuò),錯(cuò)誤信息如下:
Error: This test module uses the component BannerComponent
which is using a "templateUrl" or "styleUrls", but they were never compiled.
Please call "TestBed.compileComponents" before your test.
問題的根源在于這個(gè)測(cè)試中至少有一個(gè)組件引用了外部模板或外部 CSS 文件,就像下面這個(gè)版本的 ?BannerComponent
?所示。
import { Component } from '@angular/core';
@Component({
selector: 'app-banner',
templateUrl: './banner-external.component.html',
styleUrls: ['./banner-external.component.css']
})
export class BannerComponent {
title = 'Test Tour of Heroes';
}
當(dāng) ?TestBed
?視圖創(chuàng)建組件時(shí),這個(gè)測(cè)試失敗了。
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BannerComponent ],
}); // missing call to compileComponents()
fixture = TestBed.createComponent(BannerComponent);
});
回想一下,這個(gè)應(yīng)用從未編譯過。所以當(dāng)你調(diào)用 ?createComponent()
? 的時(shí)候,?TestBed
?就會(huì)進(jìn)行隱式編譯。
當(dāng)它的源碼都在內(nèi)存中的時(shí)候,這樣做沒問題。不過 ?BannerComponent
?需要一些外部文件,編譯時(shí)必須從文件系統(tǒng)中讀取它,而這是一個(gè)天生的異步操作。
如果 ?TestBed
?繼續(xù)執(zhí)行,這些測(cè)試就會(huì)繼續(xù)運(yùn)行,并在編譯器完成這些異步工作之前導(dǎo)致莫名其妙的失敗。
這些錯(cuò)誤信息告訴你要使用 ?compileComponents()
? 進(jìn)行顯式的編譯。
你必須在異步測(cè)試函數(shù)中調(diào)用 ?compileComponents()
?。
如果你忘了把測(cè)試函數(shù)標(biāo)為異步的(比如忘了像稍后的代碼中那樣使用 ?
waitForAsync()
?),就會(huì)看到下列錯(cuò)誤。Error: ViewDestroyedError: Attempt to use a destroyed view
典型的做法是把準(zhǔn)備邏輯拆成兩個(gè)獨(dú)立的 ?beforeEach()
? 函數(shù):
函數(shù) |
詳情 |
---|---|
異步 |
負(fù)責(zé)編譯組件 |
同步 |
負(fù)責(zé)執(zhí)行其余的準(zhǔn)備代碼 |
像下面這樣編寫第一個(gè)異步的 ?beforeEach
?。
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BannerComponent ],
}).compileComponents(); // compile template and css
});
?TestBed.configureTestingModule()
? 方法返回 ?TestBed
?類,所以你可以鏈?zhǔn)秸{(diào)用其它 ?TestBed
?中的靜態(tài)方法,比如 ?compileComponents()
?。
在這個(gè)例子中,?BannerComponent
?是僅有的待編譯組件。其它例子中可能會(huì)使用多個(gè)組件來(lái)配置測(cè)試模塊,并且可能引入某些具有其它組件的應(yīng)用模塊。它們中的任何一個(gè)都可能需要外部文件。
?TestBed.compileComponents
? 方法會(huì)異步編譯測(cè)試模塊中配置過的所有組件。
在調(diào)用了 ?
compileComponents()
? 之后就不能再重新配置 ?TestBed
?了。
調(diào)用 ?compileComponents()
? 會(huì)關(guān)閉當(dāng)前的 ?TestBed
?實(shí)例,不再允許進(jìn)行配置。你不能再調(diào)用任何 ?TestBed
?中的配置方法,既不能調(diào) ?configureTestingModule()
?,也不能調(diào)用任何 ?override...
? 方法。如果你試圖這么做,?TestBed
?就會(huì)拋出錯(cuò)誤。
確保 ?compileComponents()
? 是調(diào)用 ?TestBed.createComponent()
? 之前的最后一步。
第二個(gè)同步 ?beforeEach()
? 的例子包含剩下的準(zhǔn)備步驟,包括創(chuàng)建組件和查詢那些要檢查的元素。
beforeEach(() => {
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
h1 = fixture.nativeElement.querySelector('h1');
});
測(cè)試運(yùn)行器(runner)會(huì)先等待第一個(gè)異步 ?beforeEach
?函數(shù)執(zhí)行完再調(diào)用第二個(gè)。
你可以把這兩個(gè) ?beforeEach()
? 函數(shù)重整成一個(gè)異步的 ?beforeEach()
?。
?compileComponents()
? 方法返回一個(gè)承諾,所以你可以通過把同步代碼移到 ?await
?關(guān)鍵字后面,在那里,這個(gè) Promise 已經(jīng)解析了。
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BannerComponent ],
}).compileComponents();
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance;
h1 = fixture.nativeElement.querySelector('h1');
});
在不需要 ?compileComponents()
? 的時(shí)候調(diào)用它也不會(huì)有害處。
雖然在運(yùn)行 ?ng test
? 時(shí)永遠(yuǎn)都不需要調(diào)用 ?compileComponents()
?,但 CLI 生成的組件測(cè)試文件還是會(huì)調(diào)用它。
但這篇指南中的這些測(cè)試只會(huì)在必要時(shí)才調(diào)用 ?compileComponents
?。
此前的組件測(cè)試程序使用了一些 ?declarations
?來(lái)配置模塊,就像這樣:
TestBed
.configureTestingModule({declarations: [DashboardHeroComponent]})
?DashbaordComponent
?非常簡(jiǎn)單。它不需要幫助。但是更加復(fù)雜的組件通常依賴其它組件、指令、管道和提供者,所以這些必須也被添加到測(cè)試模塊中。
幸運(yùn)的是,?TestBed.configureTestingModule
? 參數(shù)與傳入 ?@NgModule
? 裝飾器的元數(shù)據(jù)一樣,也就是所你也可以指定 ?providers
?和 ?imports
?。
雖然 ?HeroDetailComponent
?很小,結(jié)構(gòu)也很簡(jiǎn)單,但是它需要很多幫助。除了從默認(rèn)測(cè)試模塊 ?CommonModule
?中獲得的支持,它還需要:
FormsModule
?里的 ?NgModel
?和其它,來(lái)進(jìn)行雙向數(shù)據(jù)綁定shared
?目錄里的 ?TitleCasePipe
?一種方法是從各個(gè)部分配置測(cè)試模塊,就像這樣:
beforeEach(async () => {
const routerSpy = createRouterSpy();
await TestBed
.configureTestingModule({
imports: [FormsModule],
declarations: [HeroDetailComponent, TitleCasePipe],
providers: [
{provide: ActivatedRoute, useValue: activatedRoute},
{provide: HeroService, useClass: TestHeroService},
{provide: Router, useValue: routerSpy},
]
})
.compileComponents();
});
注意,?beforeEach()
? 是異步的,它調(diào)用 ?TestBed.compileComponents
? 是因?yàn)?nbsp;?HeroDetailComponent
? 有外部模板和 CSS 文件。
如調(diào)用 compileComponents() 中所解釋的那樣,這些測(cè)試可以運(yùn)行在非 CLI 環(huán)境下,那里 Angular 并不會(huì)在瀏覽器中編譯它們。
因?yàn)楹芏鄳?yīng)用組件都需要 ?FormsModule
?和 ?TitleCasePipe
?,所以開發(fā)者創(chuàng)建了 ?SharedModule
?來(lái)把它們及其它常用的部分組合在一起。
這些測(cè)試配置也可以使用 ?SharedModule
?,如下所示:
beforeEach(async () => {
const routerSpy = createRouterSpy();
await TestBed
.configureTestingModule({
imports: [SharedModule],
declarations: [HeroDetailComponent],
providers: [
{provide: ActivatedRoute, useValue: activatedRoute},
{provide: HeroService, useClass: TestHeroService},
{provide: Router, useValue: routerSpy},
]
})
.compileComponents();
});
它的導(dǎo)入聲明少一些(未顯示),稍微干凈一些,小一些。
?HeroDetailComponent
?是 ?HeroModule
?這個(gè)特性模塊的一部分,它聚合了更多相互依賴的片段,包括 ?SharedModule
?。試試下面這個(gè)導(dǎo)入了 ?HeroModule
?的測(cè)試配置:
beforeEach(async () => {
const routerSpy = createRouterSpy();
await TestBed
.configureTestingModule({
imports: [HeroModule],
providers: [
{provide: ActivatedRoute, useValue: activatedRoute},
{provide: HeroService, useClass: TestHeroService},
{provide: Router, useValue: routerSpy},
]
})
.compileComponents();
});
這樣特別清爽。只有 ?providers
?里面的測(cè)試替身被保留。連 ?HeroDetailComponent
?聲明都消失了。
事實(shí)上,如果你試圖聲明它,Angular 就會(huì)拋出一個(gè)錯(cuò)誤,因?yàn)?nbsp;?HeroDetailComponent
?同時(shí)聲明在了 ?HeroModule
?和 ?TestBed
?創(chuàng)建的 ?DynamicTestModule
?中。
如果模塊中有很多共同依賴,并且該模塊很小(這也是特性模塊的應(yīng)有形態(tài)),那么直接導(dǎo)入組件的特性模塊可以成為配置這些測(cè)試的最佳方式。
?HeroDetailComponent
?提供自己的 ?HeroDetailService
?服務(wù)。
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css' ],
providers: [ HeroDetailService ]
})
export class HeroDetailComponent implements OnInit {
constructor(
private heroDetailService: HeroDetailService,
private route: ActivatedRoute,
private router: Router) {
}
}
在 ?TestBed.configureTestingModule
? 的 ?providers
?中 stub 偽造組件的 ?HeroDetailService
?是不可行的。這些是測(cè)試模塊的提供者,而非組件的。組件級(jí)別的提供者應(yīng)該在 fixture 級(jí)別的依賴注入器中進(jìn)行準(zhǔn)備。
Angular 會(huì)使用自己的注入器來(lái)創(chuàng)建這些組件,這個(gè)注入器是夾具的注入器的子注入器。它使用這個(gè)子注入器注冊(cè)了該組件服務(wù)提供者(這里是 ?HeroDetailService
?)。
測(cè)試沒辦法從測(cè)試夾具的注入器中獲取子注入器中的服務(wù),而 ?TestBed.configureTestingModule
? 也沒法配置它們。
Angular 始終都在創(chuàng)建真實(shí) ?HeroDetailService
?的實(shí)例。
如果 ?HeroDetailService
?向遠(yuǎn)程服務(wù)器發(fā)出自己的 XHR 請(qǐng)求,這些測(cè)試可能會(huì)失敗或者超時(shí)。這個(gè)遠(yuǎn)程服務(wù)器可能根本不存在。
幸運(yùn)的是,?HeroDetailService
?將遠(yuǎn)程數(shù)據(jù)訪問的責(zé)任交給了注入進(jìn)來(lái)的 ?HeroService
?。前面的測(cè)試配置使用 ?@Injectable() export class HeroDetailService { constructor(private heroService: HeroService) { } /* . . . */ }
TestHeroService
? 替換了真實(shí)的 ?HeroService
?,它攔截了發(fā)往服務(wù)器的請(qǐng)求,并偽造了服務(wù)器的響應(yīng)。
如果你沒有這么幸運(yùn)怎么辦?如果偽造 ?HeroService
?很難怎么辦?如果 ?HeroDetailService
?自己發(fā)出服務(wù)器請(qǐng)求怎么辦?
?TestBed.overrideComponent
? 方法可以將組件的 ?providers
?替換為容易管理的測(cè)試替身,參閱下面的變體準(zhǔn)備代碼:
beforeEach(async () => {
const routerSpy = createRouterSpy();
await TestBed
.configureTestingModule({
imports: [HeroModule],
providers: [
{provide: ActivatedRoute, useValue: activatedRoute},
{provide: Router, useValue: routerSpy},
]
})
// Override component's own provider
.overrideComponent(
HeroDetailComponent,
{set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}})
.compileComponents();
});
注意,?TestBed.configureTestingModule
? 不再提供(偽造的)?HeroService
?,因?yàn)?b>并不需要。
注意這個(gè) ?overrideComponent
?方法。
.overrideComponent(
HeroDetailComponent,
{set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}})
它接受兩個(gè)參數(shù):要改寫的組件類型(?HeroDetailComponent
?),以及用于改寫的元數(shù)據(jù)對(duì)象。用于改寫的元數(shù)據(jù)對(duì)象是一個(gè)泛型,其定義如下:
type MetadataOverride<T> = {
add?: Partial<T>;
remove?: Partial<T>;
set?: Partial<T>;
};
元數(shù)據(jù)重載對(duì)象可以添加和刪除元數(shù)據(jù)屬性的項(xiàng)目,也可以徹底重設(shè)這些屬性。這個(gè)例子重新設(shè)置了組件的 ?providers
?元數(shù)據(jù)。
這個(gè)類型參數(shù) ?T
? 就是你傳給 ?@Component
? 裝飾器的元數(shù)據(jù):
selector?: string;
template?: string;
templateUrl?: string;
providers?: any[];
…
這個(gè)例子把組件的 ?providers
?數(shù)組完全替換成了一個(gè)包含 ?HeroDetailServiceSpy
?的新數(shù)組。
?HeroDetailServiceSpy
?是實(shí)際 ?HeroDetailService
?服務(wù)的樁版本,它偽造了該服務(wù)的所有必要特性。但它既不需要注入也不會(huì)委托給低層的 ?HeroService
?服務(wù),因此不用為 ?HeroService
?提供測(cè)試替身。
通過對(duì)該服務(wù)的方法進(jìn)行刺探,?HeroDetailComponent
?的關(guān)聯(lián)測(cè)試將會(huì)對(duì) ?HeroDetailService
?是否被調(diào)用過進(jìn)行斷言。因此,這個(gè)樁類會(huì)把它的方法實(shí)現(xiàn)為刺探方法:
class HeroDetailServiceSpy {
testHero: Hero = {id: 42, name: 'Test Hero'};
/* emit cloned test hero */
getHero = jasmine.createSpy('getHero').and.callFake(
() => asyncData(Object.assign({}, this.testHero)));
/* emit clone of test hero, with changes merged in */
saveHero = jasmine.createSpy('saveHero')
.and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero)));
}
現(xiàn)在,測(cè)試程序可以通過操控這個(gè) spy-stub 的 ?testHero
?,直接控制組件的英雄,并確認(rèn)那個(gè)服務(wù)方法被調(diào)用過。
let hdsSpy: HeroDetailServiceSpy;
beforeEach(async () => {
await createComponent();
// get the component's injected HeroDetailServiceSpy
hdsSpy = fixture.debugElement.injector.get(HeroDetailService) as any;
});
it('should have called `getHero`', () => {
expect(hdsSpy.getHero.calls.count())
.withContext('getHero called once')
.toBe(1, 'getHero called once');
});
it("should display stub hero's name", () => {
expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});
it('should save stub hero change', fakeAsync(() => {
const origName = hdsSpy.testHero.name;
const newName = 'New Name';
page.nameInput.value = newName;
page.nameInput.dispatchEvent(new Event('input')); // tell Angular
expect(component.hero.name)
.withContext('component hero has new name')
.toBe(newName);
expect(hdsSpy.testHero.name)
.withContext('service hero unchanged before save')
.toBe(origName);
click(page.saveBtn);
expect(hdsSpy.saveHero.calls.count())
.withContext('saveHero called once')
.toBe(1);
tick(); // wait for async save to complete
expect(hdsSpy.testHero.name)
.withContext('service hero has new name after save')
.toBe(newName);
expect(page.navigateSpy.calls.any())
.withContext('router.navigate called')
.toBe(true);
}));
?TestBed.overrideComponent
? 方法可以在相同或不同的組件中被反復(fù)調(diào)用。?TestBed
?還提供了類似的 ?overrideDirective
?、?overrideModule
?和 ?overridePipe
?方法,用來(lái)深入并重載這些其它類的部件。
更多建議: