Angular 組件測試場景

2022-07-08 09:07 更新

組件測試場景

本指南探討了一些常見的組件測試用例。

如果你要試驗本指南中所講的應用,請在瀏覽器中運行它下載并在本地運行它。

組件綁定

在范例應用中,?BannerComponent ?在 HTML 模板中展示了靜態(tài)的標題文本。

在少許更改之后,?BannerComponent ?就會通過綁定組件的 ?title ?屬性來渲染動態(tài)標題。

@Component({
  selector: 'app-banner',
  template: '<h1>{{title}}</h1>',
  styles: ['h1 { color: green; font-size: 350%}']
})
export class BannerComponent {
  title = 'Test Tour of Heroes';
}

盡管這很小,但你還是決定要添加一個測試來確認該組件實際顯示的是你認為合適的內容。

查詢 <h1> 元素

你將編寫一系列測試來檢查 ?<h1>? 元素中包裹的 title 屬性插值綁定。

你可以修改 ?beforeEach ?以找到帶有標準 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() 不綁定數據

對于你的第一個測試,你希望屏幕上顯示默認的 ?title?。你的直覺就是編寫一個能立即檢查 ?<h1>? 的測試,就像這樣:

it('should display original title', () => {
  expect(h1.textContent).toContain(component.title);
});

那個測試失敗了

expected '' to contain 'Test Tour of Heroes'.

當 Angular 執(zhí)行變更檢測時就會發(fā)生綁定。

在生產環(huán)境中,當 Angular 創(chuàng)建一個組件,或者用戶輸入按鍵,或者異步活動(比如 AJAX)完成時,就會自動進行變更檢測。

該 ?TestBed.createComponent不會觸發(fā)變化檢測,修改后的測試可以證實這一點:

it('no title in the DOM after createComponent()', () => {
  expect(h1.textContent).toEqual('');
});

detectChanges()

你必須通過調用 ?fixture.detectChanges()? 來告訴 ?TestBed ?執(zhí)行數據綁定。只有這樣,?<h1>? 才能擁有預期的標題。

it('should display original title after detectChanges()', () => {
  fixture.detectChanges();
  expect(h1.textContent).toContain(component.title);
});

這里延遲變更檢測時機是故意而且有用的。這樣才能讓測試者在 Angular 啟動數據綁定并調用生命周期鉤子之前,查看并更改組件的狀態(tài)。

這是另一個測試,它會在調用 ?fixture.detectChanges()之前改變組件的 ?title ?屬性。

it('should display a different test title', () => {
  component.title = 'Test Title';
  fixture.detectChanges();
  expect(h1.textContent).toContain('Test Title');
});

自動變更檢測

?BannerComponent ?測試會經常調用 ?detectChanges?。一些測試人員更喜歡讓 Angular 測試環(huán)境自動運行變更檢測。

可以通過配置帶有 ?ComponentFixtureAutoDetect ?提供者的 ?TestBed ?來實現這一點。我們首先從測試工具函數庫中導入它:

import { ComponentFixtureAutoDetect } from '@angular/core/testing';

然后把它添加到測試模塊配置的 ?providers ?中:

TestBed.configureTestingModule({
  declarations: [ BannerComponent ],
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
});

這里有三個測試來說明自動變更檢測是如何工作的。

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

第一個測試顯示了自動變更檢測的優(yōu)點。

第二個和第三個測試則揭示了一個重要的限制。該 Angular 測試環(huán)境不知道測試改變了組件的 ?title?。?ComponentFixtureAutoDetect ?服務會響應異步活動,比如 Promise、定時器和 DOM 事件。但卻看不見對組件屬性的直接同步更新。該測試必須用 ?fixture.detectChanges()? 來觸發(fā)另一個變更檢測周期。

本指南中的范例總是會顯式調用 ?detectChanges()?,而不用困惑于測試夾具何時會或不會執(zhí)行變更檢測。更頻繁的調用 ?detectChanges()? 毫無危害,沒必要只在非常必要時才調用它。

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

要模擬用戶輸入,你可以找到 input 元素并設置它的 ?value ?屬性。

你會調用 ?fixture.detectChanges()? 來觸發(fā) Angular 的變更檢測。但還有一個重要的中間步驟。

Angular 并不知道你為 input 設置過 ?value ?屬性。在通過調用 ?dispatchEvent()? 分發(fā) ?input ?事件之前,它不會讀取該屬性。緊接著你就調用了 ?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 ?是用內聯模板內聯 css 定義的,它們分別是在 ?@Component.template? 和 ?@Component.styles? 屬性中指定的。

很多組件都會分別用 ?@Component.templateUrl? 和 ?@Component.styleUrls? 屬性來指定外部模板外部 css,就像下面的 ?BannerComponent ?變體一樣。

@Component({
  selector: 'app-banner',
  templateUrl: './banner-external.component.html',
  styleUrls:  ['./banner-external.component.css']
})

這個語法告訴 Angular 編譯器要在組件編譯時讀取外部文件。

當運行 ?ng test? 命令時,這不是問題,因為它會在運行測試之前編譯應用

但是,如果在非 CLI 環(huán)境中運行這些測試,那么這個組件的測試可能會失敗。比如,如果你在一個 web 編程環(huán)境(比如 plunker 中運行 ?BannerComponent ?測試,你會看到如下消息:

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.

當運行環(huán)境在測試過程中需要編譯源代碼時,就會得到這條測試失敗的消息。

要解決這個問題,可以像下面 調用 compileComponents 小節(jié)中講的那樣調用 ?compileComponents()?。

具有依賴的組件

組件通常都有服務依賴。

?WelcomeComponent ?會向登錄用戶顯示一條歡迎信息。它可以基于注入進來的 ?UserService ?的一個屬性了解到用戶是誰:

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 ?擁有與該服務交互的決策邏輯,該邏輯讓這個組件值得測試。這是 spec 文件的測試模塊配置:

TestBed.configureTestingModule({
   declarations: [ WelcomeComponent ],
// providers: [ UserService ],  // NO! Don't provide the real service!
                                // Provide a test-double instead
   providers: [ { provide: UserService, useValue: userServiceStub } ],
});

這次,除了聲明被測組件外,該配置還在 ?providers ?列表中加入了 ?UserService ?提供者。但它不是真正的 ?UserService?。

為服務提供測試替身

待測組件不必注入真正的服務。事實上,如果它們是測試替身(stubs,fakes,spies 或 mocks),通常會更好。該測試規(guī)約的目的是測試組件,而不是服務,使用真正的服務可能會遇到麻煩。

注入真正的 ?UserService ?可能是個噩夢。真正的服務可能要求用戶提供登錄憑據,并嘗試訪問認證服務器。這些行為可能難以攔截。為它創(chuàng)建并注冊一個測試專用版來代替真正的 ?UserService ?要容易得多,也更安全。

這個特定的測試套件提供了 ?UserService ?的最小化模擬,它滿足了 ?WelcomeComponent ?及其測試的需求:

let userServiceStub: Partial<UserService>;

  userServiceStub = {
    isLoggedIn: true,
    user: { name: 'Test User' },
  };

取得所注入的服務

這些測試需要訪問注入到 ?WelcomeComponent ?中的 ?UserService ?樁。

Angular 有一個分層注入系統(tǒng)。它具有多個層級的注入器,從 ?TestBed ?創(chuàng)建的根注入器開始,直到組件樹中的各個層級。

獲得注入服務的最安全的方式(始終有效),就是從被測組件的注入器中獲取它。組件注入器是測試夾具所提供的 ?DebugElement ?中的一個屬性。

// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);

TestBed.inject()

可能還可以通過 ?TestBed.inject()? 來從根注入器獲得服務。這更容易記憶,也不那么啰嗦。但這只有當 Angular 要把根注入器中的服務實例注入測試組件時才是可行的。

在下面這個測試套件中,?UserService?唯一的提供者是根測試模塊,因此可以安全地調用 ?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');
});

