填寫這份《一分鐘調查》,幫我們(開發組)做得更好!去填寫Home

元件測試場景

Component testing scenarios

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

This guide explores common component testing use cases.

對於本測試指南中描述的範例應用,參閱範例應用範例應用

For the sample app that the testing guides describe, see thesample appsample app.

要了解本測試指南中涉及的測試特性,請參閱teststests

For the tests features in the testing guides, seeteststests.

元件繫結

Component binding

在範例應用中,BannerComponent 在 HTML 範本中展示了靜態的標題文字。

In the example app, the BannerComponent presents static title text in the HTML template.

在少許更改之後,BannerComponent 就會透過繫結元件的 title 屬性來渲染動態標題。

After a few changes, the BannerComponent presents a dynamic title by binding to the component's title property like this.

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

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

As minimal as this is, you decide to add a test to confirm that component actually displays the right content where you think it should.

查詢 <h1> 元素

Query for the <h1>

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

You'll write a sequence of tests that inspect the value of the <h1> element that wraps the title property interpolation binding.

你可以修改 beforeEach 以找到帶有標準 HTML querySelector 的元素,並把它賦值給 h1 變數。

You update the beforeEach to find that element with a standard HTML querySelector and assign it to the h1 variable.

app/banner/banner.component.spec.ts (setup)
      
      let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;

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

createComponent() 不繫結資料

createComponent() does not bind data

對於你的第一個測試,你希望螢幕上顯示預設的 title 。你的直覺就是編寫一個能立即檢查 <h1> 的測試,就像這樣:

For your first test you'd like to see that the screen displays the default title. Your instinct is to write a test that immediately inspects the <h1> like this:

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

那個測試失敗了:

That test fails with the message:

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

當 Angular 執行變更檢測時就會發生繫結。

Binding happens when Angular performs change detection.

在生產環境中,當 Angular 建立一個元件,或者使用者輸入按鍵,或者非同步活動(比如 AJAX)完成時,就會自動進行變更檢測。

In production, change detection kicks in automatically when Angular creates a component or the user enters a keystroke or an asynchronous activity (e.g., AJAX) completes.

TestBed.createComponent 不會觸發變化檢測,修改後的測試可以證實這一點:

The TestBed.createComponent does not trigger change detection; a fact confirmed in the revised test:

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

detectChanges()

你必須透過呼叫 fixture.detectChanges() 來告訴 TestBed 執行資料繫結。只有這樣, <h1> 才能擁有預期的標題。

You must tell the TestBed to perform data binding by calling fixture.detectChanges(). Only then does the <h1> have the expected title.

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

這裡延遲變更檢測時機是故意而且有用的。這樣才能讓測試者在 Angular 啟動資料繫結並呼叫生命週期鉤子之前,檢視並更改元件的狀態。

Delayed change detection is intentional and useful. It gives the tester an opportunity to inspect and change the state of the component before Angular initiates data binding and calls lifecycle hooks.

這是另一個測試,它會在呼叫 fixture.detectChanges() 之前改變元件的 title 屬性。

Here's another test that changes the component's title property before calling fixture.detectChanges().

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

自動變更檢測

Automatic change detection

BannerComponent 測試會經常呼叫 detectChanges。一些測試人員更喜歡讓 Angular 測試環境自動執行變更檢測。

The BannerComponent tests frequently call detectChanges. Some testers prefer that the Angular test environment run change detection automatically.

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

That's possible by configuring the TestBed with the ComponentFixtureAutoDetect provider. First import it from the testing utility library:

app/banner/banner.component.detect-changes.spec.ts (import)
      
      import { ComponentFixtureAutoDetect } from '@angular/core/testing';
    

然後把它新增到測試模組配置的 providers 中:

Then add it to the providers array of the testing module configuration:

app/banner/banner.component.detect-changes.spec.ts (AutoDetect)
      
      TestBed.configureTestingModule({
  declarations: [ BannerComponent ],
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
});
    

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

Here are three tests that illustrate how automatic change detection works.

app/banner/banner.component.detect-changes.spec.ts (AutoDetect Tests)
      
      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);
});
    

第一個測試顯示了自動變更檢測的優點。

The first test shows the benefit of automatic change detection.

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

The second and third test reveal an important limitation. The Angular testing environment does not know that the test changed the component's title. The ComponentFixtureAutoDetect service responds to asynchronous activities such as promise resolution, timers, and DOM events. But a direct, synchronous update of the component property is invisible. The test must call fixture.detectChanges() manually to trigger another cycle of change detection.

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

Rather than wonder when the test fixture will or won't perform change detection, the samples in this guide always call detectChanges() explicitly. There is no harm in calling detectChanges() more often than is strictly necessary.

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

Change an input value with dispatchEvent()

要模擬使用者輸入,你可以找到 input 元素並設定它的 value 屬性。

To simulate user input, you can find the input element and set its value property.

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

You will call fixture.detectChanges() to trigger Angular's change detection. But there is an essential, intermediate step.

Angular 並不知道你為 input 設定過 value 屬性。在透過呼叫 dispatchEvent() 分發 input 事件之前,它不會讀取該屬性。緊接著你就呼叫了 detectChanges()

Angular doesn't know that you set the input element's value property. It won't read that property until you raise the element's input event by calling dispatchEvent(). Then you call detectChanges().

下列例子說明了正確的順序。

The following example demonstrates the proper sequence.

app/hero/hero-detail.component.spec.ts (pipe test)
      
      it('should convert hero name to Title Case', () => {
  // get the name's input and display elements from the DOM
  const hostElement = 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.
  // In older browsers, such as IE, you might need a CustomEvent instead. See
  // https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#Polyfill
  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');
});
    

包含外部檔案的元件

Component with external files

上面的 BannerComponent 是用內聯範本內聯 css 定義的,它們分別是在 @Component.template@Component.styles 屬性中指定的。

The BannerComponent above is defined with an inline template and inline css, specified in the @Component.template and @Component.styles properties respectively.

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

Many components specify external templates and external css with the @Component.templateUrl and @Component.styleUrls properties respectively, as the following variant of BannerComponent does.

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

這個語法告訴 Angular 編譯器要在元件編譯時讀取外部檔案。

This syntax tells the Angular compiler to read the external files during component compilation.

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

That's not a problem when you run the CLI ng test command because it compiles the app before running the tests.

但是,如果在非 CLI 環境中執行這些測試,那麼這個元件的測試可能會失敗。例如,如果你在一個 web 程式設計環境(比如 plunker 中執行 BannerComponent 測試,你會看到如下訊息:

However, if you run the tests in a non-CLI environment, tests of this component may fail. For example, if you run the BannerComponent tests in a web coding environment such as plunker, you'll see a message like this one:

      
      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.
    

當執行環境在測試過程中需要編譯原始碼時,就會得到這條測試失敗的訊息。

You get this test failure message when the runtime environment compiles the source code during the tests themselves.

要解決這個問題,可以呼叫 compileComponents()如下所示

To correct the problem, call compileComponents() as explained below.

具有依賴的元件

Component with a dependency

元件通常都有服務依賴。

Components often have service dependencies.

WelcomeComponent 會向登入使用者顯示一條歡迎資訊。它可以基於注入進來的 UserService 的一個屬性瞭解到使用者是誰:

The WelcomeComponent displays a welcome message to the logged in user. It knows who the user is based on a property of the injected UserService:

app/welcome/welcome.component.ts
      
      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 檔案的測試模組配置:

The WelcomeComponent has decision logic that interacts with the service, logic that makes this component worth testing. Here's the testing module configuration for the spec file:

app/welcome/welcome.component.spec.ts
      
      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

This time, in addition to declaring the component-under-test, the configuration adds a UserService provider to the providers list. But not the real UserService.

為服務提供測試替身

Provide service test doubles

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

A component-under-test doesn't have to be injected with real services. In fact, it is usually better if they are test doubles (stubs, fakes, spies, or mocks). The purpose of the spec is to test the component, not the service, and real services can be trouble.

注入真正的 UserService 可能是個噩夢。真正的服務可能要求使用者提供登入憑據,並嘗試訪問認證伺服器。這些行為可能難以攔截。為它建立並註冊一個測試專用版來代替真正的 UserService 要容易得多,也更安全。

Injecting the real UserService could be a nightmare. The real service might ask the user for login credentials and attempt to reach an authentication server. These behaviors can be hard to intercept. It is far easier and safer to create and register a test double in place of the real UserService.

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

This particular test suite supplies a minimal mock of the UserService that satisfies the needs of the WelcomeComponent and its tests:

app/welcome/welcome.component.spec.ts
      
      let userServiceStub: Partial<UserService>;

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

取得所注入的服務

Get injected services

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

The tests need access to the (stub) UserService injected into the WelcomeComponent.

Angular 有一個分層注入系統。它具有多個層級的注入器,從 TestBed 建立的根注入器開始,直到元件樹中的各個層級。

Angular has a hierarchical injection system. There can be injectors at multiple levels, from the root injector created by the TestBed down through the component tree.

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

The safest way to get the injected service, the way that always works, is to get it from the injector of the component-under-test. The component injector is a property of the fixture's DebugElement.

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

TestBed.inject()

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

You may also be able to get the service from the root injector via TestBed.inject(). This is easier to remember and less verbose. But it only works when Angular injects the component with the service instance in the test's root injector.

在下面這個測試套件中, UserService唯一的提供者是根測試模組,因此可以安全地呼叫 TestBed.inject() ,如下所示:

In this test suite, the only provider of UserService is the root testing module, so it is safe to call TestBed.inject() as follows:

TestBed injector
      
      // UserService from the root injector
userService = TestBed.inject(UserService);
    

TestBed.inject() 不起作用的用例,參閱“覆蓋元件提供者”部分,它解釋了何時以及為什麼必須從該元件自身的注入器中獲取該服務。

For a use case in which TestBed.inject() does not work, see the Override component providers section that explains when and why you must get the service from the component's injector instead.

最後的設定與測試

Final setup and tests

這裡是完成的 beforeEach() ,它使用了 TestBed.inject()

Here's the complete beforeEach(), using TestBed.inject():

app/welcome/welcome.component.spec.ts
      
      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');
});
    

