Angular 組件測(cè)試場(chǎng)景

2022-07-08 09:07 更新

組件測(cè)試場(chǎng)景

本指南探討了一些常見的組件測(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)容。

查詢 <h1> 元素

你將編寫一系列測(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');
});

createComponent() 不綁定數(shù)據(jù)

對(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('');
});

detectChanges()

你必須通過調(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');
});

自動(dòng)變更檢測(cè)

?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)用它。

使用 dispatchEvent() 改變輸入框的值

要模擬用戶輸入,你可以找到 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?。

為服務(wù)提供測(cè)試替身

待測(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' },
  };

取得所注入的服務(wù)

這些測(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()

可能還可以通過 ?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);

最后的設(shè)置與測(cè)試

這里是完成的 ?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ì)檢查組件是否顯示了正確的消息。

帶異步服務(wù)的組件

在這個(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è)試的特性。

使用間諜(spy)進(jìn)行測(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è)試。

同步測(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() 進(jìn)行異步測(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ú)效。

tick() 函數(shù)

你必須調(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)用它。

tickOptions

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() 內(nèi)部的日期

?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 與 fakeAsync() 聯(lián)用

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)度器

?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);
   }));

支持更多的 macroTasks

?fakeAsync()? 默認(rèn)支持以下宏任務(wù)。

  • ?setTimeout ?
  • ?setInterval ?
  • ?requestAnimationFrame ?
  • ?webkitRequestAnimationFrame ?
  • ?mozRequestAnimationFrame ?

如果你運(yùn)行其他宏任務(wù),比如 ?HTMLCanvasElement.toBlob()?,就會(huì)拋出 "Unknown macroTask scheduled in fake async test" 錯(cuò)誤。

  • src/app/shared/canvas.component.spec.ts (failing)
  • 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);
         }));
    });
  • src/app/shared/canvas.component.ts
  • 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';

異步可觀察對(duì)象

你可能已經(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è)試助手

異步可觀察對(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));
}

更多異步測(cè)試

現(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() 進(jìn)行異步測(cè)試

要使用 ?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é)束。

whenStable

測(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 元素。

Jasmine done()

雖然 ?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();
  });
});

組件的彈珠測(cè)試

前面的 ?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)衡策略與那些例子是一樣的。

彈珠錯(cuò)誤測(cè)試

下面是 ?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è)試

彈珠幀是測(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è)試它。
  • 把它作為一個(gè)獨(dú)立的組件進(jìn)行測(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)。

單獨(dú)測(cè)試 DashboardHeroComponent

這里是 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è)試。

點(diǎn)擊

單擊該英雄應(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è)該事件。

triggerEventHandler

前面測(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ò)誤。

點(diǎn)擊該元素

下面這個(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);
});

click() 幫助器

點(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è)試宿主中的組件

前面的這些測(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):

  • 它同時(shí)聲明了 ?DashboardHeroComponent ?和 ?TestHostComponent?。
  • 它創(chuàng)建了 ?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)組件

路由目標(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>

  • 在測(cè)試期間,?paramMap ?會(huì)返回一個(gè)能發(fā)出多個(gè)值的 ?Observable?。
  • 你需要路由器的輔助函數(shù) ?convertToParamMap()? 來(lái)創(chuàng)建 ?ParamMap?。
  • 針對(duì)路由目標(biāo)組件的其它測(cè)試需要一個(gè) ?ActivatedRoute ?的測(cè)試替身。

這些差異表明你需要一個(gè)可復(fù)用的樁類(stub)。

ActivatedRouteStub

下面的 ?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? 中。

使用 ActivatedRouteStub 進(jìn)行測(cè)試

下面的測(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('');
  });
});

對(duì)嵌套組件的測(cè)試

組件的模板中通常還會(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è)試的主要組件上。

對(duì)不需要的組件提供樁(stub)

這項(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

第二種辦法就是把 ?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)簽。

你不用再提供樁組件了。

同時(shí)使用這兩項(xiàng)技術(shù)

這些是進(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)簽。

帶有 RouterLink 的組件

真實(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ú)立的問題。

By.directive 與注入的指令

再一步配置觸發(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 ?包裝器。
  • 每個(gè) ?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)。

這些測(cè)試有什么優(yōu)點(diǎn)?

用 ?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)航。

使用 page 對(duì)象

?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í)用的組件需要 ……

  • 等獲取到英雄之后才能讓元素出現(xiàn)在 DOM 中
  • 一個(gè)對(duì)標(biāo)題文本的引用
  • 一個(gè)對(duì) name 輸入框的引用,以便對(duì)它進(jìn)行探查和修改
  • 引用兩個(gè)按鈕,以便點(diǎn)擊它們
  • 為組件和路由器的方法安插間諜

即使是像這樣一個(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');
});

調(diào)用 compileComponents()

如果你只想使用 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)行顯式的編譯。

compileComponents() 是異步的

你必須在異步測(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ù)

詳情

異步 beforeEach()

負(fù)責(zé)編譯組件

同步 beforeEach()

負(fù)責(zé)執(zhí)行其余的準(zhǔn)備代碼

異步的 beforeEach

像下面這樣編寫第一個(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()? 之前的最后一步。

同步的 beforeEach

第二個(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è)。

整理過的準(zhǔn)備代碼

你可以把這兩個(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() 是無(wú)害的

在不需要 ?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?。

準(zhǔn)備模塊的 imports

此前的組件測(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 ?
  • 一些路由器服務(wù)(測(cè)試程序?qū)?nbsp;stub 偽造它們)
  • 英雄數(shù)據(jù)訪問服務(wù)(同樣被 stub 偽造了)

一種方法是從各個(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ì)在瀏覽器中編譯它們。

導(dǎo)入共享模塊

因?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)入聲明少一些(未顯示),稍微干凈一些,小一些。

導(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è)試的最佳方式。

改寫組件的服務(wù)提供者

?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?。
@Injectable()
export class HeroDetailService {
  constructor(private heroService: HeroService) {  }
/* . . . */
}
前面的測(cè)試配置使用 ?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>并不需要。

overrideComponent 方法

注意這個(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[];
…

提供 間諜樁 (HeroDetailServiceSpy)

這個(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)));
}

改寫測(cè)試

現(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)深入并重載這些其它類的部件。


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)