以下是一些測試:

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

首先是一個健全性測試;它確認了樁服務 ?UserService ?被調用過并能正常工作。

Jasmine 匹配器的第二個參數(比如 ?'expected name'?)是一個可選的失敗標簽。如果此期望失敗,Jasmine 就會把這個標簽貼到期望失敗的消息中。在具有多個期望的測試規(guī)約中,它可以幫我們澄清出現了什么問題以及都有哪些期望失敗了。

當該服務返回不同的值時,其余的測試會確認該組件的邏輯。第二個測試驗證了更改用戶名的效果。當用戶未登錄時,第三個測試會檢查組件是否顯示了正確的消息。

帶異步服務的組件

在這個例子中,?AboutComponent ?模板托管了一個 ?TwainComponent?。?TwainComponent ?會顯示馬克·吐溫的名言。

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 ?傳遞。這意味著該屬性會返回 ?Promise ?或 ?Observable?。

在這個例子中,?TwainComponent.getQuote()? 方法告訴你 ?quote ?屬性會返回一個 ?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 ?中獲取名言。該在服務能返回第一條名言之前,該服務會先返回一個占位流(?'...'?)。

?catchError ?會攔截服務錯誤,準備一條錯誤信息,并在流的成功通道上返回占位值。它必須等一拍(tick)才能設置 ?errorMessage?,以免在同一個變更檢測周期內更新此消息兩次。

這些都是你想要測試的特性。

使用間諜(spy)進行測試

在測試組件時,只有該服務的公開 API 才有意義。通常,測試本身不應該調用遠程服務器。它們應該模擬這樣的調用。這個 ?app/twain/twain.component.spec.ts? 中的環(huá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');
});

仔細看一下這個間諜。

// 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));

這個間諜的設計目標是讓所有對 ?getQuote ?的調用都會收到一個帶有測試名言的可觀察對象。與真正的 ?getQuote()? 方法不同,這個間諜會繞過服務器,并返回一個立即同步提供可用值的可觀察對象。

雖然這個 ?Observable ?是同步的,但你也可以用這個間諜編寫很多有用的測試。

同步測試

同步 ?Observable ?的一個關鍵優(yōu)勢是,你通常可以把異步過程轉換成同步測試。

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

當間諜的結果同步返回時,?getQuote()? 方法會在第一個變更檢測周期(Angular 在這里調用 ?ngOnInit?)立即更新屏幕上的消息。

你在測試錯誤路徑時就沒有這么幸運了。雖然服務間諜會同步返回一個錯誤,但該組件方法會調用 ?setTimeout()?。在值可用之前,測試必須等待 JavaScript 引擎的至少一個周期。因此,該測試必須是異步的。

使用 fakeAsync() 進行異步測試

要使用 ?fakeAsync()? 功能,你必須在測試的環(huán)境設置文件中導入 ?zone.js/testing?。如果是用 Angular CLI 創(chuàng)建的項目,那么其 ?src/test.ts? 中已經配置好了 ?zone-testing?。

當該服務返回 ?ErrorObservable ?時,下列測試會對其預期行為進行確認。

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()? 函數會接收以下形式的參數。
fakeAsync(() => { /* test body */ })

通過在一個特殊的 ?fakeAsync test zone?(譯注:Zone.js 的一個特例)中運行測試體,?fakeAsync()? 函數可以啟用線性編碼風格。這個測試體看上去是同步的。沒有像 ?Promise.then()? 這樣的嵌套語法來破壞控制流。

限制:如果測試體要進行 ?XMLHttpRequest?(XHR)調用,則 ?fakeAsync()? 函數無效。

tick() 函數

你必須調用 ?tick()? 來推進(虛擬)時鐘。

調用 ?tick()? 時會在所有掛起的異步活動完成之前模擬時間的流逝。在這種情況下,它會等待錯誤處理程序中的 ?setTimeout()?。

?tick()? 函數接受毫秒數(milliseconds) 和 tick 選項(tickOptions) 作為參數,毫秒數(默認值為 0)參數表示虛擬時鐘要前進多少。比如,如果你在 ?fakeAsync()? 測試中有一個 ?setTimeout(fn, 100)?,你就需要使用 ?tick(100)? 來觸發(fā)其 fn 回調。tickOptions 是一個可選參數,它帶有一個名為 ?processNewMacroTasksSynchronously ?的屬性(默認為 true),表示在 tick 時是否要調用新生成的宏任務。

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()? 函數是你用 ?TestBed ?導入的 Angular 測試工具函數之一。它是 ?fakeAsync()? 的伴生工具,你只能在 ?fakeAsync()? 測試體內調用它。

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