以下是一些測試:

And here are some tests:

app/welcome/welcome.component.spec.ts
      
      it('should welcome the user', () => {
  fixture.detectChanges();
  const content = el.textContent;
  expect(content).toContain('Welcome', '"Welcome ..."');
  expect(content).toContain('Test User', 'expected name');
});

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).not.toContain('Welcome', 'not welcomed');
  expect(content).toMatch(/log in/i, '"log in"');
});
    

首先是一個健全性測試;它確認了樁服務 UserService 被呼叫過並能正常工作。

The first is a sanity test; it confirms that the stubbed UserService is called and working.

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

The second parameter to the Jasmine matcher (e.g., 'expected name') is an optional failure label. If the expectation fails, Jasmine appends this label to the expectation failure message. In a spec with multiple expectations, it can help clarify what went wrong and which expectation failed.

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

The remaining tests confirm the logic of the component when the service returns different values. The second test validates the effect of changing the user name. The third test checks that the component displays the proper message when there is no logged-in user.

帶非同步服務的元件

Component with async service

在這個例子中,AboutComponent 範本託管了一個 TwainComponentTwainComponent 會顯示馬克·吐溫的名言。

In this sample, the AboutComponent template hosts a TwainComponent. The TwainComponent displays Mark Twain quotes.

app/twain/twain.component.ts (template)
      
      template: `
  <p class="twain"><i>{{quote | async}}</i></p>
  <button (click)="getQuote()">Next quote</button>
  <p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>`,
    

注意,元件的 quote 屬性值會傳給 AsyncPipe。這意味著該屬性返回了 PromiseObservable

Note that the value of the component's quote property passes through an AsyncPipe. That means the property returns either a Promise or an Observable.

在這個例子中, TwainComponent.getQuote() 方法告訴你 quote 屬性會返回一個 Observable

In this example, the TwainComponent.getQuote() method tells you that the quote property returns an Observable.

app/twain/twain.component.ts (getQuote)
      
      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 中獲取名言。該在服務能返回第一條名言之前,該服務會先返回一個佔位流('...')。

The TwainComponent gets quotes from an injected TwainService. The component starts the returned Observable with a placeholder value ('...'), before the service can return its first quote.

catchError 會攔截服務錯誤,準備一條錯誤資訊,並在流的成功通道上返回佔位值。它必須等一拍(tick)才能設定 errorMessage,以免在同一個更改檢測週期內更新此訊息兩次。

The catchError intercepts service errors, prepares an error message, and returns the placeholder value on the success channel. It must wait a tick to set the errorMessage in order to avoid updating that message twice in the same change detection cycle.

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

These are all features you'll want to test.

使用間諜(spy)進行測試

Testing with a spy

在測試元件時,只有該服務的公開 API 才有意義。通常,測試本身不應該呼叫遠端伺服器。它們應該模擬這樣的呼叫。這個 app/twain/twain.component.spec.ts 中的環境準備工作展示了一種方法:

When testing a component, only the service's public API should matter. In general, tests themselves should not make calls to remote servers. They should emulate such calls. The setup in this app/twain/twain.component.spec.ts shows one way to do that:

app/twain/twain.component.spec.ts (setup)
      
      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');
});
    

仔細看一下這個間諜。

Focus on the spy.

      
      // 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() 方法不同,這個間諜會繞過伺服器,並返回一個立即同步提供可用值的可觀察物件。

The spy is designed such that any call to getQuote receives an observable with a test quote. Unlike the real getQuote() method, this spy bypasses the server and returns a synchronous observable whose value is available immediately.

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

You can write many useful tests with this spy, even though its Observable is synchronous.

同步測試

Synchronous tests

同步 Observable 的一個關鍵優勢是,你通常可以把非同步過程轉換成同步測試。

A key advantage of a synchronous Observable is that you can often turn asynchronous processes into synchronous tests.

      
      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()).toBe(true, 'getQuote called');
});
    