在這個例子中,我們有一個新的宏任務(嵌套的 setTimeout),默認情況下,當 ?tick ?時,setTimeout 的 ?outside ?和 ?nested ?都會被觸發(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 時觸發(fā)新的宏任務,就可以使用 ?tick(milliseconds, {processNewMacroTasksSynchronously: false})? 來要求不調用新的宏任務。

比較 fakeAsync() 內部的日期

?fakeAsync()? 可以模擬時間的流逝,以便讓你計算出 ?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() 聯用

Jasmine 還為模擬日期提供了 ?clock ?特性。而 Angular 會在 ?jasmine.clock().install()? 于 ?fakeAsync()? 方法內調用時自動運行這些測試。直到調用了 ?jasmine.clock().uninstall()? 為止。?fakeAsync()? 不是必須的,如果嵌套它就拋出錯誤。

默認情況下,此功能處于禁用狀態(tài)。要啟用它,請在導入 ?zone-testing? 之前先設置全局標志。

如果你使用的是 Angular CLI,請在 ?src/test.ts? 中配置這個標志。

(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 調度器

?fakeAsync()? 使用 RxJS 的調度器,就像使用 ?setTimeout()? 或 ?setInterval()? 一樣,但你需要導入 ?zone.js/plugins/zone-patch-rxjs-fake-async? 來給 RxJS 調度器打補丁。

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()? 默認支持以下宏任務。

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

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

  • 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()? 定義你要支持的宏任務。比如:

beforeEach(() => {
  (window as any).__zone_symbol__FakeAsyncTestMacroTask = [
    {
      source: 'HTMLCanvasElement.toBlob',
      callbackArgs: [{size: 200}],
    },
  ];
});
注意:
要在依賴 Zone.js 的應用中使用 ?<canvas>? 元素,你需要導入 ?zone-patch-canvas? 補?。ɑ蛘咴?nbsp;?polyfills.ts? 中,或者在用到 ?<canvas>? 的那個文件中):
// 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';

異步可觀察對象

你可能已經對前面這些測試的測試覆蓋率感到滿意。

但是,你可能也會為另一個事實感到不安:真實的服務并不是這樣工作的。真實的服務會向遠程服務器發(fā)送請求。服務器需要一定的時間才能做出響應,并且其響應體肯定不會像前面兩個測試中一樣是立即可用的。

如果能像下面這樣從 ?getQuote()? 間諜中返回一個異步的可觀察對象,你的測試就會更真實地反映現實世界。

// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));

異步可觀察對象測試助手

異步可觀察對象可以由測試助手 ?asyncData ?生成。測試助手 ?asyncData ?是一個你必須自行編寫的工具函數,當然也可以從下面的范例代碼中復制它。

/**
 * Create async observable that emits-once and completes
 * after a JS engine turn
 */
export function asyncData<T>(data: T) {
  return defer(() => Promise.resolve(data));
}

這個助手返回的可觀察對象會在 JavaScript 引擎的下一個周期中發(fā)送 ?data ?值。

RxJS 的defer()操作符返回一個可觀察對象。它的參數是一個返回 Promise 或可觀察對象的工廠函數。當某個訂閱者訂閱 defer 生成的可觀察對象時,defer 就會調用此工廠函數生成新的可觀察對象,并讓該訂閱者訂閱這個新對象。

?defer()? 操作符會把 ?Promise.resolve()? 轉換成一個新的可觀察對象,它和 ?HttpClient ?一樣只會發(fā)送一次然后立即結束(complete)。這樣,當訂閱者收到數據后就會自動取消訂閱。

還有一個類似的用來生成異步錯誤的測試助手。

/**
 * Create async observable error that errors
 * after a JS engine turn
 */
export function asyncError<T>(errorObject: any) {
  return defer(() => Promise.reject(errorObject));
}

更多異步測試

現在,?getQuote()? 間諜正在返回異步可觀察對象,你的大多數測試都必須是異步的。

下面是一個 ?fakeAsync()? 測試,用于演示你在真實世界中所期望的數據流。

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 元素會在 ?ngOnInit()? 之后顯示占位符 ?'...'?。因為第一句名言尚未到來。

要清除可觀察對象中的第一句名言,你可以調用 ?tick()?。然后調用 ?detectChanges()? 來告訴 Angular 更新屏幕。

然后,你可以斷言 quote 元素是否顯示了預期的文本。

用 waitForAsync() 進行異步測試

要使用 ?waitForAsync()? 函數,你必須在 test 的設置文件中導入 ?zone.js/testing?。如果你是用 Angular CLI 創(chuàng)建的項目,那就已經在 ?src/test.ts? 中導入過 ?zone-testing? 了。

這是之前的 ?fakeAsync()? 測試,用 ?waitForAsync()? 工具函數重寫的版本。

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()? 工具函數通過把測試代碼安排到在特殊的異步測試區(qū)(async test zone)下運行來隱藏某些用來處理異步的樣板代碼。你不需要把 Jasmine 的 ?done()? 傳給測試并讓測試調用 ?done()?,因為它在 Promise 或者可觀察對象的回調函數中是 ?undefined?。

但是,可以通過調用 ?fixture.whenStable()? 函數來揭示本測試的異步性,因為該函數打破了線性的控制流。

在 ?waitForAsync()? 中使用 ?intervalTimer()?(比如 ?setInterval()?)時,別忘了在測試后通過 ?clearInterval()? 取消這個定時器,否則 ?waitForAsync()? 永遠不會結束。

whenStable

測試必須等待 ?getQuote()? 可觀察對象發(fā)出下一句名言。它并沒有調用 ?tick()?,而是調用了 ?fixture.whenStable()?。

?fixture.whenStable()? 返回一個 Promise,它會在 JavaScript 引擎的任務隊列變空時解析。在這個例子中,當可觀察對象發(fā)出第一句名言時,任務隊列就會變?yōu)榭铡?/p>

測試會在該 Promise 的回調中繼續(xù)進行,它會調用 ?detectChanges()? 來用期望的文本更新 quote 元素。

Jasmine done()

雖然 ?waitForAsync()? 和 ?fakeAsync()? 函數可以大大簡化 Angular 的異步測試,但你仍然可以回退到傳統(tǒng)技術,并給 ?it ?傳一個以 ?done? 回調為參數的函數。

但你不能在 ?waitForAsync()? 或 ?fakeAsync()? 函數中調用 ?done()?,因為那里的 ?done ?參數是 ?undefined?。

現在,你要自己負責串聯各種 Promise、處理錯誤,并在適當的時機調用 ?done()?。

編寫帶有 ?done()? 的測試函數要比用 ?waitForAsync()? 和 ?fakeAsync()? 的形式笨重。但是當代碼涉及到像 ?setInterval ?這樣的 ?intervalTimer()? 時,它往往是必要的。

這里是上一個測試的另外兩種版本,用 ?done()? 編寫。第一個訂閱了通過組件的 ?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()? 操作符會在完成之前發(fā)出可觀察對象的最后一個值,它同樣是測試名言。?subscribe ?回調會調用 ?detectChanges()? 來使用測試名言刷新的 quote 元素,方法與之前的測試一樣。

在某些測試中,你可能更關心注入的服務方法是如何被調的以及它返回了什么值,而不是屏幕顯示的內容。