當間諜的結果同步返回時, getQuote() 方法會在第一個更改檢測週期(Angular 在這裡呼叫 ngOnInit立即更新螢幕上的訊息。

Because the spy result returns synchronously, the getQuote() method updates the message on screen immediately after the first change detection cycle during which Angular calls ngOnInit.

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

You're not so lucky when testing the error path. Although the service spy will return an error synchronously, the component method calls setTimeout(). The test must wait at least one full turn of the JavaScript engine before the value becomes available. The test must become asynchronous.

使用 fakeAsync() 進行非同步測試

Async test with fakeAsync()

要使用 fakeAsync() 功能,你必須在測試的環境設定檔案中匯入 zone.js/testing。如果是用 Angular CLI 建立的專案,那麼其 src/test.ts 中已經配置好了 zone-testing

To use fakeAsync() functionality, you must import zone.js/testing in your test setup file. If you created your project with the Angular CLI, zone-testing is already imported in src/test.ts.

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

The following test confirms the expected behavior when the service returns an ErrorObservable.

      
      it('should display error when TwainService fails', fakeAsync(() => {
     // tell spy to return an error observable
     getQuoteSpy.and.returnValue(throwError('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()).toMatch(/test failure/, 'should display error');
     expect(quoteEl.textContent).toBe('...', 'should show placeholder');
   }));
    

注意, it() 函式會要求如下形式的引數。

Note that the it() function receives an argument of the following form.

      
      fakeAsync(() => { /* test body */ })
    

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

The fakeAsync() function enables a linear coding style by running the test body in a special fakeAsync test zone. The test body appears to be synchronous. There is no nested syntax (like a Promise.then()) to disrupt the flow of control.

限制:如果測試體要進行 XMLHttpRequest (XHR)呼叫,則 fakeAsync() 函式無效。很少會需要在測試中進行 XHR 呼叫,但如果你確實要這麼做,請參閱下面的 waitForAsync()部分。

Limitation: The fakeAsync() function won't work if the test body makes an XMLHttpRequest (XHR) call. XHR calls within a test are rare, but if you need to call XHR, see waitForAsync(), below.

tick() 函式

The tick() function

你必須呼叫 tick() 來推進(虛擬)時鐘。

You do have to call tick() to advance the (virtual) clock.

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

Calling tick() simulates the passage of time until all pending asynchronous activities finish. In this case, it waits for the error handler's setTimeout().

tick() 函式接受毫秒數(milliseconds) 和 tick 選項(tickOptions) 作為引數,毫秒數(預設值為 0)引數表示虛擬時鐘要前進多少。比如,如果你在 fakeAsync() 測試中有一個 setTimeout(fn, 100),你就需要使用 tick(100) 來觸發其 fn 回呼(Callback)。 tickOptions 是一個可選引數,它帶有一個名為 processNewMacroTasksSynchronously 的屬性(預設為 true),表示在 tick 時是否要呼叫新產生的巨集任務。

The tick() function accepts milliseconds and tickOptions as parameters, the millisecond (defaults to 0 if not provided) parameter represents how much the virtual clock advances. For example, if you have a setTimeout(fn, 100) in a fakeAsync() test, you need to use tick(100) to trigger the fn callback. The tickOptions is an optional parameter with a property called processNewMacroTasksSynchronously (defaults to true) that represents whether to invoke new generated macro tasks when ticking.

      
      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() 測試體內呼叫它。

The tick() function is one of the Angular testing utilities that you import with TestBed. It's a companion to fakeAsync() and you can only call it within a fakeAsync() body.

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 的 outsidenested 都會被觸發。

In this example, we have a new macro task (nested setTimeout), by default, when we tick, the setTimeout outside and nested will both be triggered.

      
      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 時觸發新的巨集任務,我們可以使用 tick(milliseconds, {processNewMacroTasksSynchronously: false}) 來要求不呼叫新的巨集任務。

And in some case, we don't want to trigger the new macro task when ticking, we can use tick(milliseconds, {processNewMacroTasksSynchronously: false}) to not invoke new macro task.

比較 fakeAsync() 內部的日期

Comparing dates inside fakeAsync()

fakeAsync() 可以模擬時間的流逝,以便讓你計算出 fakeAsync() 裡面的日期差。

fakeAsync() simulates passage of time, which allows you to calculate the difference between dates inside 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 with fakeAsync()

Jasmine 還為模擬日期提供了 clock 特性。而 Angular 會在 jasmine.clock().install()fakeAsync() 方法內呼叫時自動執行這些測試。直到呼叫了 jasmine.clock().uninstall() 為止。 fakeAsync() 不是必須的,如果巢狀它就丟擲錯誤。

Jasmine also provides a clock feature to mock dates. Angular automatically runs tests that are run after jasmine.clock().install() is called inside a fakeAsync() method until jasmine.clock().uninstall() is called. fakeAsync() is not needed and throws an error if nested.

預設情況下,此功能處於禁用狀態。要啟用它,請在匯入 zone-testing 之前先設定全域性標誌。

By default, this feature is disabled. To enable it, set a global flag before importing zone-testing.

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

If you use the Angular CLI, configure this flag in 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 排程器

Using the RxJS scheduler inside fakeAsync()

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

You can also use RxJS scheduler in fakeAsync() just like using setTimeout() or setInterval(), but you need to import zone.js/plugins/zone-patch-rxjs-fake-async to patch RxJS scheduler.

      
      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

Support more macroTasks

fakeAsync() 預設支援以下巨集任務:

By default, fakeAsync() supports the following macro tasks.

  • setTimeout
  • setInterval
  • requestAnimationFrame
  • webkitRequestAnimationFrame
  • mozRequestAnimationFrame

如果你執行其他巨集任務,比如 HTMLCanvasElement.toBlob() ,就會丟擲 "Unknown macroTask scheduled in fake async test" 錯誤。*

If you run other macro tasks such as HTMLCanvasElement.toBlob(), an "Unknown macroTask scheduled in fake async test" error will be thrown.

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

如果你想支援這種情況,就要在 beforeEach() 定義你要支援的巨集任務。例如:

If you want to support such a case, you need to define the macro task you want to support in beforeEach(). For example:

src/app/shared/canvas.component.spec.ts (excerpt)
      
      beforeEach(() => {
  (window as any).__zone_symbol__FakeAsyncTestMacroTask = [
    {
      source: 'HTMLCanvasElement.toBlob',
      callbackArgs: [{size: 200}],
    },
  ];
});
    

注意,要在依賴 Zone.js 的應用中使用 <canvas> 元素,你需要匯入 zone-patch-canvas 補丁(或者在 polyfills.ts 中,或者在用到 <canvas> 的那個檔案中):

Note that in order to make the <canvas> element Zone.js-aware in your app, you need to import the zone-patch-canvas patch (either in polyfills.ts or in the specific file that uses <canvas>):

src/polyfills.ts or src/app/shared/canvas.component.ts
      
      // 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';
    

非同步可觀察物件

Async observables

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

You might be satisfied with the test coverage of these tests.

但是,你可能也會為另一個事實感到不安:真實的服務並不是這樣工作的。真實的服務會向遠端伺服器傳送請求。伺服器需要一定的時間才能做出響應,並且其回應內文肯定不會像前面兩個測試中一樣是立即可用的。

However, you might be troubled by the fact that the real service doesn't quite behave this way. The real service sends requests to a remote server. A server takes time to respond and the response certainly won't be available immediately as in the previous two tests.

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

Your tests will reflect the real world more faithfully if you return an asynchronous observable from the getQuote() spy like this.

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

非同步可觀察物件測試助手

Async observable helpers

非同步可觀察物件可以由測試助手 asyncData 產生。測試助手 asyncData 是一個你必須自行編寫的工具函式,當然也可以從下面的範例程式碼中複製它。

The async observable was produced by an asyncData helper. The asyncData helper is a utility function that you'll have to write yourself, or you can copy this one from the sample code.

testing/async-observable-helpers.ts
      
      /**
 * 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 引擎的下一個週期中傳送 data 值。

This helper's observable emits the data value in the next turn of the JavaScript engine.

RxJS 的 defer() 運算子返回一個可觀察物件。它的引數是一個返回 Promise 或可觀察物件的工廠函式。當某個訂閱者訂閱 defer 產生的可觀察物件時,defer 就會呼叫此工廠函式產生新的可觀察物件,並讓該訂閱者訂閱這個新物件。

The RxJS defer() operator returns an observable. It takes a factory function that returns either a promise or an observable. When something subscribes to defer's observable, it adds the subscriber to a new observable created with that factory.

defer() 運算子會把 Promise.resolve() 轉換成一個新的可觀察物件,它和 HttpClient 一樣只會傳送一次然後立即結束(complete)。這樣,當訂閱者收到資料後就會自動取消訂閱。

The defer() operator transforms the Promise.resolve() into a new observable that, like HttpClient, emits once and completes. Subscribers are unsubscribed after they receive the data value.

還有一個類似的用來產生非同步錯誤的測試助手。

There's a similar helper for producing an async error.

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

更多非同步測試

More async tests

現在,getQuote() 間諜正在返回非同步可觀察物件,你的大多數測試都必須是非同步的。

Now that the getQuote() spy is returning async observables, most of your tests will have to be async as well.

下面是一個 fakeAsync() 測試,用於示範你在真實世界中所期望的資料流。

Here's a fakeAsync() test that demonstrates the data flow you'd expect in the real world.

      
      it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
     fixture.detectChanges();  // ngOnInit()
     expect(quoteEl.textContent).toBe('...', 'should show placeholder');

     tick();                   // flush the observable to get the quote
     fixture.detectChanges();  // update view

     expect(quoteEl.textContent).toBe(testQuote, 'should show quote');
     expect(errorMessage()).toBeNull('should not show error');
   }));
    

注意,quote 元素會在 ngOnInit() 之後顯示佔位符 '...'。因為第一句名言尚未到來。

Notice that the quote element displays the placeholder value ('...') after ngOnInit(). The first quote hasn't arrived yet.

要清除可觀察物件中的第一句名言,你可以呼叫 tick() 。然後呼叫 detectChanges() 來告訴 Angular 更新螢幕。

To flush the first quote from the observable, you call tick(). Then call detectChanges() to tell Angular to update the screen.

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

Then you can assert that the quote element displays the expected text.

waitForAsync() 進行非同步測試

Async test with waitForAsync()

要使用 waitForAsync() 函式,你必須在 test 的設定檔案中匯入 zone.js/testing。如果你是用 Angular CLI 建立的專案,那就已經在 src/test.ts 中匯入過 zone-testing 了。

To use waitForAsync() functionality, you must import zone.js/testing in your test setup file. If you created your project with the Angular CLI, zone-testing is already imported in src/test.ts.

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

Here's the previous fakeAsync() test, re-written with the waitForAsync() utility.

      
      it('should show quote after getQuote (waitForAsync)', waitForAsync(() => {
     fixture.detectChanges();  // ngOnInit()
     expect(quoteEl.textContent).toBe('...', 'should show placeholder');

     fixture.whenStable().then(() => {  // wait for async getQuote
       fixture.detectChanges();         // update view with quote
       expect(quoteEl.textContent).toBe(testQuote);
       expect(errorMessage()).toBeNull('should not show error');
     });
   }));
    

waitForAsync() 工具函式透過把測試程式碼安排到在特殊的非同步測試區(async test zone)下執行來隱藏某些用來處理非同步的樣板程式碼。你不需要把 Jasmine 的 done() 傳給測試並讓測試呼叫 done(),因為它在 Promise 或者可觀察物件的回呼(Callback)函式中是 undefined

The waitForAsync() utility hides some asynchronous boilerplate by arranging for the tester's code to run in a special async test zone. You don't need to pass Jasmine's done() into the test and call done() because it is undefined in promise or observable callbacks.

但是,可以透過呼叫 fixture.whenStable() 函式來揭示本測試的非同步性,因為該函式打破了線性的控制流。

But the test's asynchronous nature is revealed by the call to fixture.whenStable(), which breaks the linear flow of control.

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

When using an intervalTimer() such as setInterval() in waitForAsync(), remember to cancel the timer with clearInterval() after the test, otherwise the waitForAsync() never ends.

whenStable

測試必須等待 getQuote() 可觀察物件發出下一句名言。它並沒有呼叫 tick(),而是呼叫了 fixture.whenStable()

The test must wait for the getQuote() observable to emit the next quote. Instead of calling tick(), it calls fixture.whenStable().

fixture.whenStable() 返回一個 Promise,它會在 JavaScript 引擎的任務佇列變空時解析。在這個例子中,當可觀察物件發出第一句名言時,任務佇列就會變為空。

The fixture.whenStable() returns a promise that resolves when the JavaScript engine's task queue becomes empty. In this example, the task queue becomes empty when the observable emits the first quote.

測試會在該 Promise 的回呼(Callback)中繼續進行,它會呼叫 detectChanges() 來用期望的文字更新 quote 元素。

The test resumes within the promise callback, which calls detectChanges() to update the quote element with the expected text.

Jasmine done()

雖然 waitForAsync()fakeAsync() 函式可以大大簡化 Angular 的非同步測試,但你仍然可以回退到傳統技術,並給 it 傳一個以 done 回呼(Callback)為引數的函式。

While the waitForAsync() and fakeAsync() functions greatly simplify Angular asynchronous testing, you can still fall back to the traditional technique and pass it a function that takes a done callback.

但你不能在 waitForAsync()fakeAsync() 函式中呼叫 done(),因為那裡的 done 引數是 undefined

You can't call done() in waitForAsync() or fakeAsync() functions, because the done parameter is undefined.

現在,你要自己負責串聯各種 Promise、處理錯誤,並在適當的時機呼叫 done()

Now you are responsible for chaining promises, handling errors, and calling done() at the appropriate moments.

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

Writing test functions with done(), is more cumbersome than waitForAsync()and fakeAsync(), but it is occasionally necessary when code involves the intervalTimer() like setInterval.

這裡是上一個測試的另外兩種版本,用 done() 編寫。第一個訂閱了透過元件的 quote 屬性暴露給範本的 Observable

Here are two more versions of the previous test, written with done(). The first one subscribes to the Observable exposed to the template by the component's quote property.

      
      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()).toBeNull('should not show error');
    done();
  });
});
    

RxJS 的 last() 運算子會在完成之前發出可觀察物件的最後一個值,它同樣是測試名言。subscribe 回呼(Callback)會呼叫 detectChanges() 來使用測試名言重新整理的 quote 元素,方法與之前的測試一樣。

The RxJS last() operator emits the observable's last value before completing, which will be the test quote. The subscribe callback calls detectChanges() to update the quote element with the test quote, in the same manner as the earlier tests.

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

In some tests, you're more interested in how an injected service method was called and what values it returned, than what appears on screen.

服務間諜,比如偽 TwainService 上的 qetQuote() 間諜,可以給你那些資訊,並對檢視的狀態做出斷言。

A service spy, such as the qetQuote() spy of the fake TwainService, can give you that information and make assertions about the state of the view.

      
      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()).toBeNull('should not show error');
    done();
  });
});
    

元件的彈珠測試

Component marble tests

前面的 TwainComponent 測試透過 asyncDataasyncError 工具函式模擬了一個來自 TwainService 的非同步回應內文可觀察物件。

The previous TwainComponent tests simulated an asynchronous observable response from the TwainService with the asyncData and asyncError utilities.

你可以自己編寫這些簡短易用的函式。不幸的是,對於很多常見的場景來說,它們太簡單了。可觀察物件經常會發送很多次,可能是在經過一段顯著的延遲之後。元件可以用重疊的值序列和錯誤序列來協調多個可觀察物件。

These are short, simple functions that you can write yourself. Unfortunately, they're too simple for many common scenarios. An observable often emits multiple times, perhaps after a significant delay. A component may coordinate multiple observables with overlapping sequences of values and errors.

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

RxJS marble testing is a great way to test observable scenarios, both simple and complex. You've likely seen the marble diagrams that illustrate how observables work. Marble testing uses a similar marble language to specify the observable streams and expectations in your tests.

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

The following examples revisit two of the TwainComponent tests with marble testing.

首先安裝 npm 包 jasmine-marbles。然後匯入你需要的符號。

Start by installing the jasmine-marbles npm package. Then import the symbols you need.

app/twain/twain.component.marbles.spec.ts (import marbles)
      
      import { cold, getTestScheduler } from 'jasmine-marbles';
    

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

Here's the complete test for getting a quote:

      
      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).toBe('...', 'should show placeholder');

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

  fixture.detectChanges(); // update view

  expect(quoteEl.textContent).toBe(testQuote, 'should show quote');
  expect(errorMessage()).toBeNull('should not show error');
});
    

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

Notice that the Jasmine test is synchronous. There's no fakeAsync(). Marble testing uses a test scheduler to simulate the passage of time in a synchronous test.

彈珠測試的美妙之處在於對可觀察物件流的視覺定義。這個測試定義了一個可觀察物件,它等待三--- ),發出一個值( x ),並完成( | )。在第二個引數中,你把值標記( x )對映到了發出的值( testQuote )。

The beauty of marble testing is in the visual definition of the observable streams. This test defines a cold observable that waits three frames (---), emits a value (x), and completes (|). In the second argument you map the value marker (x) to the emitted value (testQuote).

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

這個彈珠函式庫會構造出相應的可觀察物件,測試程式把它用作 getQuote 間諜的返回值。

The marble library constructs the corresponding observable, which the test sets as the getQuote spy's return value.

當你準備好啟用彈珠的可觀察物件時,就告訴 TestScheduler 把它準備好的任務佇列重新整理一下。

When you're ready to activate the marble observables, you tell the TestScheduler to flush its queue of prepared tasks like this.

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

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

This step serves a purpose analogous to tick() and whenStable() in the earlier fakeAsync() and waitForAsync() examples. The balance of the test is the same as those examples.

彈珠錯誤測試

Marble error testing

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

Here's the marble testing version of the getQuote() error test.

      
      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).toBe('...', 'should show placeholder');

  getTestScheduler().flush(); // flush the observables
  tick();                     // component shows error after a setTimeout()
  fixture.detectChanges();    // update error message

  expect(errorMessage()).toMatch(/test failure/, 'should display error');
  expect(quoteEl.textContent).toBe('...', 'should show placeholder');
}));
    

它仍然是非同步測試,呼叫 fakeAsync()tick(),因為該元件在處理錯誤時會呼叫 setTimeout()

It's still an async test, calling fakeAsync() and tick(), because the component itself calls setTimeout() when processing errors.

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

Look at the marble observable definition.

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

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

This is a cold observable that waits three frames and then emits an error, The hash (#) indicates the timing of the error that is specified in the third argument. The second argument is null because the observable never emits a value.

瞭解彈珠測試

Learn about marble testing

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

A marble frame is a virtual unit of testing time. Each symbol (-, x, |, #) marks the passing of one frame.

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

A cold observable doesn't produce values until you subscribe to it. Most of your application observables are cold. All HttpClient methods return cold observables.

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

A hot observable is already producing values before you subscribe to it. The Router.events observable, which reports router activity, is a hot observable.

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

RxJS marble testing is a rich subject, beyond the scope of this guide. Learn about it on the web, starting with the official documentation.

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

Component with inputs and outputs

具有輸入和輸出屬性的元件通常會出現在宿主元件的檢視範本中。宿主使用屬性繫結來設定輸入屬性,並使用事件繫結來監聽輸出屬性引發的事件。

A component with inputs and outputs typically appears inside the view template of a host component. The host uses a property binding to set the input property and an event binding to listen to events raised by the output property.

本測試的目標是驗證這些繫結是否如預期般工作。這些測試應該設定輸入值並監聽輸出事件。

The testing goal is to verify that such bindings work as expected. The tests should set input values and listen for output events.

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

The DashboardHeroComponent is a tiny example of a component in this role. It displays an individual hero provided by the DashboardComponent. Clicking that hero tells the DashboardComponent that the user has selected the hero.

DashboardHeroComponent 會像這樣內嵌在 DashboardComponent 範本中的:

The DashboardHeroComponent is embedded in the DashboardComponent template like this:

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

DashboardHeroComponent 出現在 *ngFor 複寫器中,把它的輸入屬性 hero 設定為當前的迴圈變數,並監聽該元件的 selected 事件。

The DashboardHeroComponent appears in an *ngFor repeater, which sets each component's hero input property to the looping value and listens for the component's selected event.

這裡是元件的完整定義:

Here's the component's full definition:

app/dashboard/dashboard-hero.component.ts (component)
      
      @Component({
  selector: 'dashboard-hero',
  template: `
    <div (click)="click()" class="hero">
      {{hero.name | uppercase}}
    </div>`,
  styleUrls: [ './dashboard-hero.component.css' ]
})
export class DashboardHeroComponent {
  @Input() hero!: Hero;
  @Output() selected = new EventEmitter<Hero>();
  click() { this.selected.emit(this.hero); }
}
    

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

While testing a component this simple has little intrinsic value, it's worth knowing how. You can use one of these approaches:

  • DashboardComponent 來測試它。

    Test it as used by DashboardComponent.

  • 把它作為一個獨立的元件進行測試。

    Test it as a stand-alone component.

  • DashboardComponent 的一個替代品來測試它。

    Test it as used by a substitute for DashboardComponent.

快速看一眼 DashboardComponent 建構函式就知道不建議採用第一種方法:

A quick look at the DashboardComponent constructor discourages the first approach:

app/dashboard/dashboard.component.ts (constructor)
      
      constructor(
  private router: Router,
  private heroService: HeroService) {
}
    

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

The DashboardComponent depends on the Angular router and the HeroService. You'd probably have to replace them both with test doubles, which is a lot of work. The router seems particularly challenging.

下面的討論涵蓋了如何測試那些需要用到路由器的元件。

The discussion below covers testing components that require the router.

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

The immediate goal is to test the DashboardHeroComponent, not the DashboardComponent, so, try the second and third options.

單獨測試 DashboardHeroComponent

Test DashboardHeroComponent stand-alone

這裡是 spec 檔案中環境設定部分的內容。

Here's the meat of the spec file setup.

app/dashboard/dashboard-hero.component.spec.ts (setup)
      
      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 在其複寫器中透過屬性繫結來設定它的方式。

Note how the setup code assigns a test hero (expectedHero) to the component's hero property, emulating the way the DashboardComponent would set it via the property binding in its repeater.

下面的測試驗證了英雄名是透過繫結傳播到範本的。

The following test verifies that the hero name is propagated to the template via a binding.

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

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

Because the template passes the hero name through the Angular UpperCasePipe, the test must match the element value with the upper-cased name.

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

This small test demonstrates how Angular tests can verify a component's visual representation—something not possible with component class tests—at low cost and without resorting to much slower and more complicated end-to-end tests.

點選

Clicking

單擊該英雄應該會讓一個宿主元件(可能是 DashboardComponent)監聽到 selected 事件。

Clicking the hero should raise a selected event that the host component (DashboardComponent presumably) can hear:

      
      it('should raise selected event when clicked (triggerEventHandler)', () => {
  let selectedHero: Hero | undefined;
  comp.selected.subscribe((hero: Hero) => selectedHero = hero);

  heroDe.triggerEventHandler('click', null);
  expect(selectedHero).toBe(expectedHero);
});
    

該元件的 selected 屬性給消費者返回了一個 EventEmitter,它看起來像是 RxJS 的同步 Observable。 該測試只有在宿主元件隱式觸發時才需要顯式訂閱它。

The component's selected property returns an EventEmitter, which looks like an RxJS synchronous Observable to consumers. The test subscribes to it explicitly just as the host component does implicitly.

當元件的行為符合預期時,單擊此英雄的元素就會告訴元件的 selected 屬性發出了一個 hero 物件。

If the component behaves as expected, clicking the hero's element should tell the component's selected property to emit the hero object.

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

The test detects that event through its subscription to selected.

triggerEventHandler

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

The heroDe in the previous test is a DebugElement that represents the hero <div>.

它有一些用於抽象與原生元素互動的 Angular 屬性和方法。 這個測試會使用事件名稱 click 來呼叫 DebugElement.triggerEventHandlerclick 的事件繫結到了 DashboardHeroComponent.click()

It has Angular properties and methods that abstract interaction with the native element. This test calls the DebugElement.triggerEventHandler with the "click" event name. The "click" event binding responds by calling DashboardHeroComponent.click().

Angular 的 DebugElement.triggerEventHandler 可以用事件的名字觸發任何資料繫結事件。 第二個引數是傳給事件處理器的事件物件。

The Angular DebugElement.triggerEventHandler can raise any data-bound event by its event name. The second parameter is the event object passed to the handler.

該測試使用事件物件 null 觸發了一次 click 事件。

The test triggered a "click" event with a null event object.

      
      heroDe.triggerEventHandler('click', null);
    

測試程式假設(在這裡應該這樣)執行時間的事件處理器(元件的 click() 方法)不關心事件物件。

The test assumes (correctly in this case) that the runtime event handler—the component's click() method—doesn't care about the event object.

其它處理器的要求比較嚴格。比如,RouterLink 指令期望一個帶有 button 屬性的物件,該屬性用於指出點選時按下的是哪個滑鼠按鈕。 如果不給出這個事件物件,RouterLink 指令就會丟擲一個錯誤。

Other handlers are less forgiving. For example, the RouterLink directive expects an object with a button property that identifies which mouse button (if any) was pressed during the click. The RouterLink directive throws an error if the event object is missing.

點選該元素

Click the element

下面這個測試改為呼叫原生元素自己的 click() 方法,它對於這個元件來說相當完美。

The following test alternative calls the native element's own click() method, which is perfectly fine for this component.

      
      it('should raise selected event when clicked (element.click)', () => {
  let selectedHero: Hero | undefined;
  comp.selected.subscribe((hero: Hero) => selectedHero = hero);

  heroEl.click();
  expect(selectedHero).toBe(expectedHero);
});
    

click() 輔助函式

click() helper

點選按鈕、連結或者任意 HTML 元素是很常見的測試任務。

Clicking a button, an anchor, or an arbitrary HTML element is a common test task.

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

Make that consistent and easy by encapsulating the click-triggering process in a helper such as the click() function below:

testing/index.ts (click helper)
      
      /** 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 指令。

The first parameter is the element-to-click. If you wish, you can pass a custom event object as the second parameter. The default is a (partial) left-button mouse event object accepted by many handlers including the RouterLink directive.

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

The click() helper function is not one of the Angular testing utilities. It's a function defined in this guide's sample code. All of the sample tests use it. If you like it, add it to your own collection of helpers.

下面是把前面的測試用 click 輔助函式重寫後的版本。

Here's the previous test, rewritten using the click helper.

app/dashboard/dashboard-hero.component.spec.ts (test with click helper)
      
      it('should raise selected event when clicked (click helper)', () => {
  let selectedHero: Hero | undefined;
  comp.selected.subscribe((hero: Hero) => selectedHero = hero);

  click(heroDe);  // click helper with DebugElement
  click(heroEl);  // click helper with native element

  expect(selectedHero).toBe(expectedHero);
});
    

位於測試宿主中的元件

Component inside a test host

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

The previous tests played the role of the host DashboardComponent themselves. But does the DashboardHeroComponent work correctly when properly data-bound to a host component?

固然,你也可以測試真實的 DashboardComponent。 但要想這麼做需要做很多準備工作,特別是它的範本中使用了某些特性,如 *ngFor、 其它元件、佈局 HTML、附加繫結、注入了多個服務的建構函式、如何用正確的方式與那些服務互動等。

You could test with the actual DashboardComponent. But doing so could require a lot of setup, especially when its template features an *ngFor repeater, other components, layout HTML, additional bindings, a constructor that injects multiple services, and it starts interacting with those services right away.

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

Imagine the effort to disable these distractions, just to prove a point that can be made satisfactorily with a test host like this one:

app/dashboard/dashboard-hero.component.spec.ts (test host)
      
      @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

This test host binds to DashboardHeroComponent as the DashboardComponent would but without the noise of the Router, the HeroService, or the *ngFor repeater.

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

The test host sets the component's hero input property with its test hero. It binds the component's selected event with its onSelected handler, which records the emitted hero in its selectedHero property.

稍後,這個測試就可以輕鬆檢查 selectedHero 以驗證 DashboardHeroComponent.selected 事件確實發出了所期望的英雄。

Later, the tests will be able to easily check selectedHero to verify that the DashboardHeroComponent.selected event emitted the expected hero.

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

The setup for the test-host tests is similar to the setup for the stand-alone tests:

app/dashboard/dashboard-hero.component.spec.ts (test host setup)
      
      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
    

這個測試模組的配置資訊有三個重要的不同點:

This testing module configuration shows three important differences:

  1. 它同時宣告DashboardHeroComponentTestHostComponent

    It declares both the DashboardHeroComponent and the TestHostComponent.

  2. 建立TestHostComponent,而非 DashboardHeroComponent

    It creates the TestHostComponent instead of the DashboardHeroComponent.

  3. TestHostComponent 透過繫結機制設定了 DashboardHeroComponent.hero

    The TestHostComponent sets the DashboardHeroComponent.hero with a binding.

createComponent 返回的 fixture 裡有 TestHostComponent 實例,而非 DashboardHeroComponent 元件實例。

The createComponent returns a fixture that holds an instance of TestHostComponent instead of an instance of DashboardHeroComponent.

當然,建立 TestHostComponent 有建立 DashboardHeroComponent 的副作用,因為後者出現在前者的範本中。 英雄元素(heroEl)的查詢語句仍然可以在測試 DOM 中找到它,儘管元素樹比以前更深。

Creating the TestHostComponent has the side-effect of creating a DashboardHeroComponent because the latter appears within the template of the former. The query for the hero element (heroEl) still finds it in the test DOM, albeit at greater depth in the element tree than before.

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

The tests themselves are almost identical to the stand-alone version:

app/dashboard/dashboard-hero.component.spec.ts (test-host)
      
      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 英雄確實透過事件繫結被傳遞到宿主元件。

Only the selected event test differs. It confirms that the selected DashboardHeroComponent hero really does find its way up through the event binding to the host component.

路由元件

Routing component

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

A routing component is a component that tells the Router to navigate to another component. The DashboardComponent is a routing component because the user can navigate to the HeroDetailComponent by clicking on one of the hero buttons on the dashboard.

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

Routing is pretty complicated. Testing the DashboardComponent seemed daunting in part because it involves the Router, which it injects together with the HeroService.

app/dashboard/dashboard.component.ts (constructor)
      
      constructor(
  private router: Router,
  private heroService: HeroService) {
}
    

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

Mocking the HeroService with a spy is a familiar story. But the Router has a complicated API and is entwined with other services and application preconditions. Might it be difficult to mock?

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

Fortunately, not in this case because the DashboardComponent isn't doing much with the Router

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

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

This is often the case with routing components. As a rule you test the component, not the router, and care only if the component navigates with the right address under the given conditions.

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

Providing a router spy for this component test suite happens to be as easy as providing a HeroService spy.

app/dashboard/dashboard.component.spec.ts (spies)
      
      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 呼叫過。

The following test clicks the displayed hero and confirms that Router.navigateByUrl is called with the expected url.

app/dashboard/dashboard.component.spec.ts (navigate test)
      
      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).toBe('/heroes/' + id, 'should nav to HeroDetail for first hero');
});
    

路由目標元件

Routed components

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

A routed component is the destination of a Router navigation. It can be trickier to test, especially when the route to the component includes parameters. The HeroDetailComponent is a routed component that is the destination of such a route.

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

When a user clicks a Dashboard hero, the DashboardComponent tells the Router to navigate to heroes/:id. The :id is a route parameter whose value is the id of the hero to edit.

Router 會根據那個 URL 匹配到一個指向 HeroDetailComponent 的路由。 它會建立一個帶有路由資訊的 ActivatedRoute 物件,並把它注入到一個 HeroDetailComponent 的新實例中。

The Router matches that URL to a route to the HeroDetailComponent. It creates an ActivatedRoute object with the routing information and injects it into a new instance of the HeroDetailComponent.

下面是 HeroDetailComponent 的建構函式:

Here's the HeroDetailComponent constructor:

app/hero/hero-detail.component.ts (constructor)
      
      constructor(
  private heroDetailService: HeroDetailService,
  private route: ActivatedRoute,
  private router: Router) {
}
    

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

The HeroDetail component needs the id parameter so it can fetch the corresponding hero via the HeroDetailService. The component has to get the id from the ActivatedRoute.paramMap property which is an Observable.

它不能僅僅參考 ActivatedRoute.paramMapid 屬性。 該元件不得不訂閱 ActivatedRoute.paramMap 這個可觀察物件,要做好它在生命週期中隨時會發生變化的準備。

It can't just reference the id property of the ActivatedRoute.paramMap. The component has to subscribe to the ActivatedRoute.paramMap observable and be prepared for the id to change during its lifetime.

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

透過操縱注入到元件建構函式中的這個 ActivatedRoute,測試可以探查 HeroDetailComponent 是如何對不同的 id 引數值做出響應的。

Tests can explore how the HeroDetailComponent responds to different id parameter values by manipulating the ActivatedRoute injected into the component's constructor.

你已經知道了如何給 Router 和資料服務安插間諜。

You know how to spy on the Router and a data service.

不過對於 ActivatedRoute,你要採用另一種方式,因為:

You'll take a different approach with ActivatedRoute because

  • 在測試期間,paramMap 會返回一個能發出多個值的 Observable

    paramMap returns an Observable that can emit more than one value during a test.

  • 你需要路由器的輔助函式 convertToParamMap() 來建立 ParamMap

    You need the router helper function, convertToParamMap(), to create a ParamMap.

  • 針對路由目標元件的其它測試需要一個 ActivatedRoute 的測試替身。

    Other routed component tests need a test double for ActivatedRoute.

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

These differences argue for a re-usable stub class.

ActivatedRouteStub

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

The following ActivatedRouteStub class serves as a test double for ActivatedRoute.

testing/activated-route-stub.ts (ActivatedRouteStub)
      
      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 中。

Consider placing such helpers in a testing folder sibling to the app folder. This sample puts ActivatedRouteStub in testing/activated-route-stub.ts.

可以考慮使用彈珠測試函式庫來為此測試樁編寫一個更強力的版本。

Consider writing a more capable version of this stub class with the marble testing library.

使用 ActivatedRouteStub 進行測試

Testing with ActivatedRouteStub

下面的測試程式是示範元件在被觀察的 id 指向現有英雄時的行為:

Here's a test demonstrating the component's behavior when the observed id refers to an existing hero:

app/hero/hero-detail.component.spec.ts (existing 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 物件會在稍後進行討論。 不過目前,你只要憑直覺來理解就行了。

The createComponent() method and page object are discussed below. Rely on your intuition for now.

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

When the id cannot be found, the component should re-route to the HeroListComponent.

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

The test suite setup provided the same router spy described above which spies on the router without actually navigating.

這個測試中會期待該元件嘗試導航到 HeroListComponent

This test expects the component to try to navigate to the HeroListComponent.

app/hero/hero-detail.component.spec.ts (bad id)
      
      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()).toBe(true, 'comp.gotoList called');
    expect(page.navigateSpy.calls.any()).toBe(true, 'router.navigate called');
  });
});
    

雖然本應用沒有在缺少 id 引數的時候,繼續導航到 HeroDetailComponent 的路由,但是,將來它可能會新增這樣的路由。 當沒有 id 時,該元件應該作出合理的反應。

While this app doesn't have a route to the HeroDetailComponent that omits the id parameter, it might add such a route someday. The component should do something reasonable when there is no id.

在本例中,元件應該建立和顯示新英雄。 新英雄的 id 為零,name 為空。本測試程式確認元件是按照預期的這樣做的:

In this implementation, the component should create and display a new hero. New heroes have id=0 and a blank name. This test confirms that the component behaves as expected:

app/hero/hero-detail.component.spec.ts (no id)
      
      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('');
  });
});
    

對巢狀元件的測試

Nested component tests

元件的範本中通常還會有巢狀元件,巢狀元件的範本還可能包含更多元件。

Component templates often have nested components, whose templates may contain more components.

這棵元件樹可能非常深,並且大多數時候在測試這棵樹頂部的元件時,這些巢狀的元件都無關緊要。

The component tree can be very deep and, most of the time, the nested components play no role in testing the component at the top of the tree.

比如,AppComponent 會顯示一個帶有連結及其 RouterLink 指令的導覽列。

The AppComponent, for example, displays a navigation bar with anchors and their RouterLink directives.

app/app.component.html
      
      <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 指令。

While the AppComponent class is empty, you may want to write unit tests to confirm that the links are wired properly to the RouterLink directives, perhaps for the reasons explained below.

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

To validate the links, you don't need the Router to navigate and you don't need the <router-outlet> to mark where the Router inserts routed components.

BannerComponentWelcomeComponent(寫作 <app-banner><app-welcome>)也同樣風馬牛不相及。

The BannerComponent and WelcomeComponent (indicated by <app-banner> and <app-welcome>) are also irrelevant.

然而,任何測試,只要能在 DOM 中建立 AppComponent,也就同樣能建立這三個元件的實例。如果要建立它們,你就要配置 TestBed

Yet any test that creates the AppComponent in the DOM will also create instances of these three components and, if you let that happen, you'll have to configure the TestBed to create them.

如果你忘了宣告它們,Angular 編譯器就無法在 AppComponent 範本中識別出 <app-banner><app-welcome><router-outlet> 標記,並丟擲一個錯誤。

If you neglect to declare them, the Angular compiler won't recognize the <app-banner>, <app-welcome>, and <router-outlet> tags in the AppComponent template and will throw an error.

如果你宣告的這些都是真實的元件,那麼也同樣要宣告它們的巢狀元件,並要為這棵元件樹中的任何元件提供要注入的所有服務。

If you declare the real components, you'll also have to declare their nested components and provide for all services injected in any component in the tree.

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

That's too much effort just to answer a few simple questions about links.

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

This section describes two techniques for minimizing the setup. Use them, alone or in combination, to stay focused on testing the primary component.

對不需要的元件提供樁(stub)
Stubbing unneeded components

這項技術中,你要為那些在測試中無關緊要的元件或指令建立和宣告一些測試樁。

In the first technique, you create and declare stub versions of the components and directive that play little or no role in the tests.

app/app.component.spec.ts (stub declaration)
      
      @Component({selector: 'app-banner', template: ''})
class BannerStubComponent {
}

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

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

這些測試樁的選擇器要和其對應的真實元件一致,但其範本和類別是空的。

The stub selectors match the selectors for the corresponding real components. But their templates and classes are empty.

然後在 TestBed 的配置中那些真正有用的元件、指令、管道之後宣告它們。

Then declare them in the TestBed configuration next to the components, directives, and pipes that need to be real.

app/app.component.spec.ts (TestBed stubs)
      
      TestBed
    .configureTestingModule({
      declarations: [
        AppComponent, RouterLinkDirectiveStub, BannerStubComponent, RouterOutletStubComponent,
        WelcomeStubComponent
      ]
    })
    

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

The AppComponent is the test subject, so of course you declare the real version.

RouterLinkDirectiveStub稍後講解)是一個真實的 RouterLink 的測試版,它能幫你對連結進行測試。

The RouterLinkDirectiveStub, described later, is a test version of the real RouterLink that helps with the link tests.

其它都是測試樁。

The rest are stubs.

NO_ERRORS_SCHEMA

第二種辦法就是把 NO_ERRORS_SCHEMA 新增到 TestBed.schemas 的元資料中。

In the second approach, add NO_ERRORS_SCHEMA to the TestBed.schemas metadata.

app/app.component.spec.ts (NO_ERRORS_SCHEMA)
      
      TestBed
    .configureTestingModule({
      declarations: [
        AppComponent,
        RouterLinkDirectiveStub
      ],
      schemas: [NO_ERRORS_SCHEMA]
    })
    

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

The NO_ERRORS_SCHEMA tells the Angular compiler to ignore unrecognized elements and attributes.

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

The compiler will recognize the <app-root> element and the routerLink attribute because you declared a corresponding AppComponent and RouterLinkDirectiveStub in the TestBed configuration.

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

But the compiler won't throw an error when it encounters <app-banner>, <app-welcome>, or <router-outlet>. It simply renders them as empty tags and the browser ignores them.

你不用再提供樁元件了。

You no longer need the stub components.

同時使用這兩項技術

Use both techniques together

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

These are techniques for Shallow Component Testing , so-named because they reduce the visual surface of the component to just those elements in the component's template that matter for tests.

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

The NO_ERRORS_SCHEMA approach is the easier of the two but don't overuse it.

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

The NO_ERRORS_SCHEMA also prevents the compiler from telling you about the missing components and attributes that you omitted inadvertently or misspelled. You could waste hours chasing phantom bugs that the compiler would have caught in an instant.

樁元件方式還有其它優點。 雖然這個例子中的樁是空的,但你如果想要和它們用某種形式互動,也可以給它們一些裁剪過的範本和類別。

The stub component approach has another advantage. While the stubs in this example were empty, you could give them stripped-down templates and classes if your tests need to interact with them in some way.

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

In practice you will combine the two techniques in the same setup, as seen in this example.

app/app.component.spec.ts (mixed setup)
      
      TestBed
    .configureTestingModule({
      declarations: [
        AppComponent,
        BannerStubComponent,
        RouterLinkDirectiveStub
      ],
      schemas: [NO_ERRORS_SCHEMA]
    })
    

Angular 編譯器會為 <app-banner> 元素建立 BannerComponentStub,並把 RouterLinkStubDirective 應用到帶有 routerLink 屬性的連結上,不過它會忽略 <app-welcome><router-outlet> 標籤。

The Angular compiler creates the BannerComponentStub for the <app-banner> element and applies the RouterLinkStubDirective to the anchors with the routerLink attribute, but it ignores the <app-welcome> and <router-outlet> tags.

真實的 RouterLinkDirective 太複雜了,而且與 RouterModule 中的其它元件和指令有著千絲萬縷的聯絡。 要在準備階段 Mock 它以及在測試中使用它具有一定的挑戰性。

The real RouterLinkDirective is quite complicated and entangled with other components and directives of the RouterModule. It requires challenging setup to mock and use in tests.

這段範例程式碼中的 RouterLinkDirectiveStub 用一個代用品替換了真實的指令,這個代用品用來驗證 AppComponent 中所用連結的型別。

The RouterLinkDirectiveStub in this sample code replaces the real directive with an alternative version designed to validate the kind of anchor tag wiring seen in the AppComponent template.

testing/router-link-directive-stub.ts (RouterLinkDirectiveStub)
      
      @Directive({
  selector: '[routerLink]'
})
export class RouterLinkDirectiveStub {
  @Input('routerLink') linkParams: any;
  navigatedTo: any = null;

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

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

The URL bound to the [routerLink] attribute flows in to the directive's linkParams property.

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

The HostListener wires the click event of the host element (the <a> anchor elements in AppComponent) to the stub directive's onClick method.

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

Clicking the anchor should trigger the onClick() method, which sets the stub's telltale navigatedTo property. Tests inspect navigatedTo to confirm that clicking the anchor sets the expected route definition.

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

Whether the router is configured properly to navigate with that route definition is a question for a separate set of tests.

By.directive 與注入的指令

By.directive and injected directives

再一步配置觸發了資料繫結的初始化,獲取導航連結的參考:

A little more setup triggers the initial data binding and gets references to the navigation links:

app/app.component.spec.ts (test setup)
      
      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));
});
    

有三點特別重要:

Three points of special interest:

  1. 你可以使用 By.directive 來定位一個帶附屬指令的連結元素。

    You can locate the anchor elements with an attached directive using By.directive.

  2. 該查詢返回包含了匹配元素的 DebugElement 包裝器。

    The query returns DebugElement wrappers around the matching elements.

  3. 每個 DebugElement 都會匯出該元素中的一個依賴注入器,其中帶有指定的指令實例。

    Each DebugElement exposes a dependency injector with the specific instance of the directive attached to that element.

AppComponent 中要驗證的連結如下:

The AppComponent links to validate are as follows:

app/app.component.html (navigation links)
      
      <nav>
  <a routerLink="/dashboard">Dashboard</a>
  <a routerLink="/heroes">Heroes</a>
  <a routerLink="/about">About</a>
</nav>
    

下面這些測試用來確認那些連結是否如預期般連線到了 RouterLink 指令中:

Here are some tests that confirm those links are wired to the routerLink directives as expected:

app/app.component.spec.ts (selected tests)
      
      it('can get RouterLinks from template', () => {
  expect(routerLinks.length).toBe(3, 'should have 3 routerLinks');
  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).toBeNull('should not have navigated yet');

  heroesLinkDe.triggerEventHandler('click', null);
  fixture.detectChanges();

  expect(heroesLink.navigatedTo).toBe('/heroes');
});
    

其實這個例子中的“click”測試誤入歧途了。 它測試的重點其實是 RouterLinkDirectiveStub,而不是該元件。 這是寫樁指令時常見的錯誤。

The "click" test in this example is misleading. It tests the RouterLinkDirectiveStub rather than the component. This is a common failing of directive stubs.

在本章中,它有存在的必要。 它示範瞭如何在不涉及完整路由器機制的情況下,如何找到 RouterLink 元素、點選它並檢查結果。 要測試更復雜的元件,你可能需要具備這樣的能力,能改變檢視和重新計算引數,或者當用戶點選連結時,有能力重新安排導航選項。

It has a legitimate purpose in this guide. It demonstrates how to find a RouterLink element, click it, and inspect a result, without engaging the full router machinery. This is a skill you may need to test a more sophisticated component, one that changes the display, re-calculates parameters, or re-arranges navigation options when the user clicks the link.

這些測試有什麼優點?

What good are these tests?

RouterLink 的樁指令進行測試可以確認帶有連結和 outlet 的元件的設定的正確性,確認元件有應該有的連結,確認它們都指向了正確的方向。 這些測試程式不關心使用者點選連結時,也不關心應用是否會成功的導航到目標元件。

Stubbed RouterLink tests can confirm that a component with links and an outlet is setup properly, that the component has the links it should have, and that they are all pointing in the expected direction. These tests do not concern whether the app will succeed in navigating to the target component when the user clicks a link.

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

Stubbing the RouterLink and RouterOutlet is the best option for such limited testing goals. Relying on the real router would make them brittle. They could fail for reasons unrelated to the component. For example, a navigation guard could prevent an unauthorized user from visiting the HeroListComponent. That's not the fault of the AppComponent and no change to that component could cure the failed test.

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

A different battery of tests can explore whether the application navigates as expected in the presence of conditions that influence guards such as whether the user is authenticated and authorized.

未來對本章的更新將介紹如何使用 RouterTestingModule 來編寫這樣的測試程式。

A future guide update will explain how to write such tests with the RouterTestingModule.

使用頁面(page)物件

Use a page object

HeroDetailComponent 是帶有標題、兩個英雄欄位和兩個按鈕的簡單檢視。

The HeroDetailComponent is a simple view with a title, two hero fields, and two buttons.

但即使是這麼簡單的表單,其範本中也涉及到不少複雜性。

But there's plenty of template complexity even in this simple form.

app/hero/hero-detail.component.html
      
      <div *ngIf="hero">
  <h2><span>{{hero.name | titlecase}}</span> Details</h2>
  <div>
    <label>id: </label>{{hero.id}}</div>
  <div>
    <label for="name">name: </label>
    <input id="name" [(ngModel)]="hero.name" placeholder="name" />
  </div>
  <button (click)="save()">Save</button>
  <button (click)="cancel()">Cancel</button>
</div>
    

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

Tests that exercise the component need ...

  • 等獲取到英雄之後才能讓元素出現在 DOM 中。

    to wait until a hero arrives before elements appear in the DOM.

  • 一個對標題文字的參考。

    a reference to the title text.

  • 一個對 name 輸入框的參考,以便對它進行探查和修改。

    a reference to the name input box to inspect and set it.

  • 參考兩個按鈕,以便點選它們。

    references to the two buttons so they can click them.

  • 為元件和路由器的方法安插間諜。

    spies for some of the component and router methods.

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

Even a small form such as this one can produce a mess of tortured conditional setup and CSS element selection.

可以使用 Page 類別來征服這種複雜性。Page 類別可以處理對元件屬性的訪問,並對設定這些屬性的邏輯進行封裝。

Tame the complexity with a Page class that handles access to component properties and encapsulates the logic that sets them.

下面是一個供 hero-detail.component.spec.ts 使用的 Page 類別

Here is such a Page class for the hero-detail.component.spec.ts

app/hero/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 實例來使用它們。

Now the important hooks for component manipulation and inspection are neatly organized and accessible from an instance of Page.

createComponent 方法會建立一個 page 物件,並在 hero 到來時自動填補空白。

A createComponent method creates a page object and fills in the blanks once the hero arrives.

app/hero/hero-detail.component.spec.ts (createComponent)
      
      /** 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();
  });
}
    

前面小節中的 HeroDetailComponent 測試示範瞭如何 createComponent,而 page 讓這些測試保持簡短而富有表達力。 而且還不用分心:不用等待承諾被解析,不必在 DOM 中找出元素的值才能進行比較。

The HeroDetailComponent tests in an earlier section demonstrate how createComponent and page keep the tests short and on message. There are no distractions: no waiting for promises to resolve and no searching the DOM for element values to compare.

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

Here are a few more HeroDetailComponent tests to reinforce the point.

app/hero/hero-detail.component.spec.ts (selected tests)
      
      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()).toBe(true, 'router.navigate called');
});

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()).toBe(true, 'HeroDetailService.save called');
  expect(page.navigateSpy.calls.any()).toBe(false, 'router.navigate not called');
});

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()).toBe(true, 'router.navigate called');
   }));

it('should convert hero name to Title Case', () => {
  // get the name's input and display elements from the DOM
  const hostElement = 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.
  // In older browsers, such as IE, you might need a CustomEvent instead. See
  // https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#Polyfill
  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()

Calling compileComponents()

如果你只想使用 CLI 的 ng test 命令來執行測試,那麼可以忽略這一節。

You can ignore this section if you only run tests with the CLI ng test command because the CLI compiles the application before running the tests.

如果你在非 CLI 環境中執行測試,這些測試可能會報錯,錯誤資訊如下:

If you run tests in a non-CLI environment, the tests may fail with a message like this one:

      
      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 所示:

The root of the problem is at least one of the components involved in the test specifies an external template or CSS file as the following version of the BannerComponent does.

app/banner/banner-external.component.ts (external template & css)
      
      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 檢視建立元件時,這個測試失敗了:

The test fails when the TestBed tries to create the component.

app/banner/banner.component.spec.ts (setup that fails)
      
      beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ BannerComponent ],
  });
  fixture = TestBed.createComponent(BannerComponent);
});
    

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

Recall that the app hasn't been compiled. So when you call createComponent(), the TestBed compiles implicitly.

當它的原始碼都在記憶體中的時候,這樣做沒問題。 不過 BannerComponent 需要一些外部檔案,編譯時必須從檔案系統中讀取它,而這是一個天生的非同步操作。

That's not a problem when the source code is in memory. But the BannerComponent requires external files that the compiler must read from the file system, an inherently asynchronous operation.

如果 TestBed 繼續執行,這些測試就會繼續執行,並在編譯器完成這些非同步工作之前導致莫名其妙的失敗。

If the TestBed were allowed to continue, the tests would run and fail mysteriously before the compiler could finished.

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

The preemptive error message tells you to compile explicitly with compileComponents().

compileComponents() 是非同步的

compileComponents() is async

你必須在非同步測試函式中呼叫 compileComponents()

You must call compileComponents() within an asynchronous test function.

如果你忘了把測試函式標為非同步的(比如忘了像稍後的程式碼中那樣使用 async 關鍵字),就會看到下列錯誤。

If you neglect to make the test function async (e.g., forget to use the async keyword as described below), you'll see this error message

      
      Error: ViewDestroyedError: Attempt to use a destroyed view
    

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

A typical approach is to divide the setup logic into two separate beforeEach() functions:

  1. 非同步的 beforeEach() 負責編譯元件

    An async beforeEach() that compiles the components

  2. 同步的 beforeEach() 負責執行其餘的準備程式碼。

    A synchronous beforeEach() that performs the remaining setup.

非同步的 beforeEach

The async beforeEach

像下面這樣編寫第一個非同步的 beforeEach

Write the first async beforeEach like this.

app/banner/banner-external.component.spec.ts (async beforeEach)
      
      beforeEach(async () => {
  TestBed
      .configureTestingModule({
        declarations: [BannerComponent],
      })
      .compileComponents();  // compile template and css
});
    

TestBed.configureTestingModule() 方法返回 TestBed 類別,所以你可以鏈式呼叫其它 TestBed 中的靜態方法,比如 compileComponents()

The TestBed.configureTestingModule() method returns the TestBed class so you can chain calls to other TestBed static methods such as compileComponents().

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

In this example, the BannerComponent is the only component to compile. Other examples configure the testing module with multiple components and may import application modules that hold yet more components. Any of them could require external files.

TestBed.compileComponents 方法會非同步編譯測試模組中配置過的所有元件。

The TestBed.compileComponents method asynchronously compiles all components configured in the testing module.

在呼叫了 compileComponents() 之後就不能再重新配置 TestBed 了。

Do not re-configure the TestBed after calling compileComponents().

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

Calling compileComponents() closes the current TestBed instance to further configuration. You cannot call any more TestBed configuration methods, not configureTestingModule() nor any of the override... methods. The TestBed throws an error if you try.

確保 compileComponents() 是呼叫 TestBed.createComponent() 之前的最後一步。

Make compileComponents() the last step before calling TestBed.createComponent().

同步的 beforeEach

The synchronous beforeEach

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

The second, synchronous beforeEach() contains the remaining setup steps, which include creating the component and querying for elements to inspect.

app/banner/banner-external.component.spec.ts (synchronous beforeEach)
      
      beforeEach(() => {
  fixture = TestBed.createComponent(BannerComponent);
  component = fixture.componentInstance;  // BannerComponent test instance
  h1 = fixture.nativeElement.querySelector('h1');
});
    

測試執行器(runner)會先等待第一個非同步 beforeEach 函式執行完再呼叫第二個。

You can count on the test runner to wait for the first asynchronous beforeEach to finish before calling the second.

整理過的準備程式碼

Consolidated setup

你可以把這兩個 beforeEach() 函式重整成一個非同步的 beforeEach()

You can consolidate the two beforeEach() functions into a single, async beforeEach().

compileComponents() 方法返回一個承諾,所以你可以透過把同步程式碼移到 then(...) 回呼(Callback)中, 以便在編譯完成之後 執行那些同步準備任務。

The compileComponents() method returns a promise so you can perform the synchronous setup tasks after compilation by moving the synchronous code into a then(...) callback.

app/banner/banner-external.component.spec.ts (one beforeEach)
      
      beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [BannerComponent],
  }).compileComponents();
  fixture = TestBed.createComponent(BannerComponent);
  component = fixture.componentInstance;
  h1 = fixture.nativeElement.querySelector('h1');
});
    

compileComponents() 是無害的

compileComponents() is harmless

在不需要 compileComponents() 的時候呼叫它也不會有害處。

There's no harm in calling compileComponents() when it's not required.

雖然在執行 ng test 時永遠都不需要呼叫 compileComponents(),但 CLI 產生的元件測試檔案還是會呼叫它。

The component test file generated by the CLI calls compileComponents() even though it is never required when running ng test.

但這篇指南中的這些測試只會在必要時才呼叫 compileComponents

The tests in this guide only call compileComponents when necessary.

準備模組的 imports

Setup with module imports

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

Earlier component tests configured the testing module with a few declarations like this:

app/dashboard/dashboard-hero.component.spec.ts (configure TestBed)
      
      TestBed
    .configureTestingModule({declarations: [DashboardHeroComponent]})
    

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

The DashboardComponent is simple. It needs no help. But more complex components often depend on other components, directives, pipes, and providers and these must be added to the testing module too.

幸運的是,TestBed.configureTestingModule 引數與傳入 @NgModule 裝飾器的元資料一樣,也就是所你也可以指定 providersimports.

Fortunately, the TestBed.configureTestingModule parameter parallels the metadata passed to the @NgModule decorator which means you can also specify providers and imports.

雖然 HeroDetailComponent 很小,結構也很簡單,但是它需要很多幫助。 除了從預設測試模組 CommonModule 中獲得的支援,它還需要:

The HeroDetailComponent requires a lot of help despite its small size and simple construction. In addition to the support it receives from the default testing module CommonModule, it needs:

  • FormsModule 裡的 NgModel 和其它,來進行雙向資料繫結

    NgModel and friends in the FormsModule to enable two-way data binding.

  • shared 目錄裡的 TitleCasePipe

    The TitleCasePipe from the shared folder.

  • 一些路由器服務(測試程式將 stub 偽造它們)

    Router services (which these tests are stubbing).

  • 英雄資料訪問服務(同樣被 stub 偽造了)

    Hero data access services (also stubbed).

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

One approach is to configure the testing module from the individual pieces as in this example:

app/hero/hero-detail.component.spec.ts (FormsModule setup)
      
      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 檔案。

Notice that the beforeEach() is asynchronous and calls TestBed.compileComponents because the HeroDetailComponent has an external template and css file.

如前面的呼叫 compileComponents()中所解釋的那樣,這些測試可以執行在非 CLI 環境下,那裡 Angular 並不會在瀏覽器中編譯它們。

As explained in Calling compileComponents() above, these tests could be run in a non-CLI environment where Angular would have to compile them in the browser.

匯入共享模組

Import a shared module

因為很多應用元件都需要 FormsModuleTitleCasePipe,所以開發者建立了 SharedModule 來把它們及其它常用的部分組合在一起。

Because many app components need the FormsModule and the TitleCasePipe, the developer created a SharedModule to combine these and other frequently requested parts.

這些測試配置也可以使用 SharedModule,如下所示:

The test configuration can use the SharedModule too as seen in this alternative setup:

app/hero/hero-detail.component.spec.ts (SharedModule setup)
      
      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();
});
    

它的匯入宣告少一些(未顯示),稍微乾淨一些,小一些。

It's a bit tighter and smaller, with fewer import statements (not shown).

匯入特性模組

Import a feature module

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

The HeroDetailComponent is part of the HeroModule Feature Module that aggregates more of the interdependent pieces including the SharedModule. Try a test configuration that imports the HeroModule like this one:

app/hero/hero-detail.component.spec.ts (HeroModule setup)
      
      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 宣告都消失了。

That's really crisp. Only the test doubles in the providers remain. Even the HeroDetailComponent declaration is gone.

事實上,如果你試圖宣告它,Angular 就會丟擲一個錯誤,因為 HeroDetailComponent 同時宣告在了 HeroModuleTestBed 建立的 DynamicTestModule 中。

In fact, if you try to declare it, Angular will throw an error because HeroDetailComponent is declared in both the HeroModule and the DynamicTestModule created by the TestBed.

如果模組中有很多共同依賴,並且該模組很小(這也是特性模組的應有形態),那麼直接匯入元件的特性模組可以成為配置這些測試的簡易方式。

Importing the component's feature module can be the easiest way to configure tests when there are many mutual dependencies within the module and the module is small, as feature modules tend to be.

改寫元件的服務提供者

Override component providers

HeroDetailComponent 提供自己的 HeroDetailService 服務。

The HeroDetailComponent provides its own HeroDetailService.

app/hero/hero-detail.component.ts (prototype)
      
      @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.configureTestingModuleproviders 中 stub 偽造元件的 HeroDetailService 是不可行的。 這些是測試模組的提供者,而非元件的。元件級別的供應商應該在fixture 級別準備的依賴注入器。

It's not possible to stub the component's HeroDetailService in the providers of the TestBed.configureTestingModule. Those are providers for the testing module, not the component. They prepare the dependency injector at the fixture level.

Angular 會使用自己的注入器來建立這些元件,這個注入器是夾具的注入器的子注入器。 它使用這個子注入器註冊了該元件服務提供者(這裡是 HeroDetailService )。

Angular creates the component with its own injector, which is a child of the fixture injector. It registers the component's providers (the HeroDetailService in this case) with the child injector.

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

A test cannot get to child injector services from the fixture injector. And TestBed.configureTestingModule can't configure them either.

Angular 始終都在建立真實 HeroDetailService 的實例。

Angular has been creating new instances of the real HeroDetailService all along!

如果 HeroDetailService 向遠端伺服器發出自己的 XHR 請求,這些測試可能會失敗或者超時。 這個遠端伺服器可能根本不存在。

These tests could fail or timeout if the HeroDetailService made its own XHR calls to a remote server. There might not be a remote server to call.

幸運的是,HeroDetailService 將遠端資料訪問的責任交給了注入進來的 HeroService

Fortunately, the HeroDetailService delegates responsibility for remote data access to an injected HeroService.

app/hero/hero-detail.service.ts (prototype)
      
      @Injectable()
export class HeroDetailService {
  constructor(private heroService: HeroService) {  }
/* . . . */
}
    

前面的測試配置使用 TestHeroService 替換了真實的 HeroService,它攔截了發往伺服器的請求,並偽造了伺服器的響應。

The previous test configuration replaces the real HeroService with a TestHeroService that intercepts server requests and fakes their responses.

如果你沒有這麼幸運怎麼辦?如果偽造 HeroService 很難怎麼辦?如果 HeroDetailService 自己發出伺服器請求怎麼辦?

What if you aren't so lucky. What if faking the HeroService is hard? What if HeroDetailService makes its own server requests?

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

The TestBed.overrideComponent method can replace the component's providers with easy-to-manage test doubles as seen in the following setup variation:

app/hero/hero-detail.component.spec.ts (Override setup)
      
      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,因為並不需要

Notice that TestBed.configureTestingModule no longer provides a (fake) HeroService because it's not needed.

overrideComponent 方法

The overrideComponent method

注意這個 overrideComponent 方法。

Focus on the overrideComponent method.

app/hero/hero-detail.component.spec.ts (overrideComponent)
      
      .overrideComponent(
    HeroDetailComponent,
    {set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}})
    

它接受兩個引數:要改寫的元件型別(HeroDetailComponent),以及用於改寫的元資料物件。 用於改寫的元資料物件是一個泛型,其定義如下:

It takes two arguments: the component type to override (HeroDetailComponent) and an override metadata object. The override metadata object is a generic defined as follows:

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

元資料過載物件可以新增和刪除元資料屬性的專案,也可以徹底重設這些屬性。 這個例子重新設定了元件的 providers 元資料。

A metadata override object can either add-and-remove elements in metadata properties or completely reset those properties. This example resets the component's providers metadata.

這個型別引數 T 就是你傳給 @Component 裝飾器的元資料:

The type parameter, T, is the kind of metadata you'd pass to the @Component decorator:

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

提供 間諜樁 (HeroDetailServiceSpy)

Provide a spy stub (HeroDetailServiceSpy)

這個例子把元件的 providers 陣列完全替換成了一個包含 HeroDetailServiceSpy 的新陣列。

This example completely replaces the component's providers array with a new array containing a HeroDetailServiceSpy.

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

The HeroDetailServiceSpy is a stubbed version of the real HeroDetailService that fakes all necessary features of that service. It neither injects nor delegates to the lower level HeroService so there's no need to provide a test double for that.

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

The related HeroDetailComponent tests will assert that methods of the HeroDetailService were called by spying on the service methods. Accordingly, the stub implements its methods as spies:

app/hero/hero-detail.component.spec.ts (HeroDetailServiceSpy)
      
      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)));
}
    