服務間諜,比如偽 ?TwainService ?上的 ?qetQuote()? 間諜,可以給你那些信息,并對視圖的狀態(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 ?測試通過 ?asyncData ?和 ?asyncError ?工具函數模擬了一個來自 ?TwainService ?的異步響應體可觀察對象。

你可以自己編寫這些簡短易用的函數。不幸的是,對于很多常見的場景來說,它們太簡單了。可觀察對象經常會發(fā)送很多次,可能是在經過一段顯著的延遲之后。組件可以用重疊的值序列和錯誤序列來協(xié)調多個可觀察對象。

RxJS 彈珠測試是一種測試可觀察場景的好方法,它既簡單又復雜。你很可能已經看過用于說明可觀察對象是如何工作彈珠圖。彈珠測試使用類似的彈珠語言來指定測試中的可觀察流和期望值。

下面的例子用彈珠測試再次實現了 ?TwainComponent ?中的兩個測試。

首先安裝 npm 包 ?jasmine-marbles?。然后導入你需要的符號。

import { cold, getTestScheduler } from 'jasmine-marbles';

獲取名言的完整測試方法如下:

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

注意,這個 Jasmine 測試是同步的。沒有 ?fakeAsync()?。彈珠測試使用測試調度程序(scheduler)來模擬同步測試中的時間流逝。

彈珠測試的美妙之處在于對可觀察對象流的視覺定義。這個測試定義了一個冷可觀察對象,它等待三幀(?---?),發(fā)出一個值(?x?),并完成(?|?)。在第二個參數中,你把值標記(?x?)映射到了發(fā)出的值(?testQuote?)。

const q$ = cold('---x|', { x: testQuote });

這個彈珠庫會構造出相應的可觀察對象,測試程序把它用作 ?getQuote ?間諜的返回值。

當你準備好激活彈珠的可觀察對象時,就告訴 ?TestScheduler ?把它準備好的任務隊列刷新一下。

getTestScheduler().flush(); // flush the observables

這個步驟的作用類似于之前的 ?fakeAsync()? 和 ?waitForAsync()? 例子中的 ?tick()? 和 ?whenStable()? 測試。對這種測試的權衡策略與那些例子是一樣的。

彈珠錯誤測試

下面是 ?getQuote()? 錯誤測試的彈珠測試版。

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

它仍然是異步測試,調用 ?fakeAsync()? 和 ?tick()?,因為該組件在處理錯誤時會調用 ?setTimeout()?。

看看這個彈珠的可觀察定義。

const q$ = cold('---#|', null, new Error('TwainService test failure'));

這是一個可觀察對象,等待三幀,然后發(fā)出一個錯誤,井號(?#?)標出了在第三個參數中指定錯誤的發(fā)生時間。第二個參數為 null,因為該可觀察對象永遠不會發(fā)出值。

了解彈珠測試

彈珠幀是測試時間線上的虛擬單位。每個符號(-,x,|,#)都表示經過了一幀。

可觀察對象在你訂閱它之前不會產生值。你的大多數應用中可觀察對象都是冷的。所有的 ?HttpClient ?方法返回的都是冷可觀察對象。

熱的可觀察對象在訂閱它之前就已經在生成了這些值。用來報告路由器活動的 ?Router.events? 可觀察對象就是一種可觀察對象。

RxJS 彈珠測試這個主題非常豐富,超出了本指南的范圍。你可以在網上了解它,先從其官方文檔開始。

具有輸入和輸出屬性的組件

具有輸入和輸出屬性的組件通常會出現在宿主組件的視圖模板中。宿主使用屬性綁定來設置輸入屬性,并使用事件綁定來監(jiān)聽輸出屬性引發(fā)的事件。

本測試的目標是驗證這些綁定是否如預期般工作。這些測試應該設置輸入值并監(jiān)聽輸出事件。

?DashboardHeroComponent ?是這類組件的一個小例子。它會顯示由 ?DashboardComponent ?提供的一個英雄。點擊這個英雄就會告訴 ?DashboardComponent?,用戶已經選擇了此英雄。

?DashboardHeroComponent ?會像這樣內嵌在 ?DashboardComponent ?模板中的:

<dashboard-hero *ngFor="let hero of heroes"  class="col-1-4"
  [hero]=hero  (selected)="gotoDetail($event)" >
</dashboard-hero>

?DashboardHeroComponent ?出現在 ?*ngFor? 復寫器中,把它的輸入屬性 ?hero ?設置為當前的循環(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); }
}

在測試一個組件時,像這樣簡單的場景沒什么內在價值,但值得了解它。你可以繼續(xù)嘗試這些方法:

  • 用 ?DashboardComponent ?來測試它。
  • 把它作為一個獨立的組件進行測試。
  • 用 ?DashboardComponent ?的一個替代品來測試它。

快速看一眼 ?DashboardComponent ?構造函數就知道不建議采用第一種方法:

constructor(
  private router: Router,
  private heroService: HeroService) {
}

?DashboardComponent ?依賴于 Angular 的路由器和 ?HeroService?。你可能不得不用測試替身來代替它們,這有很多工作。路由器看上去特別有挑戰(zhàn)性。

當前的目標是測試 ?DashboardHeroComponent?,而不是 ?DashboardComponent?,所以試試第二個和第三個選項。

單獨測試 DashboardHeroComponent

這里是 spec 文件中環(huán)境設置部分的內容。

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();

注意這些設置代碼如何把一個測試英雄(?expectedHero?)賦值給組件的 ?hero ?屬性的,它模仿了 ?DashboardComponent ?在其復寫器中通過屬性綁定來設置它的方式。

下面的測試驗證了英雄名是通過綁定傳播到模板的。

it('should display hero name in uppercase', () => {
  const expectedPipedName = expectedHero.name.toUpperCase();
  expect(heroEl.textContent).toContain(expectedPipedName);
});

因為模板把英雄的名字傳給了 ?UpperCasePipe?,所以測試必須要讓元素值與其大寫形式的名字一致。

這個小測試演示了 Angular 測試會如何驗證一個組件的可視化表示形式 - 這是組件類測試所無法實現的 - 成本相對較低,無需進行更慢、更復雜的端到端測試。

點擊

單擊該英雄應該會讓一個宿主組件(可能是 ?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 ?屬性給消費者返回了一個 ?EventEmitter?,它看起來像是 RxJS 的同步 ?Observable?。該測試只有在宿主組件隱式觸發(fā)時才需要顯式訂閱它。

當組件的行為符合預期時,單擊此英雄的元素就會告訴組件的 ?selected ?屬性發(fā)出了一個 ?hero ?對象。

該測試通過對 ?selected ?的訂閱來檢測該事件。

triggerEventHandler

前面測試中的 ?heroDe ?是一個指向英雄條目 ?<div>? 的 ?DebugElement?。

它有一些用于抽象與原生元素交互的 Angular 屬性和方法。這個測試會使用事件名稱 ?click ?來調用 ?DebugElement.triggerEventHandler?。?click ?的事件綁定到了 ?DashboardHeroComponent.click()?。

Angular 的 ?DebugElement.triggerEventHandler? 可以用事件的名字觸發(fā)任何數據綁定事件。第二個參數是傳給事件處理器的事件對象。

該測試觸發(fā)了一個 “click” 事件。

heroDe.triggerEventHandler('click');

測試程序假設(在這里應該這樣)運行時間的事件處理器(組件的 ?click()? 方法)不關心事件對象。

其它處理器的要求比較嚴格。比如,?RouterLink ?指令期望一個帶有 ?button ?屬性的對象,該屬性用于指出點擊時按下的是哪個鼠標按鈕。如果不給出這個事件對象,?RouterLink ?指令就會拋出一個錯誤。

點擊該元素

下面這個測試改為調用原生元素自己的 ?click()? 方法,它對于這個組件來說相當完美。

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() 幫助器

點擊按鈕、鏈接或者任意 HTML 元素是很常見的測試任務。

點擊事件的處理過程包裝到如下的 ?click()? 輔助函數中,可以讓這項任務更一致、更簡單:

/** 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);
  }
}

第一個參數是用來點擊的元素。如果你愿意,可以將自定義的事件對象傳給第二個參數。 默認的是(局部的)鼠標左鍵事件對象,它被許多事件處理器接受,包括 RouterLink 指令。

?click()? 輔助函數不是Angular 測試工具之一。它是在本章的例子代碼中定義的函數方法,被所有測試例子所用。如果你喜歡它,將它添加到你自己的輔助函數集。

下面是把前面的測試用 ?click ?輔助函數重寫后的版本。

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

位于測試宿主中的組件

前面的這些測試都是自己扮演宿主元素 ?DashboardComponent ?的角色。但是當 ?DashboardHeroComponent ?真的綁定到某個宿主元素時還能正常工作嗎?

固然,你也可以測試真實的 ?DashboardComponent?。但要想這么做需要做很多準備工作,特別是它的模板中使用了某些特性,如 ?*ngFor?、 其它組件、布局 HTML、附加綁定、注入了多個服務的構造函數、如何用正確的方式與那些服務交互等。

想出這么多需要努力排除的干擾,只是為了證明一點 —— 可以造出這樣一個令人滿意的測試宿主

@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;
  }
}

這個測試宿主像 ?DashboardComponent ?那樣綁定了 ?DashboardHeroComponent?,但是沒有 ?Router?、 沒有 ?HeroService?,也沒有 ?*ngFor?。

這個測試宿主使用其測試用的英雄設置了組件的輸入屬性 ?hero?。它使用 ?onSelected ?事件處理器綁定了組件的 ?selected ?事件,其中把事件中發(fā)出的英雄記錄到了 ?selectedHero ?屬性中。

稍后,這個測試就可以輕松檢查 ?selectedHero ?以驗證 ?DashboardHeroComponent.selected? 事件確實發(fā)出了所期望的英雄。

這個測試宿主中的準備代碼和獨立測試中的準備過程類似:

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

這個測試模塊的配置信息有三個重要的不同點:

  • 它同時聲明了 ?DashboardHeroComponent ?和 ?TestHostComponent?。
  • 它創(chuàng)建了 ?TestHostComponent?,而非 ?DashboardHeroComponent?。
  • ?TestHostComponent ?通過綁定機制設置了 ?DashboardHeroComponent.hero?。

?createComponent ?返回的 ?fixture ?里有 ?TestHostComponent ?實例,而非 ?DashboardHeroComponent ?組件實例。

當然,創(chuàng)建 ?TestHostComponent ?有創(chuàng)建 ?DashboardHeroComponent ?的副作用,因為后者出現在前者的模板中。英雄元素(?heroEl?)的查詢語句仍然可以在測試 DOM 中找到它,盡管元素樹比以前更深。

這些測試本身和它們的孤立版本幾乎相同:

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 事件的測試不一樣。它確保被選擇的 ?DashboardHeroComponent ?英雄確實通過事件綁定被傳遞到宿主組件。

路由組件

所謂路由組件就是指會要求 ?Router ?導航到其它組件的組件。?DashboardComponent ?就是一個路由組件,因為用戶可以通過點擊儀表盤中的某個英雄按鈕來導航到 ?HeroDetailComponent?。

路由確實很復雜。測試 ?DashboardComponent ?看上去有點令人生畏,因為它牽扯到和 ?HeroService ?一起注入進來的 ?Router?。

constructor(
  private router: Router,
  private heroService: HeroService) {
}

使用間諜來 Mock ?HeroService ?是一個熟悉的故事。 但是 ?Router ?的 API 很復雜,并且與其它服務和應用的前置條件糾纏在一起。它應該很難進行 Mock 吧?

慶幸的是,在這個例子中不會,因為 ?DashboardComponent ?并沒有深度使用 ?Router?。

gotoDetail(hero: Hero) {
  const url = `/heroes/${hero.id}`;
  this.router.navigateByUrl(url);
}

這是路由組件中的通例。一般來說,你應該測試組件而不是路由器,應該只關心組件有沒有根據給定的條件導航到正確的地址。

這個組件的測試套件提供路由器的間諜就像提供 ?HeroService ?的間諜一樣簡單。

const routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']);
const heroServiceSpy = jasmine.createSpyObj('HeroService', ['getHeroes']);

TestBed
    .configureTestingModule({
      providers: [
        {provide: HeroService, useValue: heroServiceSpy}, {provide: Router, useValue: routerSpy}
      ]
    })

下面這個測試會點擊正在顯示的英雄,并確認 ?Router.navigateByUrl? 曾用所期待的 URL 調用過。

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

路由目標組件

路由目標組件是指 ?Router ?導航到的目標。它測試起來可能很復雜,特別是當路由到的這個組件包含參數的時候。?HeroDetailComponent ?就是一個路由目標組件,它是某個路由定義指向的目標。

當用戶點擊儀表盤中的英雄時,?DashboardComponent ?會要求 ?Router ?導航到 ?heroes/:id?。?:id? 是一個路由參數,它的值就是所要編輯的英雄的 ?id?。

該 ?Router ?會根據那個 URL 匹配到一個指向 ?HeroDetailComponent ?的路由。它會創(chuàng)建一個帶有路由信息的 ?ActivatedRoute ?對象,并把它注入到一個 ?HeroDetailComponent ?的新實例中。

下面是 ?HeroDetailComponent ?的構造函數:

constructor(
  private heroDetailService: HeroDetailService,
  private route: ActivatedRoute,
  private router: Router) {
}

?HeroDetailComponent ?組件需要一個 ?id ?參數,以便通過 ?HeroDetailService ?獲取相應的英雄。該組件只能從 ?ActivatedRoute.paramMap? 屬性中獲取這個 ?id?,這個屬性是一個 ?Observable?。

它不能僅僅引用 ?ActivatedRoute.paramMap? 的 ?id ?屬性。該組件不得不訂閱 ?ActivatedRoute.paramMap? 這個可觀察對象,要做好它在生命周期中隨時會發(fā)生變化的準備。

ngOnInit(): void {
  // get hero when `id` param changes
  this.route.paramMap.subscribe(pmap => this.getHero(pmap.get('id')));
}

通過操縱注入到組件構造函數中的這個 ?ActivatedRoute?,測試可以探查 ?HeroDetailComponent ?是如何對不同的 ?id ?參數值做出響應的。

你已經知道了如何給 ?Router ?和數據服務安插間諜。

不過對于 ?ActivatedRoute?,你要采用另一種方式,因為:

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

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

ActivatedRouteStub

下面的 ?ActivatedRouteStub ?類就是作為 ?ActivatedRoute ?類的測試替身使用的。

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

考慮把這類輔助函數放進一個緊鄰 ?app ?文件夾的 ?testing ?文件夾。這個例子把 ?ActivatedRouteStub ?放在了 ?testing/activated-route-stub.ts? 中。

使用 ActivatedRouteStub 進行測試

下面的測試程序是演示組件在被觀察的 ?id ?指向現有英雄時的行為:

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

稍后會對 ?createComponent()? 方法和 ?page ?對象進行討論。不過目前,你只要憑直覺來理解就行了。

當找不到 ?id ?的時候,組件應該重新路由到 ?HeroListComponent?。

測試套件的準備代碼提供了一個和前面一樣的路由器間諜,它會充當路由器的角色,而不用發(fā)起實際的導航。

這個測試中會期待該組件嘗試導航到 ?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);
  });
});

雖然本應用沒有在缺少 ?id ?參數的時候,繼續(xù)導航到 ?HeroDetailComponent ?的路由,但是,將來它可能會添加這樣的路由。當沒有 ?id ?時,該組件應該作出合理的反應。

在本例中,組件應該創(chuàng)建和顯示新英雄。新英雄的 ?id ?為零,?name ?為空。本測試程序確認組件是按照預期的這樣做的:

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

對嵌套組件的測試

組件的模板中通常還會有嵌套組件,嵌套組件的模板還可能包含更多組件。

這棵組件樹可能非常深,并且大多數時候在測試這棵樹頂部的組件時,這些嵌套的組件都無關緊要。

比如,?AppComponent ?會顯示一個帶有鏈接及其 ?RouterLink ?指令的導航條。

<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 ?類是空的,但你可能會希望寫個單元測試來確認這些鏈接是否正確使用了 ?RouterLink ?指令。

要想驗證這些鏈接,你不必用 ?Router ?進行導航,也不必使用 ?<router-outlet>? 來指出 ?Router ?應該把路由目標組件插入到什么地方。

而 ?BannerComponent ?和 ?WelcomeComponent?(寫作 ?<app-banner>? 和 ?<app-welcome>?)也同樣風馬牛不相及。

然而,任何測試,只要能在 DOM 中創(chuàng)建 ?AppComponent?,也就同樣能創(chuàng)建這三個組件的實例。如果要創(chuàng)建它們,你就要配置 ?TestBed?。

如果你忘了聲明它們,Angular 編譯器就無法在 ?AppComponent ?模板中識別出 ?<app-banner>?、?<app-welcome>? 和 ?<router-outlet>? 標記,并拋出一個錯誤。

如果你聲明的這些都是真實的組件,那么也同樣要聲明它們的嵌套組件,并要為這棵組件樹中的任何組件提供要注入的所有服務。

如果只是想回答關于鏈接的一些簡單問題,做這些顯然就太多了。

本節(jié)會講減少此類準備工作的兩項技術。單獨使用或組合使用它們,可以讓這些測試聚焦于要測試的主要組件上。

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

這項技術中,你要為那些在測試中無關緊要的組件或指令創(chuàng)建和聲明一些測試樁。

@Component({selector: 'app-banner', template: ''})
class BannerStubComponent {
}

@Component({selector: 'router-outlet', template: ''})
class RouterOutletStubComponent {
}

@Component({selector: 'app-welcome', template: ''})
class WelcomeStubComponent {
}

這些測試樁的選擇器要和其對應的真實組件一致,但其模板和類是空的。

然后在 ?TestBed ?的配置中那些真正有用的組件、指令、管道之后聲明它們。

TestBed
    .configureTestingModule({
      declarations: [
        AppComponent, RouterLinkDirectiveStub, BannerStubComponent, RouterOutletStubComponent,
        WelcomeStubComponent
      ]
    })

?AppComponent ?是該測試的主角,因此當然要用它的真實版本。

而 ?RouterLinkDirectiveStub ?是一個真實的 ?RouterLink ?的測試版,它能幫你對鏈接進行測試。

其它都是測試樁。

NO_ERRORS_SCHEMA

第二種辦法就是把 ?NO_ERRORS_SCHEMA ?添加到 ?TestBed.schemas? 的元數據中。

TestBed
    .configureTestingModule({
      declarations: [
        AppComponent,
        RouterLinkDirectiveStub
      ],
      schemas: [NO_ERRORS_SCHEMA]
    })

?NO_ERRORS_SCHEMA ?會要求 Angular 編譯器忽略不認識的那些元素和屬性。

編譯器將會識別出 ?<app-root>? 元素和 ?RouterLink ?屬性,因為你在 ?TestBed ?的配置中聲明了相應的 ?AppComponent ?和 ?RouterLinkDirectiveStub?。

但編譯器在遇到 ?<app-banner>?、?<app-welcome>? 或 ?<router-outlet>? 時不會報錯。它只會把它們渲染成空白標簽,而瀏覽器會忽略這些標簽。

你不用再提供樁組件了。

同時使用這兩項技術

這些是進行淺層測試要用到的技術,之所以叫淺層測試是因為只包含本測試所關心的這個組件模板中的元素。

?NO_ERRORS_SCHEMA ?方法在這兩者中比較簡單,但也不要過度使用它。

?NO_ERRORS_SCHEMA ?還會阻止編譯器告訴你因為的疏忽或拼寫錯誤而缺失的組件和屬性。你如果人工找出這些 bug 可能要浪費幾個小時,但編譯器可以立即捕獲它們。

樁組件方式還有其它優(yōu)點。雖然這個例子中的樁是空的,但你如果想要和它們用某種形式互動,也可以給它們一些裁剪過的模板和類。

在實踐中,你可以在準備代碼中組合使用這兩種技術,例子如下。

TestBed
    .configureTestingModule({
      declarations: [
        AppComponent,
        BannerStubComponent,
        RouterLinkDirectiveStub
      ],
      schemas: [NO_ERRORS_SCHEMA]
    })

Angular 編譯器會為 ?<app-banner>? 元素創(chuàng)建 ?BannerComponentStub?,并把 ?RouterLinkStubDirective ?應用到帶有 ?routerLink ?屬性的鏈接上,不過它會忽略 ?<app-welcome>? 和 ?<router-outlet>? 標簽。

帶有 RouterLink 的組件

真實的 ?RouterLinkDirective ?太復雜了,而且與 ?RouterModule ?中的其它組件和指令有著千絲萬縷的聯系。要在準備階段 Mock 它以及在測試中使用它具有一定的挑戰(zhàn)性。

這段范例代碼中的 ?RouterLinkDirectiveStub ?用一個代用品替換了真實的指令,這個代用品用來驗證 ?AppComponent ?中所用鏈接的類型。

@Directive({
  selector: '[routerLink]'
})
export class RouterLinkDirectiveStub {
  @Input('routerLink') linkParams: any;
  navigatedTo: any = null;

  @HostListener('click')
  onClick() {
    this.navigatedTo = this.linkParams;
  }
}

這個 URL 被綁定到了 ?[routerLink]? 屬性,它的值流入了該指令的 ?linkParams ?屬性。

它的元數據中的 ?host ?屬性把宿主元素(即 ?AppComponent ?中的 ?<a>? 元素)的 ?click ?事件關聯到了這個樁指令的 ?onClick ?方法。

點擊這個鏈接應該觸發(fā) ?onClick()? 方法,其中會設置該樁指令中的警示器屬性 ?navigatedTo?。測試中檢查 ?navigatedTo ?以確認點擊該鏈接確實如預期的那樣根據路由定義設置了該屬性。

路由器的配置是否正確和是否能按照那些路由定義進行導航,是測試中一組獨立的問題。

By.directive 與注入的指令

再一步配置觸發(fā)了數據綁定的初始化,獲取導航鏈接的引用:

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

有三點特別重要:

  • 你可以使用 ?By.directive? 來定位一個帶附屬指令的鏈接元素。
  • 該查詢返回包含了匹配元素的 ?DebugElement ?包裝器。
  • 每個 ?DebugElement ?都會導出該元素中的一個依賴注入器,其中帶有指定的指令實例。

?AppComponent ?中要驗證的鏈接如下:

<nav>
  <a routerLink="/dashboard">Dashboard</a>
  <a routerLink="/heroes">Heroes</a>
  <a routerLink="/about">About</a>
</nav>

下面這些測試用來確認那些鏈接是否如預期般連接到了 ?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');
});
其實這個例子中的“click”測試誤入歧途了。它測試的重點其實是 ?RouterLinkDirectiveStub?,而不是該組件。這是寫樁指令時常見的錯誤。
在本章中,它有存在的必要。它演示了如何在不涉及完整路由器機制的情況下,如何找到 ?RouterLink ?元素、點擊它并檢查結果。要測試更復雜的組件,你可能需要具備這樣的能力,能改變視圖和重新計算參數,或者當用戶點擊鏈接時,有能力重新安排導航選項。

這些測試有什么優(yōu)點?

用 ?RouterLink ?的樁指令進行測試可以確認帶有鏈接和 outlet 的組件的設置的正確性,確認組件有應該有的鏈接,確認它們都指向了正確的方向。這些測試程序不關心用戶點擊鏈接時,也不關心應用是否會成功的導航到目標組件。

對于這些有限的測試目標,使用 RouterLink 樁指令和 RouterOutlet 樁組件 是最佳選擇。依靠真正的路由器會讓它們很脆弱。它們可能因為與組件無關的原因而失敗。比如,一個導航守衛(wèi)可能防止沒有授權的用戶訪問 ?HeroListComponent?。這并不是 ?AppComponent ?的過錯,并且無論該組件怎么改變都無法修復這個失敗的測試程序。

一組不同的測試程序可以探索當存在影響守衛(wèi)的條件時(比如用戶是否已認證和授權),該應用是否如期望般導航。

使用 page 對象

?HeroDetailComponent ?是帶有標題、兩個英雄字段和兩個按鈕的簡單視圖。


但即使是這么簡單的表單,其模板中也涉及到不少復雜性。

<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>

這些供練習用的組件需要 ……

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

即使是像這樣一個很小的表單,也能產生令人瘋狂的錯綜復雜的條件設置和 CSS 元素選擇。

可以使用 ?Page ?類來征服這種復雜性。?Page ?類可以處理對組件屬性的訪問,并對設置這些屬性的邏輯進行封裝。

下面是一個供 ?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);
  }
}

現在,用來操作和檢查組件的重要鉤子都被井然有序的組織起來了,可以通過 ?page ?實例來使用它們。

?createComponent ?方法會創(chuàng)建一個 ?page ?對象,并在 ?hero ?到來時自動填補空白。

/** 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 測試示范了如何 ?createComponent?,而 ?page ?讓這些測試保持簡短而富有表達力。 而且還不用分心:不用等待承諾被解析,不必在 DOM 中找出元素的值才能進行比較。

還有更多的 ?HeroDetailComponent ?測試可以證明這一點。

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

調用 compileComponents()

如果你只想使用 CLI 的 ?ng test? 命令來運行測試,那么可以忽略這一節(jié)。

如果你在非 CLI 環(huán)境中運行測試,這些測試可能會報錯,錯誤信息如下:

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.

問題的根源在于這個測試中至少有一個組件引用了外部模板或外部 CSS 文件,就像下面這個版本的 ?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';
}

當 ?TestBed ?視圖創(chuàng)建組件時,這個測試失敗了。

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ BannerComponent ],
  }); // missing call to compileComponents()
  fixture = TestBed.createComponent(BannerComponent);
});

回想一下,這個應用從未編譯過。所以當你調用 ?createComponent()? 的時候,?TestBed ?就會進行隱式編譯。

當它的源碼都在內存中的時候,這樣做沒問題。不過 ?BannerComponent ?需要一些外部文件,編譯時必須從文件系統(tǒng)中讀取它,而這是一個天生的異步操作。

如果 ?TestBed ?繼續(xù)執(zhí)行,這些測試就會繼續(xù)運行,并在編譯器完成這些異步工作之前導致莫名其妙的失敗。

這些錯誤信息告訴你要使用 ?compileComponents()? 進行顯式的編譯。

compileComponents() 是異步的

你必須在異步測試函數中調用 ?compileComponents()?。

如果你忘了把測試函數標為異步的(比如忘了像稍后的代碼中那樣使用 ?waitForAsync()?),就會看到下列錯誤。

Error: ViewDestroyedError: Attempt to use a destroyed view

典型的做法是把準備邏輯拆成兩個獨立的 ?beforeEach()? 函數:

函數

詳情

異步 beforeEach()

負責編譯組件

同步 beforeEach()

負責執(zhí)行其余的準備代碼

異步的 beforeEach

像下面這樣編寫第一個異步的 ?beforeEach?。

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ BannerComponent ],
  }).compileComponents();  // compile template and css
});

?TestBed.configureTestingModule()? 方法返回 ?TestBed ?類,所以你可以鏈式調用其它 ?TestBed ?中的靜態(tài)方法,比如 ?compileComponents()?。

在這個例子中,?BannerComponent ?是僅有的待編譯組件。其它例子中可能會使用多個組件來配置測試模塊,并且可能引入某些具有其它組件的應用模塊。它們中的任何一個都可能需要外部文件。

?TestBed.compileComponents? 方法會異步編譯測試模塊中配置過的所有組件。

在調用了 ?compileComponents()? 之后就不能再重新配置 ?TestBed ?了。

調用 ?compileComponents()? 會關閉當前的 ?TestBed ?實例,不再允許進行配置。你不能再調用任何 ?TestBed ?中的配置方法,既不能調 ?configureTestingModule()?,也不能調用任何 ?override...? 方法。如果你試圖這么做,?TestBed ?就會拋出錯誤。

確保 ?compileComponents()? 是調用 ?TestBed.createComponent()? 之前的最后一步。

同步的 beforeEach

第二個同步 ?beforeEach()? 的例子包含剩下的準備步驟,包括創(chuàng)建組件和查詢那些要檢查的元素。

beforeEach(() => {
  fixture = TestBed.createComponent(BannerComponent);
  component = fixture.componentInstance;  // BannerComponent test instance
  h1 = fixture.nativeElement.querySelector('h1');
});

測試運行器(runner)會先等待第一個異步 ?beforeEach ?函數執(zhí)行完再調用第二個。

整理過的準備代碼

你可以把這兩個 ?beforeEach()? 函數重整成一個異步的 ?beforeEach()?。

?compileComponents()? 方法返回一個承諾,所以你可以通過把同步代碼移到 ?await ?關鍵字后面,在那里,這個 Promise 已經解析了。

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ BannerComponent ],
  }).compileComponents();
  fixture = TestBed.createComponent(BannerComponent);
  component = fixture.componentInstance;
  h1 = fixture.nativeElement.querySelector('h1');
});

compileComponents() 是無害的

在不需要 ?compileComponents()? 的時候調用它也不會有害處。

雖然在運行 ?ng test? 時永遠都不需要調用 ?compileComponents()?,但 CLI 生成的組件測試文件還是會調用它。

但這篇指南中的這些測試只會在必要時才調用 ?compileComponents?。

準備模塊的 imports

此前的組件測試程序使用了一些 ?declarations ?來配置模塊,就像這樣:

TestBed
    .configureTestingModule({declarations: [DashboardHeroComponent]})

?DashbaordComponent ?非常簡單。它不需要幫助。但是更加復雜的組件通常依賴其它組件、指令、管道和提供者,所以這些必須也被添加到測試模塊中。

幸運的是,?TestBed.configureTestingModule? 參數與傳入 ?@NgModule? 裝飾器的元數據一樣,也就是所你也可以指定 ?providers ?和 ?imports?。

雖然 ?HeroDetailComponent ?很小,結構也很簡單,但是它需要很多幫助。除了從默認測試模塊 ?CommonModule ?中獲得的支持,它還需要:

  • ?FormsModule ?里的 ?NgModel ?和其它,來進行雙向數據綁定
  • ?shared ?目錄里的 ?TitleCasePipe ?
  • 一些路由器服務(測試程序將 stub 偽造它們)
  • 英雄數據訪問服務(同樣被 stub 偽造了)

一種方法是從各個部分配置測試模塊,就像這樣:

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()? 是異步的,它調用 ?TestBed.compileComponents? 是因為 ?HeroDetailComponent? 有外部模板和 CSS 文件。
調用 compileComponents() 中所解釋的那樣,這些測試可以運行在非 CLI 環(huán)境下,那里 Angular 并不會在瀏覽器中編譯它們。

導入共享模塊

因為很多應用組件都需要 ?FormsModule ?和 ?TitleCasePipe?,所以開發(fā)者創(chuàng)建了 ?SharedModule ?來把它們及其它常用的部分組合在一起。

這些測試配置也可以使用 ?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();
});

它的導入聲明少一些(未顯示),稍微干凈一些,小一些。

導入特性模塊

?HeroDetailComponent ?是 ?HeroModule ?這個特性模塊的一部分,它聚合了更多相互依賴的片段,包括 ?SharedModule?。試試下面這個導入了 ?HeroModule ?的測試配置:

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 ?里面的測試替身被保留。連 ?HeroDetailComponent ?聲明都消失了。

事實上,如果你試圖聲明它,Angular 就會拋出一個錯誤,因為 ?HeroDetailComponent ?同時聲明在了 ?HeroModule ?和 ?TestBed ?創(chuàng)建的 ?DynamicTestModule ?中。

如果模塊中有很多共同依賴,并且該模塊很小(這也是特性模塊的應有形態(tài)),那么直接導入組件的特性模塊可以成為配置這些測試的最佳方式。

改寫組件的服務提供者

?HeroDetailComponent ?提供自己的 ?HeroDetailService ?服務。

@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 ?是不可行的。這些是測試模塊的提供者,而非組件的。組件級別的提供者應該在 fixture 級別的依賴注入器中進行準備。

Angular 會使用自己的注入器來創(chuàng)建這些組件,這個注入器是夾具的注入器的子注入器。它使用這個子注入器注冊了該組件服務提供者(這里是 ?HeroDetailService?)。

測試沒辦法從測試夾具的注入器中獲取子注入器中的服務,而 ?TestBed.configureTestingModule? 也沒法配置它們。

Angular 始終都在創(chuàng)建真實 ?HeroDetailService ?的實例。

如果 ?HeroDetailService ?向遠程服務器發(fā)出自己的 XHR 請求,這些測試可能會失敗或者超時。這個遠程服務器可能根本不存在。
幸運的是,?HeroDetailService ?將遠程數據訪問的責任交給了注入進來的 ?HeroService?。
@Injectable()
export class HeroDetailService {
  constructor(private heroService: HeroService) {  }
/* . . . */
}
前面的測試配置使用 ?TestHeroService? 替換了真實的 ?HeroService?,它攔截了發(fā)往服務器的請求,并偽造了服務器的響應。

如果你沒有這么幸運怎么辦?如果偽造 ?HeroService ?很難怎么辦?如果 ?HeroDetailService ?自己發(fā)出服務器請求怎么辦?

?TestBed.overrideComponent? 方法可以將組件的 ?providers ?替換為容易管理的測試替身,參閱下面的變體準備代碼:

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?,因為并不需要。

overrideComponent 方法

注意這個 ?overrideComponent ?方法。

.overrideComponent(
    HeroDetailComponent,
    {set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}})

它接受兩個參數:要改寫的組件類型(?HeroDetailComponent?),以及用于改寫的元數據對象。用于改寫的元數據對象是一個泛型,其定義如下:

type MetadataOverride<T> = {
  add?: Partial<T>;
  remove?: Partial<T>;
  set?: Partial<T>;
};

元數據重載對象可以添加和刪除元數據屬性的項目,也可以徹底重設這些屬性。這個例子重新設置了組件的 ?providers ?元數據。

這個類型參數 ?T? 就是你傳給 ?@Component? 裝飾器的元數據:

selector?: string;
template?: string;
templateUrl?: string;
providers?: any[];
…

提供 間諜樁 (HeroDetailServiceSpy)

這個例子把組件的 ?providers ?數組完全替換成了一個包含 ?HeroDetailServiceSpy ?的新數組。

?HeroDetailServiceSpy ?是實際 ?HeroDetailService ?服務的樁版本,它偽造了該服務的所有必要特性。但它既不需要注入也不會委托給低層的 ?HeroService ?服務,因此不用為 ?HeroService ?提供測試替身。

通過對該服務的方法進行刺探,?HeroDetailComponent ?的關聯測試將會對 ?HeroDetailService ?是否被調用過進行斷言。因此,這個樁類會把它的方法實現為刺探方法:

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

改寫測試

現在,測試程序可以通過操控這個 spy-stub 的 ?testHero?,直接控制組件的英雄,并確認那個服務方法被調用過。

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? 方法可以在相同或不同的組件中被反復調用。?TestBed ?還提供了類似的 ?overrideDirective?、?overrideModule ?和 ?overridePipe ?方法,用來深入并重載這些其它類的部件。


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號