改寫測試

The override tests

現在,測試程式可以透過操控這個 spy-stub 的 testHero,直接控制組件的英雄,並確認那個服務方法被呼叫過。

Now the tests can control the component's hero directly by manipulating the spy-stub's testHero and confirm that service methods were called.

app/hero/hero-detail.component.spec.ts (override tests)
      
      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()).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;

     // In older browsers, such as IE, you might need a CustomEvent instead. See
     // https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#Polyfill
     page.nameInput.dispatchEvent(new Event('input')); // tell Angular

     expect(component.hero.name).toBe(newName, 'component hero has new name');
     expect(hdsSpy.testHero.name).toBe(origName, 'service hero unchanged before save');

     click(page.saveBtn);
     expect(hdsSpy.saveHero.calls.count()).toBe(1, 'saveHero called once');

     tick();  // wait for async save to complete
     expect(hdsSpy.testHero.name).toBe(newName, 'service hero has new name after save');
     expect(page.navigateSpy.calls.any()).toBe(true, 'router.navigate called');
   }));
    

更多的改寫

More overrides

TestBed.overrideComponent 方法可以在相同或不同的元件中被反覆呼叫。 TestBed 還提供了類似的 overrideDirectiveoverrideModuleoverridePipe 方法,用來深入並重載這些其它類別的部件。

The TestBed.overrideComponent method can be called multiple times for the same or different components. The TestBed offers similar overrideDirective, overrideModule, and overridePipe methods for digging into and replacing parts of these other classes.

自己探索這些選項和組合。

Explore the options and combinations on your own.