詳細ガイド
テスト

コンポーネントテストシナリオ

このガイドでは、一般的なコンポーネントテストのユースケースについて説明します。

コンポーネントバインディング

サンプルアプリケーションでは、BannerComponentはHTMLテンプレートに静的なタイトルテキストを表示します。

いくつかの変更を加えた後、BannerComponentは、次のようにコンポーネントのtitleプロパティにバインドすることで動的なタイトルを表示します。

app/banner/banner.component.ts

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

これは最小限のものですが、コンポーネントが実際に期待どおりに正しいコンテンツを表示していることを確認するためのテストを追加することにします。

<h1>のクエリ

タイトルプロパティの補間バインディングを囲む<h1>要素の値を検査する一連のテストを作成します。

beforeEachを更新して、標準のHTMLquerySelectorでその要素を見つけ、h1変数に割り当てます。

app/banner/banner.component.spec.ts (setup)

      
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => {  let component: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      imports: [BannerComponent],    });    fixture = TestBed.createComponent(BannerComponent);    component = fixture.componentInstance; // BannerComponent test instance    h1 = fixture.nativeElement.querySelector('h1');  });  it('no title in the DOM after createComponent()', () => {    expect(h1.textContent).toEqual('');  });  it('should display original title', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display original title after detectChanges()', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display a different test title', () => {    component.title = 'Test Title';    fixture.detectChanges();    expect(h1.textContent).toContain('Test Title');  });});

createComponent()はデータをバインドしません

最初のテストでは、画面にデフォルトのtitleが表示されることを確認したいと考えています。 直感的には、次のように<h1>をすぐに検査するテストを作成するでしょう。

      
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => {  let component: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      imports: [BannerComponent],    });    fixture = TestBed.createComponent(BannerComponent);    component = fixture.componentInstance; // BannerComponent test instance    h1 = fixture.nativeElement.querySelector('h1');  });  it('no title in the DOM after createComponent()', () => {    expect(h1.textContent).toEqual('');  });  it('should display original title', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display original title after detectChanges()', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display a different test title', () => {    component.title = 'Test Title';    fixture.detectChanges();    expect(h1.textContent).toContain('Test Title');  });});

このテストは失敗します、メッセージは次のとおりです。

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

バインディングは、Angularが変更検知を実行したときに発生します。

本番環境では、Angularがコンポーネントを作成したり、ユーザーがキーストロークを入力したりしたときに、変更検知は自動的に開始されます。

TestBed.createComponentはデフォルトで変更検知をトリガーしません。これは、修正されたテストで確認できます。

      
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => {  let component: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      imports: [BannerComponent],    });    fixture = TestBed.createComponent(BannerComponent);    component = fixture.componentInstance; // BannerComponent test instance    h1 = fixture.nativeElement.querySelector('h1');  });  it('no title in the DOM after createComponent()', () => {    expect(h1.textContent).toEqual('');  });  it('should display original title', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display original title after detectChanges()', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display a different test title', () => {    component.title = 'Test Title';    fixture.detectChanges();    expect(h1.textContent).toContain('Test Title');  });});

detectChanges()

TestBedfixture.detectChanges()を呼び出してデータバインディングを実行するように指示することができます。 そうすれば初めて、<h1>に期待どおりのタイトルが表示されます。

      
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => {  let component: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      imports: [BannerComponent],    });    fixture = TestBed.createComponent(BannerComponent);    component = fixture.componentInstance; // BannerComponent test instance    h1 = fixture.nativeElement.querySelector('h1');  });  it('no title in the DOM after createComponent()', () => {    expect(h1.textContent).toEqual('');  });  it('should display original title', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display original title after detectChanges()', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display a different test title', () => {    component.title = 'Test Title';    fixture.detectChanges();    expect(h1.textContent).toContain('Test Title');  });});

変更検知が遅延されるのは意図的なことであり、便利です。 これにより、テスターはAngularがデータバインディングを開始し、ライフサイクルフックを呼び出す前に、コンポーネントの状態を検査および変更することができます。

次に、fixture.detectChanges()を呼び出す前に、コンポーネントのtitleプロパティを変更するテストを示します。

      
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => {  let component: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      imports: [BannerComponent],    });    fixture = TestBed.createComponent(BannerComponent);    component = fixture.componentInstance; // BannerComponent test instance    h1 = fixture.nativeElement.querySelector('h1');  });  it('no title in the DOM after createComponent()', () => {    expect(h1.textContent).toEqual('');  });  it('should display original title', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display original title after detectChanges()', () => {    fixture.detectChanges();    expect(h1.textContent).toContain(component.title);  });  it('should display a different test title', () => {    component.title = 'Test Title';    fixture.detectChanges();    expect(h1.textContent).toContain('Test Title');  });});

自動変更検知

BannerComponentのテストでは、頻繁にdetectChangesを呼び出しています。 多くのテスターは、Angularのテスト環境が本番環境のように自動的に変更検知を実行することを好みます。

これは、TestBedComponentFixtureAutoDetectプロバイダーで設定することで可能です。 まず、テストユーティリティライブラリからインポートします。

app/banner/banner.component.detect-changes.spec.ts (import)

      
import {ComponentFixtureAutoDetect} from '@angular/core/testing';import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (AutoChangeDetect)', () => {  let comp: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: [{provide: ComponentFixtureAutoDetect, useValue: true}],    });    fixture = TestBed.createComponent(BannerComponent);    comp = fixture.componentInstance;    h1 = fixture.nativeElement.querySelector('h1');  });  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', async () => {    const oldTitle = comp.title;    const newTitle = 'Test Title';    comp.title.set(newTitle);    // Displayed title is old because Angular didn't yet run change detection    expect(h1.textContent).toContain(oldTitle);    await fixture.whenStable();    expect(h1.textContent).toContain(newTitle);  });  it('should display updated title after detectChanges', () => {    comp.title.set('Test Title');    fixture.detectChanges(); // detect changes explicitly    expect(h1.textContent).toContain(comp.title);  });});

次に、テストモジュール設定のproviders配列に追加します。

app/banner/banner.component.detect-changes.spec.ts (AutoDetect)

      
import {ComponentFixtureAutoDetect} from '@angular/core/testing';import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (AutoChangeDetect)', () => {  let comp: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: [{provide: ComponentFixtureAutoDetect, useValue: true}],    });    fixture = TestBed.createComponent(BannerComponent);    comp = fixture.componentInstance;    h1 = fixture.nativeElement.querySelector('h1');  });  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', async () => {    const oldTitle = comp.title;    const newTitle = 'Test Title';    comp.title.set(newTitle);    // Displayed title is old because Angular didn't yet run change detection    expect(h1.textContent).toContain(oldTitle);    await fixture.whenStable();    expect(h1.textContent).toContain(newTitle);  });  it('should display updated title after detectChanges', () => {    comp.title.set('Test Title');    fixture.detectChanges(); // detect changes explicitly    expect(h1.textContent).toContain(comp.title);  });});

HELPFUL: fixture.autoDetectChanges()関数を代わりに使用することもできます。 これは、フィクスチャのコンポーネントの状態を更新した後、自動変更検知を有効にする場合のみです。 また、自動変更検知はprovideExperimentalZonelessChangeDetectionを使用する場合、デフォルトで有効になっており、オフにすることは推奨されません。

次に、自動変更検知がどのように機能するかを示す3つのテストを示します。

app/banner/banner.component.detect-changes.spec.ts (AutoDetect Tests)

      
import {ComponentFixtureAutoDetect} from '@angular/core/testing';import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (AutoChangeDetect)', () => {  let comp: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: [{provide: ComponentFixtureAutoDetect, useValue: true}],    });    fixture = TestBed.createComponent(BannerComponent);    comp = fixture.componentInstance;    h1 = fixture.nativeElement.querySelector('h1');  });  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', async () => {    const oldTitle = comp.title;    const newTitle = 'Test Title';    comp.title.set(newTitle);    // Displayed title is old because Angular didn't yet run change detection    expect(h1.textContent).toContain(oldTitle);    await fixture.whenStable();    expect(h1.textContent).toContain(newTitle);  });  it('should display updated title after detectChanges', () => {    comp.title.set('Test Title');    fixture.detectChanges(); // detect changes explicitly    expect(h1.textContent).toContain(comp.title);  });});

最初のテストは、自動変更検知の利点を示しています。

2番目と3番目のテストは、重要な制限を明らかにしています。 Angularのテスト環境は、コンポーネントのtitleを変更したテストケース内で更新が行われた場合、変更検知を同期的に実行しません。 テストは、別の変更検知を待つためにawait fixture.whenStableを呼び出す必要があります。

HELPFUL: Angularは、信号ではない値への直接的な更新については知りません。 変更検知がスケジュールされるようにするための最も簡単な方法は、テンプレートで読み取られる値に信号を使用することです。

dispatchEvent()を使用して入力値を変更する

ユーザー入力をシミュレートするには、入力要素を見つけ、そのvalueプロパティを設定します。

しかし、重要な中間ステップがあります。

Angularは、入力要素のvalueプロパティを設定したことを知りません。 dispatchEvent()を呼び出して要素のinputイベントを発生させるまで、そのプロパティを読み込みません。

次の例は、正しいシーケンスを示しています。

app/hero/hero-detail.component.spec.ts (pipe test)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

外部ファイルを持つコンポーネント

前のBannerComponentは、それぞれ@Component.template@Component.stylesプロパティで指定されたインラインテンプレートインラインcssで定義されています。

多くのコンポーネントは、それぞれ@Component.templateUrl@Component.styleUrlsプロパティで、外部テンプレート外部cssを指定します。BannerComponentの次のバリアントは、そのようにします。

app/banner/banner-external.component.ts (metadata)

      
import {Component} from '@angular/core';@Component({  standalone: true,  selector: 'app-banner',  templateUrl: './banner-external.component.html',  styleUrls: ['./banner-external.component.css'],})export class BannerComponent {  title = 'Test Tour of Heroes';}

この構文は、Angularコンパイラーに、コンポーネントのコンパイル中に外部ファイルを読み取るように指示します。

これは、CLIng testコマンドを実行するときに問題になりません。なぜなら、CLIはテストを実行する前にアプリケーションをコンパイルするからです。

しかし、CLI以外の環境でテストを実行する場合、このコンポーネントのテストは失敗する可能性があります。 たとえば、plunkerなどのWebコーディング環境でBannerComponentのテストを実行すると、次のようなメッセージが表示されます。

      
Error: This test module uses the component BannerComponentwhich is using a "templateUrl" or "styleUrls", but they were never compiled.Please call "TestBed.compileComponents" before your test.

このテストエラーメッセージは、ランタイム環境がテスト自体の実行中にソースコードをコンパイルするときに発生します。

この問題を解決するには、次のcompileComponentsの呼び出しセクションで説明されているように、compileComponents()を呼び出します。

依存関係を持つコンポーネント

コンポーネントは、多くの場合、サービス依存関係を持ちます。

WelcomeComponentは、ログインしたユーザーに歓迎メッセージを表示します。 これは、注入されたUserServiceのプロパティに基づいて、ユーザーが誰かを知っています。

app/welcome/welcome.component.ts

      
import {Component, OnInit, signal} from '@angular/core';import {UserService} from '../model/user.service';@Component({  standalone: true,  selector: 'app-welcome',  template: '<h3 class="welcome"><i>{{welcome()}}</i></h3>',})export class WelcomeComponent implements OnInit {  welcome = signal('');  constructor(private userService: UserService) {}  ngOnInit(): void {    this.welcome.set(      this.userService.isLoggedIn() ? 'Welcome, ' + this.userService.user().name : 'Please log in.',    );  }}

WelcomeComponentには、サービスと対話する意思決定ロジックがあり、このコンポーネントのテスト価値を高めています。

サービステストダブルを提供する

テスト対象のコンポーネントは、実際のサービスを提供する必要はありません。

実際のUserServiceを注入するのは難しい場合があります。 実際のサービスは、ユーザーにログイン資格情報の入力を求めて、認証サーバーにアクセスしようとするかもしれません。 これらの動作は、インターセプトするのが難しい場合があります。テストダブルを使用すると、テストが本番環境とは異なる動作をするため、控えめに使用してください。

注入されたサービスを取得する

テストでは、WelcomeComponentに注入されたUserServiceにアクセスする必要があります。

Angularには、階層的な注入システムがあります。 TestBedによって作成されたルートインジェクターから、コンポーネントツリーを下って、複数のレベルにインジェクターが存在する可能性があります。

注入されたサービスを取得する最も安全な方法は、常に動作する方法であり、 テスト対象のコンポーネントのインジェクターから取得することです。 コンポーネントインジェクターは、フィクスチャのDebugElementのプロパティです。

WelcomeComponent's injector

      
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';import {UserService} from '../model/user.service';import {WelcomeComponent} from './welcome.component';class MockUserService {  isLoggedIn = true;  user = {name: 'Test User'};}describe('WelcomeComponent', () => {  let comp: WelcomeComponent;  let fixture: ComponentFixture<WelcomeComponent>;  let componentUserService: UserService; // the actually injected service  let userService: UserService; // the TestBed injected service  let el: HTMLElement; // the DOM element with the welcome message  beforeEach(() => {    fixture = TestBed.createComponent(WelcomeComponent);    fixture.autoDetectChanges();    comp = fixture.componentInstance;    // UserService actually injected into the component    userService = fixture.debugElement.injector.get(UserService);    componentUserService = userService;    // UserService from the root injector    userService = TestBed.inject(UserService);    //  get the "welcome" element by CSS selector (e.g., by class name)    el = fixture.nativeElement.querySelector('.welcome');  });  it('should welcome the user', async () => {    await fixture.whenStable();    const content = el.textContent;    expect(content).withContext('"Welcome ..."').toContain('Welcome');    expect(content).withContext('expected name').toContain('Test User');  });  it('should welcome "Bubba"', async () => {    userService.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet    await fixture.whenStable();    expect(el.textContent).toContain('Bubba');  });  it('should request login if not logged in', async () => {    userService.isLoggedIn.set(false); // welcome message hasn't been shown yet    await fixture.whenStable();    const content = el.textContent;    expect(content).withContext('not welcomed').not.toContain('Welcome');    expect(content)      .withContext('"log in"')      .toMatch(/log in/i);  });  it("should inject the component's UserService instance", inject(    [UserService],    (service: UserService) => {      expect(service).toBe(componentUserService);    },  ));  it('TestBed and Component UserService should be the same', () => {    expect(userService).toBe(componentUserService);  });});

HELPFUL: これは_通常_は必要ありません。サービスは、多くの場合、ルートまたはTestBedのオーバーライドで提供され、TestBed.inject()を使用してより簡単に取得できます(下記参照)。

TestBed.inject()

これは、フィクスチャのDebugElementを使用してサービスを取得するよりも覚えやすく、冗長性が少なくなります。

このテストスイートでは、UserServiceのプロバイダーはルートテストモジュールのみであるため、次のようにTestBed.inject()を呼び出すのは安全です。

TestBed injector

      
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';import {UserService} from '../model/user.service';import {WelcomeComponent} from './welcome.component';class MockUserService {  isLoggedIn = true;  user = {name: 'Test User'};}describe('WelcomeComponent', () => {  let comp: WelcomeComponent;  let fixture: ComponentFixture<WelcomeComponent>;  let componentUserService: UserService; // the actually injected service  let userService: UserService; // the TestBed injected service  let el: HTMLElement; // the DOM element with the welcome message  beforeEach(() => {    fixture = TestBed.createComponent(WelcomeComponent);    fixture.autoDetectChanges();    comp = fixture.componentInstance;    // UserService actually injected into the component    userService = fixture.debugElement.injector.get(UserService);    componentUserService = userService;    // UserService from the root injector    userService = TestBed.inject(UserService);    //  get the "welcome" element by CSS selector (e.g., by class name)    el = fixture.nativeElement.querySelector('.welcome');  });  it('should welcome the user', async () => {    await fixture.whenStable();    const content = el.textContent;    expect(content).withContext('"Welcome ..."').toContain('Welcome');    expect(content).withContext('expected name').toContain('Test User');  });  it('should welcome "Bubba"', async () => {    userService.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet    await fixture.whenStable();    expect(el.textContent).toContain('Bubba');  });  it('should request login if not logged in', async () => {    userService.isLoggedIn.set(false); // welcome message hasn't been shown yet    await fixture.whenStable();    const content = el.textContent;    expect(content).withContext('not welcomed').not.toContain('Welcome');    expect(content)      .withContext('"log in"')      .toMatch(/log in/i);  });  it("should inject the component's UserService instance", inject(    [UserService],    (service: UserService) => {      expect(service).toBe(componentUserService);    },  ));  it('TestBed and Component UserService should be the same', () => {    expect(userService).toBe(componentUserService);  });});

HELPFUL: TestBed.inject()が機能しないユースケースについては、コンポーネントプロバイダーのオーバーライドセクションを参照してください。このセクションでは、いつ、なぜフィクスチャのインジェクターの代わりにコンポーネントのインジェクターからサービスを取得する必要があるのかを説明しています。

最終的な設定とテスト

次に、TestBed.inject()を使用した完全なbeforeEach()を示します。

app/welcome/welcome.component.spec.ts

      
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';import {UserService} from '../model/user.service';import {WelcomeComponent} from './welcome.component';class MockUserService {  isLoggedIn = true;  user = {name: 'Test User'};}describe('WelcomeComponent', () => {  let comp: WelcomeComponent;  let fixture: ComponentFixture<WelcomeComponent>;  let componentUserService: UserService; // the actually injected service  let userService: UserService; // the TestBed injected service  let el: HTMLElement; // the DOM element with the welcome message  beforeEach(() => {    fixture = TestBed.createComponent(WelcomeComponent);    fixture.autoDetectChanges();    comp = fixture.componentInstance;    // UserService actually injected into the component    userService = fixture.debugElement.injector.get(UserService);    componentUserService = userService;    // UserService from the root injector    userService = TestBed.inject(UserService);    //  get the "welcome" element by CSS selector (e.g., by class name)    el = fixture.nativeElement.querySelector('.welcome');  });  it('should welcome the user', async () => {    await fixture.whenStable();    const content = el.textContent;    expect(content).withContext('"Welcome ..."').toContain('Welcome');    expect(content).withContext('expected name').toContain('Test User');  });  it('should welcome "Bubba"', async () => {    userService.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet    await fixture.whenStable();    expect(el.textContent).toContain('Bubba');  });  it('should request login if not logged in', async () => {    userService.isLoggedIn.set(false); // welcome message hasn't been shown yet    await fixture.whenStable();    const content = el.textContent;    expect(content).withContext('not welcomed').not.toContain('Welcome');    expect(content)      .withContext('"log in"')      .toMatch(/log in/i);  });  it("should inject the component's UserService instance", inject(    [UserService],    (service: UserService) => {      expect(service).toBe(componentUserService);    },  ));  it('TestBed and Component UserService should be the same', () => {    expect(userService).toBe(componentUserService);  });});

次に、いくつかのテストを示します。

app/welcome/welcome.component.spec.ts

      
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';import {UserService} from '../model/user.service';import {WelcomeComponent} from './welcome.component';class MockUserService {  isLoggedIn = true;  user = {name: 'Test User'};}describe('WelcomeComponent', () => {  let comp: WelcomeComponent;  let fixture: ComponentFixture<WelcomeComponent>;  let componentUserService: UserService; // the actually injected service  let userService: UserService; // the TestBed injected service  let el: HTMLElement; // the DOM element with the welcome message  beforeEach(() => {    fixture = TestBed.createComponent(WelcomeComponent);    fixture.autoDetectChanges();    comp = fixture.componentInstance;    // UserService actually injected into the component    userService = fixture.debugElement.injector.get(UserService);    componentUserService = userService;    // UserService from the root injector    userService = TestBed.inject(UserService);    //  get the "welcome" element by CSS selector (e.g., by class name)    el = fixture.nativeElement.querySelector('.welcome');  });  it('should welcome the user', async () => {    await fixture.whenStable();    const content = el.textContent;    expect(content).withContext('"Welcome ..."').toContain('Welcome');    expect(content).withContext('expected name').toContain('Test User');  });  it('should welcome "Bubba"', async () => {    userService.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet    await fixture.whenStable();    expect(el.textContent).toContain('Bubba');  });  it('should request login if not logged in', async () => {    userService.isLoggedIn.set(false); // welcome message hasn't been shown yet    await fixture.whenStable();    const content = el.textContent;    expect(content).withContext('not welcomed').not.toContain('Welcome');    expect(content)      .withContext('"log in"')      .toMatch(/log in/i);  });  it("should inject the component's UserService instance", inject(    [UserService],    (service: UserService) => {      expect(service).toBe(componentUserService);    },  ));  it('TestBed and Component UserService should be the same', () => {    expect(userService).toBe(componentUserService);  });});

最初のテストは、健全性テストです。これは、UserServiceが呼び出され、機能していることを確認します。

HELPFUL: withContext関数(たとえば、'expected name')は、オプションの失敗ラベルです。 期待値が失敗した場合、Jasmineは期待値の失敗メッセージにこのラベルを追加します。 複数の期待値を持つスペックでは、何が間違っていたのか、どの期待値が失敗したのかを明確にするのに役立ちます。

残りのテストは、サービスが異なる値を返した場合に、コンポーネントのロジックが正しいことを確認します。 2番目のテストは、ユーザー名の変更の効果を検証します。 3番目のテストは、ログインしたユーザーがいない場合に、コンポーネントが適切なメッセージを表示することを確認します。

非同期サービスを持つコンポーネント

このサンプルでは、AboutComponentテンプレートはTwainComponentをホストしています。 TwainComponentは、マーク・トウェインの引用を表示します。

app/twain/twain.component.ts (template)

      
import {Component, OnInit, signal} from '@angular/core';import {AsyncPipe} from '@angular/common';import {sharedImports} from '../shared/shared';import {Observable, of} from 'rxjs';import {catchError, startWith} from 'rxjs/operators';import {TwainService} from './twain.service';@Component({  standalone: true,  selector: 'twain-quote',  template: ` <p class="twain">      <i>{{ quote | async }}</i>    </p>    <button type="button" (click)="getQuote()">Next quote</button>    @if (errorMessage()) {      <p class="error">{{ errorMessage() }}</p>    }`,  styles: ['.twain { font-style: italic; } .error { color: red; }'],  imports: [AsyncPipe, sharedImports],})export class TwainComponent implements OnInit {  errorMessage = signal('');  quote?: Observable<string>;  constructor(private twainService: TwainService) {}  ngOnInit(): void {    this.getQuote();  }  getQuote() {    this.errorMessage.set('');    this.quote = this.twainService.getQuote().pipe(      startWith('...'),      catchError((err: any) => {        this.errorMessage.set(err.message || err.toString());        return of('...'); // reset message to placeholder      }),    );  }}

HELPFUL: コンポーネントのquoteプロパティの値は、AsyncPipeを通過します。 つまり、プロパティはPromiseまたはObservableのいずれかを返します。

この例では、TwainComponent.getQuote()メソッドは、quoteプロパティがObservableを返すと伝えています。

app/twain/twain.component.ts (getQuote)

      
import {Component, OnInit, signal} from '@angular/core';import {AsyncPipe} from '@angular/common';import {sharedImports} from '../shared/shared';import {Observable, of} from 'rxjs';import {catchError, startWith} from 'rxjs/operators';import {TwainService} from './twain.service';@Component({  standalone: true,  selector: 'twain-quote',  template: ` <p class="twain">      <i>{{ quote | async }}</i>    </p>    <button type="button" (click)="getQuote()">Next quote</button>    @if (errorMessage()) {      <p class="error">{{ errorMessage() }}</p>    }`,  styles: ['.twain { font-style: italic; } .error { color: red; }'],  imports: [AsyncPipe, sharedImports],})export class TwainComponent implements OnInit {  errorMessage = signal('');  quote?: Observable<string>;  constructor(private twainService: TwainService) {}  ngOnInit(): void {    this.getQuote();  }  getQuote() {    this.errorMessage.set('');    this.quote = this.twainService.getQuote().pipe(      startWith('...'),      catchError((err: any) => {        this.errorMessage.set(err.message || err.toString());        return of('...'); // reset message to placeholder      }),    );  }}

TwainComponentは、注入されたTwainServiceから引用を取得します。 コンポーネントは、サービスが最初の引用を返す前に、プレースホルダー値('...')で返されたObservableを開始します。

catchErrorはサービスエラーをインターセプトし、エラーメッセージを準備し、成功チャネルでプレースホルダー値を返します。

これらはすべて、テストしたい機能です。

スパイによるテスト

コンポーネントをテストする場合は、サービスのパブリックAPIのみが問題になります。 一般的に、テスト自体がリモートサーバーへの呼び出しを行わないようにする必要があります。 そのような呼び出しをエミュレートする必要があります。 このapp/twain/twain.component.spec.tsのセットアップは、その方法の1つを示しています。

app/twain/twain.component.spec.ts (setup)

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => {  let component: TwainComponent;  let fixture: ComponentFixture<TwainComponent>;  let getQuoteSpy: jasmine.Spy;  let quoteEl: HTMLElement;  let testQuote: string;  // Helper function to get the error message element value  // An *ngIf keeps it out of the DOM until there is an error  const errorMessage = () => {    const el = fixture.nativeElement.querySelector('.error');    return el ? el.textContent : null;  };  beforeEach(() => {    TestBed.configureTestingModule({      imports: [TwainComponent],      providers: [TwainService],    });    testQuote = 'Test Quote';    // Create a fake TwainService object with a `getQuote()` spy    const twainService = TestBed.inject(TwainService);    // Make the spy return a synchronous Observable with the test data    getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));    fixture = TestBed.createComponent(TwainComponent);    fixture.autoDetectChanges();    component = fixture.componentInstance;    quoteEl = fixture.nativeElement.querySelector('.twain');  });  describe('when test with synchronous observable', () => {    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    // The quote would not be immediately available if the service were truly async.    it('should show quote after component initialized', async () => {      await fixture.whenStable(); // onInit()      // sync spy result shows testQuote immediately after init      expect(quoteEl.textContent).toBe(testQuote);      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    // The error would not be immediately available if the service were truly async.    // Use `fakeAsync` because the component error calls `setTimeout`    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an error observable after a timeout      getQuoteSpy.and.returnValue(        defer(() => {          return new Promise((resolve, reject) => {            setTimeout(() => {              reject('TwainService test failure');            });          });        }),      );      fixture.detectChanges(); // onInit()      // sync spy errors immediately after init      tick(); // flush the setTimeout()      fixture.detectChanges(); // update errorMessage within setTimeout()      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });  describe('when test with asynchronous observable', () => {    beforeEach(() => {      // Simulate delayed observable values with the `asyncData()` helper      getQuoteSpy.and.returnValue(asyncData(testQuote));    });    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    it('should still not show quote after component initialized', () => {      fixture.detectChanges();      // getQuote service is async => still has not returned with quote      // so should show the start value, '...'      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      expect(errorMessage()).withContext('should not show error').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      tick(); // flush the observable to get the quote      fixture.detectChanges(); // update view      expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    }));    it('should show quote after getQuote (async)', async () => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      await fixture.whenStable();      // wait for async getQuote      fixture.detectChanges(); // update view with quote      expect(quoteEl.textContent).toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    });    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an async error observable      getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));      fixture.detectChanges();      tick(); // component shows error after a setTimeout()      fixture.detectChanges(); // update error message      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });});

スパイに注目してください。

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => {  let component: TwainComponent;  let fixture: ComponentFixture<TwainComponent>;  let getQuoteSpy: jasmine.Spy;  let quoteEl: HTMLElement;  let testQuote: string;  // Helper function to get the error message element value  // An *ngIf keeps it out of the DOM until there is an error  const errorMessage = () => {    const el = fixture.nativeElement.querySelector('.error');    return el ? el.textContent : null;  };  beforeEach(() => {    TestBed.configureTestingModule({      imports: [TwainComponent],      providers: [TwainService],    });    testQuote = 'Test Quote';    // Create a fake TwainService object with a `getQuote()` spy    const twainService = TestBed.inject(TwainService);    // Make the spy return a synchronous Observable with the test data    getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));    fixture = TestBed.createComponent(TwainComponent);    fixture.autoDetectChanges();    component = fixture.componentInstance;    quoteEl = fixture.nativeElement.querySelector('.twain');  });  describe('when test with synchronous observable', () => {    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    // The quote would not be immediately available if the service were truly async.    it('should show quote after component initialized', async () => {      await fixture.whenStable(); // onInit()      // sync spy result shows testQuote immediately after init      expect(quoteEl.textContent).toBe(testQuote);      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    // The error would not be immediately available if the service were truly async.    // Use `fakeAsync` because the component error calls `setTimeout`    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an error observable after a timeout      getQuoteSpy.and.returnValue(        defer(() => {          return new Promise((resolve, reject) => {            setTimeout(() => {              reject('TwainService test failure');            });          });        }),      );      fixture.detectChanges(); // onInit()      // sync spy errors immediately after init      tick(); // flush the setTimeout()      fixture.detectChanges(); // update errorMessage within setTimeout()      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });  describe('when test with asynchronous observable', () => {    beforeEach(() => {      // Simulate delayed observable values with the `asyncData()` helper      getQuoteSpy.and.returnValue(asyncData(testQuote));    });    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    it('should still not show quote after component initialized', () => {      fixture.detectChanges();      // getQuote service is async => still has not returned with quote      // so should show the start value, '...'      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      expect(errorMessage()).withContext('should not show error').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      tick(); // flush the observable to get the quote      fixture.detectChanges(); // update view      expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    }));    it('should show quote after getQuote (async)', async () => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      await fixture.whenStable();      // wait for async getQuote      fixture.detectChanges(); // update view with quote      expect(quoteEl.textContent).toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    });    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an async error observable      getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));      fixture.detectChanges();      tick(); // component shows error after a setTimeout()      fixture.detectChanges(); // update error message      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });});

スパイは、getQuoteへの呼び出しが、テスト引用を含むObservableを受け取るように設計されています。 実際のgetQuote()メソッドとは異なり、このスパイはサーバーをバイパスし、値がすぐに利用可能な同期Observableを返します。

このスパイを使用すると、Observableが同期的なものであっても、多くの役立つテストを作成できます。

HELPFUL: スパイの使用は、テストに必要なものに限定するのが最善です。必要なもの以上のモックやスパイを作成すると、壊れやすくなる可能性があります。コンポーネントとインジェクタブルが進化するにつれて、関連のないテストは、それ以外ではテストに影響を与えないのに十分な動作をモックしなくなったために失敗する可能性があります。

fakeAsync()による非同期テスト

fakeAsync()機能を使用するには、テストセットアップファイルにzone.js/testingをインポートする必要があります。 Angular CLIでプロジェクトを作成した場合、zone-testingはすでにsrc/test.tsにインポートされています。

次のテストは、サービスがErrorObservableを返した場合に期待される動作を確認します。

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => {  let component: TwainComponent;  let fixture: ComponentFixture<TwainComponent>;  let getQuoteSpy: jasmine.Spy;  let quoteEl: HTMLElement;  let testQuote: string;  // Helper function to get the error message element value  // An *ngIf keeps it out of the DOM until there is an error  const errorMessage = () => {    const el = fixture.nativeElement.querySelector('.error');    return el ? el.textContent : null;  };  beforeEach(() => {    TestBed.configureTestingModule({      imports: [TwainComponent],      providers: [TwainService],    });    testQuote = 'Test Quote';    // Create a fake TwainService object with a `getQuote()` spy    const twainService = TestBed.inject(TwainService);    // Make the spy return a synchronous Observable with the test data    getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));    fixture = TestBed.createComponent(TwainComponent);    fixture.autoDetectChanges();    component = fixture.componentInstance;    quoteEl = fixture.nativeElement.querySelector('.twain');  });  describe('when test with synchronous observable', () => {    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    // The quote would not be immediately available if the service were truly async.    it('should show quote after component initialized', async () => {      await fixture.whenStable(); // onInit()      // sync spy result shows testQuote immediately after init      expect(quoteEl.textContent).toBe(testQuote);      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    // The error would not be immediately available if the service were truly async.    // Use `fakeAsync` because the component error calls `setTimeout`    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an error observable after a timeout      getQuoteSpy.and.returnValue(        defer(() => {          return new Promise((resolve, reject) => {            setTimeout(() => {              reject('TwainService test failure');            });          });        }),      );      fixture.detectChanges(); // onInit()      // sync spy errors immediately after init      tick(); // flush the setTimeout()      fixture.detectChanges(); // update errorMessage within setTimeout()      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });  describe('when test with asynchronous observable', () => {    beforeEach(() => {      // Simulate delayed observable values with the `asyncData()` helper      getQuoteSpy.and.returnValue(asyncData(testQuote));    });    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    it('should still not show quote after component initialized', () => {      fixture.detectChanges();      // getQuote service is async => still has not returned with quote      // so should show the start value, '...'      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      expect(errorMessage()).withContext('should not show error').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      tick(); // flush the observable to get the quote      fixture.detectChanges(); // update view      expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    }));    it('should show quote after getQuote (async)', async () => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      await fixture.whenStable();      // wait for async getQuote      fixture.detectChanges(); // update view with quote      expect(quoteEl.textContent).toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    });    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an async error observable      getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));      fixture.detectChanges();      tick(); // component shows error after a setTimeout()      fixture.detectChanges(); // update error message      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });});

HELPFUL: it()関数は、次の形式の引数を受け取ります。

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

fakeAsync()関数は、特別なfakeAsync test zoneでテスト本体を実行することで、線形のコーディングスタイルを可能にします。 テスト本体は同期的に見えるようになります。 制御フローを妨げるネストされた構文(Promise.then()など)はありません。

HELPFUL: 制限事項: fakeAsync()関数は、テスト本体がXMLHttpRequest(XHR)呼び出しをすると機能しません。 テスト内のXHR呼び出しはまれですが、XHRを呼び出す必要がある場合は、waitForAsync()セクションを参照してください。

IMPORTANT: fakeAsyncゾーン内で発生する非同期タスクは、flushまたはtickを使用して手動で実行する必要があることに注意してください。 fakeAsyncテストヘルパーを使用して時間を進めずに、完了するまで待つと(つまり、fixture.whenStableを使用)、テストは失敗する可能性が高いです。 詳細については、以下を参照してください。

tick()関数

tick()を呼び出して、仮想クロックを進める必要があります。

tick()を呼び出すと、保留中の非同期アクティビティがすべて完了するまで、仮想クロックが前進します。 この場合、ObservableのsetTimeout()を待ちます。

tick()関数は、millistickOptionsをパラメーターとして受け取ります。millisパラメーターは、仮想クロックがどれだけ進むかを指定し、指定されていない場合はデフォルトで0になります。 たとえば、fakeAsync()テストにsetTimeout(fn, 100)がある場合、fnのコールバックをトリガーするには、tick(100)を使用する必要があります。 オプションのtickOptionsパラメーターには、processNewMacroTasksSynchronouslyという名前のプロパティがあります。processNewMacroTasksSynchronouslyプロパティは、ティック時に新しい生成されたマクロタスクを呼び出すかどうかを表し、デフォルトではtrueです。

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => {  describe('async', () => {    let actuallyDone = false;    beforeEach(() => {      actuallyDone = false;    });    afterEach(() => {      expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);    });    it('should run normal test', () => {      actuallyDone = true;    });    it('should run normal async test', (done: DoneFn) => {      setTimeout(() => {        actuallyDone = true;        done();      }, 0);    });    it('should run async test with task', waitForAsync(() => {      setTimeout(() => {        actuallyDone = true;      }, 0);    }));    it('should run async test with task', waitForAsync(() => {      const id = setInterval(() => {        actuallyDone = true;        clearInterval(id);      }, 100);    }));    it('should run async test with successful promise', waitForAsync(() => {      const p = new Promise((resolve) => {        setTimeout(resolve, 10);      });      p.then(() => {        actuallyDone = true;      });    }));    it('should run async test with failed promise', waitForAsync(() => {      const p = new Promise((resolve, reject) => {        setTimeout(reject, 10);      });      p.catch(() => {        actuallyDone = true;      });    }));    // Use done. Can also use async or fakeAsync.    it('should run async test with successful delayed Observable', (done: DoneFn) => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),        complete: done,      });    });    it('should run async test with successful delayed Observable', waitForAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });    }));    it('should run async test with successful delayed Observable', fakeAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });      tick(10);    }));  });  describe('fakeAsync', () => {    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);    }));    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();    }));    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();    }));    it('should get Date diff correctly in fakeAsync', fakeAsync(() => {      const start = Date.now();      tick(100);      const end = Date.now();      expect(end - start).toBe(100);    }));    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);    }));  });  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);    });  });  describe('test jsonp', () => {    function jsonp(url: string, callback: () => void) {      // do a jsonp call which is not zone aware    }    // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag    // before loading zone.js/testing    it('should wait until promise.then is called', waitForAsync(() => {      let finished = false;      new Promise<void>((res) => {        jsonp('localhost:8080/jsonp', () => {          // success callback and resolve the promise          finished = true;          res();        });      }).then(() => {        // async will wait until promise.then is called        // if __zone_symbol__supportWaitUnResolvedChainedPromise is set        expect(finished).toBe(true);      });    }));  });});

tick()関数は、TestBedでインポートするAngularのテストユーティリティの1つです。 これはfakeAsync()の仲間であり、fakeAsync()本体内でのみ呼び出すことができます。

tickOptions

この例では、ネストされたsetTimeout関数が新しいマクロタスクです。デフォルトでは、tickがsetTimeoutの場合、outsidenestedの両方がトリガーされます。

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => {  describe('async', () => {    let actuallyDone = false;    beforeEach(() => {      actuallyDone = false;    });    afterEach(() => {      expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);    });    it('should run normal test', () => {      actuallyDone = true;    });    it('should run normal async test', (done: DoneFn) => {      setTimeout(() => {        actuallyDone = true;        done();      }, 0);    });    it('should run async test with task', waitForAsync(() => {      setTimeout(() => {        actuallyDone = true;      }, 0);    }));    it('should run async test with task', waitForAsync(() => {      const id = setInterval(() => {        actuallyDone = true;        clearInterval(id);      }, 100);    }));    it('should run async test with successful promise', waitForAsync(() => {      const p = new Promise((resolve) => {        setTimeout(resolve, 10);      });      p.then(() => {        actuallyDone = true;      });    }));    it('should run async test with failed promise', waitForAsync(() => {      const p = new Promise((resolve, reject) => {        setTimeout(reject, 10);      });      p.catch(() => {        actuallyDone = true;      });    }));    // Use done. Can also use async or fakeAsync.    it('should run async test with successful delayed Observable', (done: DoneFn) => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),        complete: done,      });    });    it('should run async test with successful delayed Observable', waitForAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });    }));    it('should run async test with successful delayed Observable', fakeAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });      tick(10);    }));  });  describe('fakeAsync', () => {    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);    }));    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();    }));    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();    }));    it('should get Date diff correctly in fakeAsync', fakeAsync(() => {      const start = Date.now();      tick(100);      const end = Date.now();      expect(end - start).toBe(100);    }));    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);    }));  });  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);    });  });  describe('test jsonp', () => {    function jsonp(url: string, callback: () => void) {      // do a jsonp call which is not zone aware    }    // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag    // before loading zone.js/testing    it('should wait until promise.then is called', waitForAsync(() => {      let finished = false;      new Promise<void>((res) => {        jsonp('localhost:8080/jsonp', () => {          // success callback and resolve the promise          finished = true;          res();        });      }).then(() => {        // async will wait until promise.then is called        // if __zone_symbol__supportWaitUnResolvedChainedPromise is set        expect(finished).toBe(true);      });    }));  });});

場合によっては、ティック時に新しいマクロタスクをトリガーしたくない場合があります。tick(millis, {processNewMacroTasksSynchronously: false})を使用して、新しいマクロタスクを呼び出さないようにすることができます。

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => {  describe('async', () => {    let actuallyDone = false;    beforeEach(() => {      actuallyDone = false;    });    afterEach(() => {      expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);    });    it('should run normal test', () => {      actuallyDone = true;    });    it('should run normal async test', (done: DoneFn) => {      setTimeout(() => {        actuallyDone = true;        done();      }, 0);    });    it('should run async test with task', waitForAsync(() => {      setTimeout(() => {        actuallyDone = true;      }, 0);    }));    it('should run async test with task', waitForAsync(() => {      const id = setInterval(() => {        actuallyDone = true;        clearInterval(id);      }, 100);    }));    it('should run async test with successful promise', waitForAsync(() => {      const p = new Promise((resolve) => {        setTimeout(resolve, 10);      });      p.then(() => {        actuallyDone = true;      });    }));    it('should run async test with failed promise', waitForAsync(() => {      const p = new Promise((resolve, reject) => {        setTimeout(reject, 10);      });      p.catch(() => {        actuallyDone = true;      });    }));    // Use done. Can also use async or fakeAsync.    it('should run async test with successful delayed Observable', (done: DoneFn) => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),        complete: done,      });    });    it('should run async test with successful delayed Observable', waitForAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });    }));    it('should run async test with successful delayed Observable', fakeAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });      tick(10);    }));  });  describe('fakeAsync', () => {    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);    }));    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();    }));    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();    }));    it('should get Date diff correctly in fakeAsync', fakeAsync(() => {      const start = Date.now();      tick(100);      const end = Date.now();      expect(end - start).toBe(100);    }));    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);    }));  });  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);    });  });  describe('test jsonp', () => {    function jsonp(url: string, callback: () => void) {      // do a jsonp call which is not zone aware    }    // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag    // before loading zone.js/testing    it('should wait until promise.then is called', waitForAsync(() => {      let finished = false;      new Promise<void>((res) => {        jsonp('localhost:8080/jsonp', () => {          // success callback and resolve the promise          finished = true;          res();        });      }).then(() => {        // async will wait until promise.then is called        // if __zone_symbol__supportWaitUnResolvedChainedPromise is set        expect(finished).toBe(true);      });    }));  });});

fakeAsync()内の日付の比較

fakeAsync()は時間の経過をシミュレートするため、fakeAsync()内で日付の差を計算することができます。

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => {  describe('async', () => {    let actuallyDone = false;    beforeEach(() => {      actuallyDone = false;    });    afterEach(() => {      expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);    });    it('should run normal test', () => {      actuallyDone = true;    });    it('should run normal async test', (done: DoneFn) => {      setTimeout(() => {        actuallyDone = true;        done();      }, 0);    });    it('should run async test with task', waitForAsync(() => {      setTimeout(() => {        actuallyDone = true;      }, 0);    }));    it('should run async test with task', waitForAsync(() => {      const id = setInterval(() => {        actuallyDone = true;        clearInterval(id);      }, 100);    }));    it('should run async test with successful promise', waitForAsync(() => {      const p = new Promise((resolve) => {        setTimeout(resolve, 10);      });      p.then(() => {        actuallyDone = true;      });    }));    it('should run async test with failed promise', waitForAsync(() => {      const p = new Promise((resolve, reject) => {        setTimeout(reject, 10);      });      p.catch(() => {        actuallyDone = true;      });    }));    // Use done. Can also use async or fakeAsync.    it('should run async test with successful delayed Observable', (done: DoneFn) => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),        complete: done,      });    });    it('should run async test with successful delayed Observable', waitForAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });    }));    it('should run async test with successful delayed Observable', fakeAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });      tick(10);    }));  });  describe('fakeAsync', () => {    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);    }));    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();    }));    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();    }));    it('should get Date diff correctly in fakeAsync', fakeAsync(() => {      const start = Date.now();      tick(100);      const end = Date.now();      expect(end - start).toBe(100);    }));    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);    }));  });  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);    });  });  describe('test jsonp', () => {    function jsonp(url: string, callback: () => void) {      // do a jsonp call which is not zone aware    }    // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag    // before loading zone.js/testing    it('should wait until promise.then is called', waitForAsync(() => {      let finished = false;      new Promise<void>((res) => {        jsonp('localhost:8080/jsonp', () => {          // success callback and resolve the promise          finished = true;          res();        });      }).then(() => {        // async will wait until promise.then is called        // if __zone_symbol__supportWaitUnResolvedChainedPromise is set        expect(finished).toBe(true);      });    }));  });});

jasmine.clockfakeAsync()

Jasmineは、日付をモックするためのclock機能も提供しています。 Angularは、jasmine.clock().install()fakeAsync()メソッド内で呼び出された後、jasmine.clock().uninstall()が呼び出されるまで実行されるテストを自動的に実行します。 fakeAsync()は必要なく、ネストされている場合はエラーをスローします。

デフォルトでは、この機能はオフになっています。 有効にするには、zone-testingをインポートする前にグローバルフラグを設定します。

Angular CLIを使用している場合は、src/test.tsでこのフラグを設定します。

      
[window as any]('__zone_symbol__fakeAsyncPatchLock') = true;import 'zone.js/testing';
      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => {  describe('async', () => {    let actuallyDone = false;    beforeEach(() => {      actuallyDone = false;    });    afterEach(() => {      expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);    });    it('should run normal test', () => {      actuallyDone = true;    });    it('should run normal async test', (done: DoneFn) => {      setTimeout(() => {        actuallyDone = true;        done();      }, 0);    });    it('should run async test with task', waitForAsync(() => {      setTimeout(() => {        actuallyDone = true;      }, 0);    }));    it('should run async test with task', waitForAsync(() => {      const id = setInterval(() => {        actuallyDone = true;        clearInterval(id);      }, 100);    }));    it('should run async test with successful promise', waitForAsync(() => {      const p = new Promise((resolve) => {        setTimeout(resolve, 10);      });      p.then(() => {        actuallyDone = true;      });    }));    it('should run async test with failed promise', waitForAsync(() => {      const p = new Promise((resolve, reject) => {        setTimeout(reject, 10);      });      p.catch(() => {        actuallyDone = true;      });    }));    // Use done. Can also use async or fakeAsync.    it('should run async test with successful delayed Observable', (done: DoneFn) => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),        complete: done,      });    });    it('should run async test with successful delayed Observable', waitForAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });    }));    it('should run async test with successful delayed Observable', fakeAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });      tick(10);    }));  });  describe('fakeAsync', () => {    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);    }));    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();    }));    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();    }));    it('should get Date diff correctly in fakeAsync', fakeAsync(() => {      const start = Date.now();      tick(100);      const end = Date.now();      expect(end - start).toBe(100);    }));    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);    }));  });  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);    });  });  describe('test jsonp', () => {    function jsonp(url: string, callback: () => void) {      // do a jsonp call which is not zone aware    }    // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag    // before loading zone.js/testing    it('should wait until promise.then is called', waitForAsync(() => {      let finished = false;      new Promise<void>((res) => {        jsonp('localhost:8080/jsonp', () => {          // success callback and resolve the promise          finished = true;          res();        });      }).then(() => {        // async will wait until promise.then is called        // if __zone_symbol__supportWaitUnResolvedChainedPromise is set        expect(finished).toBe(true);      });    }));  });});

fakeAsync()内のRxJSスケジューラーの使用

setTimeout()setInterval()と同様に、fakeAsync()でRxJSスケジューラーを使用することもできますが、RxJSスケジューラーをパッチするためにzone.js/plugins/zone-patch-rxjs-fake-asyncをインポートする必要があります。

      
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => {  describe('async', () => {    let actuallyDone = false;    beforeEach(() => {      actuallyDone = false;    });    afterEach(() => {      expect(actuallyDone).withContext('actuallyDone should be true').toBe(true);    });    it('should run normal test', () => {      actuallyDone = true;    });    it('should run normal async test', (done: DoneFn) => {      setTimeout(() => {        actuallyDone = true;        done();      }, 0);    });    it('should run async test with task', waitForAsync(() => {      setTimeout(() => {        actuallyDone = true;      }, 0);    }));    it('should run async test with task', waitForAsync(() => {      const id = setInterval(() => {        actuallyDone = true;        clearInterval(id);      }, 100);    }));    it('should run async test with successful promise', waitForAsync(() => {      const p = new Promise((resolve) => {        setTimeout(resolve, 10);      });      p.then(() => {        actuallyDone = true;      });    }));    it('should run async test with failed promise', waitForAsync(() => {      const p = new Promise((resolve, reject) => {        setTimeout(reject, 10);      });      p.catch(() => {        actuallyDone = true;      });    }));    // Use done. Can also use async or fakeAsync.    it('should run async test with successful delayed Observable', (done: DoneFn) => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),        complete: done,      });    });    it('should run async test with successful delayed Observable', waitForAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });    }));    it('should run async test with successful delayed Observable', fakeAsync(() => {      const source = of(true).pipe(delay(10));      source.subscribe({        next: (val) => (actuallyDone = true),        error: (err) => fail(err),      });      tick(10);    }));  });  describe('fakeAsync', () => {    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);    }));    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();    }));    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();    }));    it('should get Date diff correctly in fakeAsync', fakeAsync(() => {      const start = Date.now();      tick(100);      const end = Date.now();      expect(end - start).toBe(100);    }));    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);    }));  });  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);    });  });  describe('test jsonp', () => {    function jsonp(url: string, callback: () => void) {      // do a jsonp call which is not zone aware    }    // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag    // before loading zone.js/testing    it('should wait until promise.then is called', waitForAsync(() => {      let finished = false;      new Promise<void>((res) => {        jsonp('localhost:8080/jsonp', () => {          // success callback and resolve the promise          finished = true;          res();        });      }).then(() => {        // async will wait until promise.then is called        // if __zone_symbol__supportWaitUnResolvedChainedPromise is set        expect(finished).toBe(true);      });    }));  });});

より多くのmacroTasksをサポートする

デフォルトでは、fakeAsync()は次のmacroTasksをサポートしています。

  • setTimeout
  • setInterval
  • requestAnimationFrame
  • webkitRequestAnimationFrame
  • mozRequestAnimationFrame

HTMLCanvasElement.toBlob()などの他のmacroTasksを実行すると、「fake async testで不明なmacroTaskがスケジュールされました」というエラーがスローされます。

src/app/shared/canvas.component.spec.ts (failing)

      
import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {CanvasComponent} from './canvas.component';describe('CanvasComponent', () => {  beforeEach(() => {    (window as any).__zone_symbol__FakeAsyncTestMacroTask = [      {        source: 'HTMLCanvasElement.toBlob',        callbackArgs: [{size: 200}],      },    ];  });  beforeEach(async () => {    await TestBed.configureTestingModule({      imports: [CanvasComponent],    }).compileComponents();  });  it('should be able to generate blob data from canvas', fakeAsync(() => {    const fixture = TestBed.createComponent(CanvasComponent);    const canvasComp = fixture.componentInstance;    fixture.detectChanges();    expect(canvasComp.blobSize).toBe(0);    tick();    expect(canvasComp.blobSize).toBeGreaterThan(0);  }));});

src/app/shared/canvas.component.ts

      
// Import 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';import {Component, AfterViewInit, ViewChild, ElementRef} from '@angular/core';@Component({  standalone: true,  selector: 'sample-canvas',  template: '<canvas #sampleCanvas width="200" height="200"></canvas>',})export class CanvasComponent implements AfterViewInit {  blobSize = 0;  @ViewChild('sampleCanvas') sampleCanvas!: ElementRef;  ngAfterViewInit() {    const canvas: HTMLCanvasElement = this.sampleCanvas.nativeElement;    const context = canvas.getContext('2d')!;    context.clearRect(0, 0, 200, 200);    context.fillStyle = '#FF1122';    context.fillRect(0, 0, 200, 200);    canvas.toBlob((blob) => {      this.blobSize = blob?.size ?? 0;    });  }}

このような場合をサポートしたい場合は、サポートするmacroTasksをbeforeEach()で定義する必要があります。 たとえば、次のようになります。

src/app/shared/canvas.component.spec.ts (excerpt)

      
import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {CanvasComponent} from './canvas.component';describe('CanvasComponent', () => {  beforeEach(() => {    (window as any).__zone_symbol__FakeAsyncTestMacroTask = [      {        source: 'HTMLCanvasElement.toBlob',        callbackArgs: [{size: 200}],      },    ];  });  beforeEach(async () => {    await TestBed.configureTestingModule({      imports: [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);  }));});

HELPFUL: アプリで<canvas>要素をZone.js対応にするには、zone-patch-canvasパッチをインポートする必要があります(polyfills.tsまたは<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';import {Component, AfterViewInit, ViewChild, ElementRef} from '@angular/core';@Component({  standalone: true,  selector: 'sample-canvas',  template: '<canvas #sampleCanvas width="200" height="200"></canvas>',})export class CanvasComponent implements AfterViewInit {  blobSize = 0;  @ViewChild('sampleCanvas') sampleCanvas!: ElementRef;  ngAfterViewInit() {    const canvas: HTMLCanvasElement = this.sampleCanvas.nativeElement;    const context = canvas.getContext('2d')!;    context.clearRect(0, 0, 200, 200);    context.fillStyle = '#FF1122';    context.fillRect(0, 0, 200, 200);    canvas.toBlob((blob) => {      this.blobSize = blob?.size ?? 0;    });  }}

非同期Observable

これらのテストのテストカバレッジに満足しているかもしれません。

しかし、実際のサービスが完全にこのようには動作していないという事実で悩んでいるかもしれません。 実際のサービスは、リモートサーバーにリクエストを送信します。 サーバーは応答するまでに時間がかかり、応答は前の2つのテストのようにすぐに利用できるわけではありません。

テストでは、次のようにgetQuote()スパイから非同期Observableを返すと、現実の世界をより忠実に反映することができます。

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => {  let component: TwainComponent;  let fixture: ComponentFixture<TwainComponent>;  let getQuoteSpy: jasmine.Spy;  let quoteEl: HTMLElement;  let testQuote: string;  // Helper function to get the error message element value  // An *ngIf keeps it out of the DOM until there is an error  const errorMessage = () => {    const el = fixture.nativeElement.querySelector('.error');    return el ? el.textContent : null;  };  beforeEach(() => {    TestBed.configureTestingModule({      imports: [TwainComponent],      providers: [TwainService],    });    testQuote = 'Test Quote';    // Create a fake TwainService object with a `getQuote()` spy    const twainService = TestBed.inject(TwainService);    // Make the spy return a synchronous Observable with the test data    getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));    fixture = TestBed.createComponent(TwainComponent);    fixture.autoDetectChanges();    component = fixture.componentInstance;    quoteEl = fixture.nativeElement.querySelector('.twain');  });  describe('when test with synchronous observable', () => {    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    // The quote would not be immediately available if the service were truly async.    it('should show quote after component initialized', async () => {      await fixture.whenStable(); // onInit()      // sync spy result shows testQuote immediately after init      expect(quoteEl.textContent).toBe(testQuote);      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    // The error would not be immediately available if the service were truly async.    // Use `fakeAsync` because the component error calls `setTimeout`    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an error observable after a timeout      getQuoteSpy.and.returnValue(        defer(() => {          return new Promise((resolve, reject) => {            setTimeout(() => {              reject('TwainService test failure');            });          });        }),      );      fixture.detectChanges(); // onInit()      // sync spy errors immediately after init      tick(); // flush the setTimeout()      fixture.detectChanges(); // update errorMessage within setTimeout()      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });  describe('when test with asynchronous observable', () => {    beforeEach(() => {      // Simulate delayed observable values with the `asyncData()` helper      getQuoteSpy.and.returnValue(asyncData(testQuote));    });    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    it('should still not show quote after component initialized', () => {      fixture.detectChanges();      // getQuote service is async => still has not returned with quote      // so should show the start value, '...'      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      expect(errorMessage()).withContext('should not show error').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      tick(); // flush the observable to get the quote      fixture.detectChanges(); // update view      expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    }));    it('should show quote after getQuote (async)', async () => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      await fixture.whenStable();      // wait for async getQuote      fixture.detectChanges(); // update view with quote      expect(quoteEl.textContent).toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    });    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an async error observable      getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));      fixture.detectChanges();      tick(); // component shows error after a setTimeout()      fixture.detectChanges(); // update error message      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });});

非同期Observableのヘルパー

非同期Observableは、asyncDataヘルパーによって生成されました。 asyncDataヘルパーは、自分で記述するか、サンプルコードからこのヘルパーをコピーする必要があるユーティリティ関数です。

testing/async-observable-helpers.ts

      
/* * Mock async observables that return asynchronously. * The observable either emits once and completes or errors. * * Must call `tick()` when test with `fakeAsync()`. * * THE FOLLOWING DON'T WORK * Using `of().delay()` triggers TestBed errors; * see https://github.com/angular/angular/issues/10127 . * * Using `asap` scheduler - as in `of(value, asap)` - doesn't work either. */import {defer} from 'rxjs';/** * Create async observable that emits-once and completes * after a JS engine turn */export function asyncData<T>(data: T) {  return defer(() => Promise.resolve(data));}/** * Create async observable error that errors * after a JS engine turn */export function asyncError<T>(errorObject: any) {  return defer(() => Promise.reject(errorObject));}

このヘルパーのObservableは、JavaScriptエンジンの次のターンでdata値を発行します。

RxJS defer()演算子は、Observableを返します。 これは、PromiseまたはObservableのいずれかを返すファクトリ関数を取得します。 何かがdeferのObservableを購読すると、そのファクトリで作成された新しいObservableに購読者を追加します。

defer()演算子は、Promise.resolve()を、HttpClientのように一度発行して完了する新しいObservableに変換します。 購読者は、data値を受け取ると購読解除されます。

非同期エラーを生成するための同様のヘルパーがあります。

      
/* * Mock async observables that return asynchronously. * The observable either emits once and completes or errors. * * Must call `tick()` when test with `fakeAsync()`. * * THE FOLLOWING DON'T WORK * Using `of().delay()` triggers TestBed errors; * see https://github.com/angular/angular/issues/10127 . * * Using `asap` scheduler - as in `of(value, asap)` - doesn't work either. */import {defer} from 'rxjs';/** * Create async observable that emits-once and completes * after a JS engine turn */export function asyncData<T>(data: T) {  return defer(() => Promise.resolve(data));}/** * Create async observable error that errors * after a JS engine turn */export function asyncError<T>(errorObject: any) {  return defer(() => Promise.reject(errorObject));}

さらなる非同期テスト

getQuote()スパイが非同期Observableを返すようになったので、ほとんどのテストも非同期にする必要があります。

次に、現実の世界で期待されるデータフローを示すfakeAsync()テストを示します。

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => {  let component: TwainComponent;  let fixture: ComponentFixture<TwainComponent>;  let getQuoteSpy: jasmine.Spy;  let quoteEl: HTMLElement;  let testQuote: string;  // Helper function to get the error message element value  // An *ngIf keeps it out of the DOM until there is an error  const errorMessage = () => {    const el = fixture.nativeElement.querySelector('.error');    return el ? el.textContent : null;  };  beforeEach(() => {    TestBed.configureTestingModule({      imports: [TwainComponent],      providers: [TwainService],    });    testQuote = 'Test Quote';    // Create a fake TwainService object with a `getQuote()` spy    const twainService = TestBed.inject(TwainService);    // Make the spy return a synchronous Observable with the test data    getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));    fixture = TestBed.createComponent(TwainComponent);    fixture.autoDetectChanges();    component = fixture.componentInstance;    quoteEl = fixture.nativeElement.querySelector('.twain');  });  describe('when test with synchronous observable', () => {    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    // The quote would not be immediately available if the service were truly async.    it('should show quote after component initialized', async () => {      await fixture.whenStable(); // onInit()      // sync spy result shows testQuote immediately after init      expect(quoteEl.textContent).toBe(testQuote);      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    // The error would not be immediately available if the service were truly async.    // Use `fakeAsync` because the component error calls `setTimeout`    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an error observable after a timeout      getQuoteSpy.and.returnValue(        defer(() => {          return new Promise((resolve, reject) => {            setTimeout(() => {              reject('TwainService test failure');            });          });        }),      );      fixture.detectChanges(); // onInit()      // sync spy errors immediately after init      tick(); // flush the setTimeout()      fixture.detectChanges(); // update errorMessage within setTimeout()      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });  describe('when test with asynchronous observable', () => {    beforeEach(() => {      // Simulate delayed observable values with the `asyncData()` helper      getQuoteSpy.and.returnValue(asyncData(testQuote));    });    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    it('should still not show quote after component initialized', () => {      fixture.detectChanges();      // getQuote service is async => still has not returned with quote      // so should show the start value, '...'      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      expect(errorMessage()).withContext('should not show error').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      tick(); // flush the observable to get the quote      fixture.detectChanges(); // update view      expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    }));    it('should show quote after getQuote (async)', async () => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      await fixture.whenStable();      // wait for async getQuote      fixture.detectChanges(); // update view with quote      expect(quoteEl.textContent).toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    });    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an async error observable      getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));      fixture.detectChanges();      tick(); // component shows error after a setTimeout()      fixture.detectChanges(); // update error message      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });});

引用要素には、ngOnInit()の後、プレースホルダー値('...')が表示されます。 まだ最初の引用は届いていません。

Observableから最初の引用をフラッシュするには、tick()を呼び出します。 次に、detectChanges()を呼び出して、Angularに画面を更新するように指示します。

その後、引用要素に期待どおりのテキストが表示されていることをアサートできます。

fakeAsync()を使わない非同期テスト

次に、前のfakeAsync()テストをasyncで書き直したものを示します。

      
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => {  let component: TwainComponent;  let fixture: ComponentFixture<TwainComponent>;  let getQuoteSpy: jasmine.Spy;  let quoteEl: HTMLElement;  let testQuote: string;  // Helper function to get the error message element value  // An *ngIf keeps it out of the DOM until there is an error  const errorMessage = () => {    const el = fixture.nativeElement.querySelector('.error');    return el ? el.textContent : null;  };  beforeEach(() => {    TestBed.configureTestingModule({      imports: [TwainComponent],      providers: [TwainService],    });    testQuote = 'Test Quote';    // Create a fake TwainService object with a `getQuote()` spy    const twainService = TestBed.inject(TwainService);    // Make the spy return a synchronous Observable with the test data    getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));    fixture = TestBed.createComponent(TwainComponent);    fixture.autoDetectChanges();    component = fixture.componentInstance;    quoteEl = fixture.nativeElement.querySelector('.twain');  });  describe('when test with synchronous observable', () => {    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    // The quote would not be immediately available if the service were truly async.    it('should show quote after component initialized', async () => {      await fixture.whenStable(); // onInit()      // sync spy result shows testQuote immediately after init      expect(quoteEl.textContent).toBe(testQuote);      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    // The error would not be immediately available if the service were truly async.    // Use `fakeAsync` because the component error calls `setTimeout`    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an error observable after a timeout      getQuoteSpy.and.returnValue(        defer(() => {          return new Promise((resolve, reject) => {            setTimeout(() => {              reject('TwainService test failure');            });          });        }),      );      fixture.detectChanges(); // onInit()      // sync spy errors immediately after init      tick(); // flush the setTimeout()      fixture.detectChanges(); // update errorMessage within setTimeout()      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });  describe('when test with asynchronous observable', () => {    beforeEach(() => {      // Simulate delayed observable values with the `asyncData()` helper      getQuoteSpy.and.returnValue(asyncData(testQuote));    });    it('should not show quote before OnInit', () => {      expect(quoteEl.textContent).withContext('nothing displayed').toBe('');      expect(errorMessage()).withContext('should not show error element').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false);    });    it('should still not show quote after component initialized', () => {      fixture.detectChanges();      // getQuote service is async => still has not returned with quote      // so should show the start value, '...'      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      expect(errorMessage()).withContext('should not show error').toBeNull();      expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true);    });    it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      tick(); // flush the observable to get the quote      fixture.detectChanges(); // update view      expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    }));    it('should show quote after getQuote (async)', async () => {      fixture.detectChanges(); // ngOnInit()      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');      await fixture.whenStable();      // wait for async getQuote      fixture.detectChanges(); // update view with quote      expect(quoteEl.textContent).toBe(testQuote);      expect(errorMessage()).withContext('should not show error').toBeNull();    });    it('should display error when TwainService fails', fakeAsync(() => {      // tell spy to return an async error observable      getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));      fixture.detectChanges();      tick(); // component shows error after a setTimeout()      fixture.detectChanges(); // update error message      expect(errorMessage())        .withContext('should display error')        .toMatch(/test failure/);      expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');    }));  });});

whenStable

テストは、getQuote()Observableが次の引用を発行するまで待つ必要があります。 tick()を呼び出す代わりに、fixture.whenStable()を呼び出します。

fixture.whenStable()は、JavaScriptエンジンのタスクキューが空になったときに解決されるプロミスを返します。 この例では、タスクキューはObservableが最初の引用を発行したときに空になります。

入力と出力を持つコンポーネント

入力と出力を持つコンポーネントは、通常、ホストコンポーネントのビューテンプレート内に表示されます。 ホストは、プロパティバインディングを使用して入力プロパティを設定し、イベントバインディングを使用して出力プロパティによって発生したイベントをリスンします。

テストの目標は、そのようなバインディングが期待どおりに機能することを確認することです。 テストでは、入力値を設定し、出力イベントをリスンする必要があります。

DashboardHeroComponentは、この役割を果たすコンポーネントの小さな例です。 これは、DashboardComponentによって提供された個々のヒーローを表示します。 そのヒーローをクリックすると、DashboardComponentにユーザーがヒーローを選択したことを伝えます。

DashboardHeroComponentは、次のようにDashboardComponentテンプレートに埋め込まれています。

app/dashboard/dashboard.component.html (excerpt)

      
<h2 highlight>{{ title }}</h2><div class="grid grid-pad">  @for (hero of heroes; track hero) {    <dashboard-hero      class="col-1-4"      [hero]="hero"      (selected)="gotoDetail($event)"    >    </dashboard-hero>  }</div>

DashboardHeroComponent*ngForリピーター内に表示され、各コンポーネントのhero入力プロパティはループする値に設定され、コンポーネントのselectedイベントをリスンします。

コンポーネントの完全な定義を次に示します。

app/dashboard/dashboard-hero.component.ts (component)

      
import {Component, input, output} from '@angular/core';import {UpperCasePipe} from '@angular/common';import {Hero} from '../model/hero';@Component({  standalone: true,  selector: 'dashboard-hero',  template: `    <button type="button" (click)="click()" class="hero">      {{ hero().name | uppercase }}    </button>  `,  styleUrls: ['./dashboard-hero.component.css'],  imports: [UpperCasePipe],})export class DashboardHeroComponent {  hero = input.required<Hero>();  selected = output<Hero>();  click() {    this.selected.emit(this.hero());  }}

この単純なコンポーネントをテストすることは、ほとんど内在的な価値はありませんが、テスト方法を知る価値はあります。 次のいずれかの方法を使用します。

  • DashboardComponentで使用されているようにテストする
  • スタンドアロンコンポーネントとしてテストする
  • DashboardComponentの代替として使用されているようにテストする

当面の目標は、DashboardComponentではなくDashboardHeroComponentをテストすることなので、2番目と3番目のオプションを試してみましょう。

DashboardHeroComponentをスタンドアロンでテストする

スペックファイルの設定の重要な部分を次に示します。

app/dashboard/dashboard-hero.component.spec.ts (setup)

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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;  }}

設定コードは、コンポーネントのheroプロパティにテストヒーロー(expectedHero)を割り当てています。これは、DashboardComponentがリピーターのプロパティバインディングを使用して設定する方法をエミュレートしています。

次のテストは、ヒーロー名がバインディングを使用してテンプレートに伝播されることを検証します。

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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;  }}

テンプレートは、ヒーロー名をAngularのUpperCasePipeを通して渡すため、テストでは要素値を大文字の名前と照合する必要があります。

クリック

ヒーローをクリックすると、ホストコンポーネント(DashboardComponentと推測される)が聞き取ることができるselectedイベントが発生するはずです。

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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;  }}

コンポーネントのselectedプロパティは、EventEmitterを返します。これは、消費者にとってはRxJSの同期Observableのように見えます。 テストは、ホストコンポーネントが暗黙的に行うように、明示的にこれを購読します。

コンポーネントが期待どおりに動作すれば、ヒーローの要素をクリックすると、コンポーネントのselectedプロパティにheroオブジェクトを発行するように指示されます。

テストは、selectedへの購読を通じてそのイベントを検出します。

triggerEventHandler

前のテストのheroDeは、ヒーロー<div>を表すDebugElementです。

これは、ネイティブ要素との対話を抽象化するAngularのプロパティとメソッドを持っています。 このテストは、"click"イベント名でDebugElement.triggerEventHandlerを呼び出します。 "click"イベントバインディングは、DashboardHeroComponent.click()を呼び出すことで応答します。

AngularのDebugElement.triggerEventHandlerは、イベント名を使用して、データバインドされたイベントを発生させることができます。 2番目のパラメーターは、ハンドラーに渡されるイベントオブジェクトです。

テストでは、"click"イベントをトリガーしました。

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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;  }}

この場合、テストでは、ランタイムイベントハンドラーであるコンポーネントのclick()メソッドがイベントオブジェクトを気にしないことを正しく想定しています。

HELPFUL: 他のハンドラーは、それほど寛容ではありません。 たとえば、RouterLinkディレクティブは、クリック時にどのマウスボタンが押されたかを識別するbuttonプロパティを持つオブジェクトを期待しています。 RouterLinkディレクティブは、イベントオブジェクトが不足している場合、エラーをスローします。

要素をクリックする

次のテストの代替案は、ネイティブ要素自身のclick()メソッドを呼び出します。これは、このコンポーネントには完全に適しています。

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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;  }}

click()ヘルパー

ボタン、アンカー、または任意のHTML要素をクリックすることは、一般的なテストタスクです。

次のclick()関数のようなヘルパーにクリックをトリガーするプロセスをカプセル化することで、それを一貫性があり、簡単に行うことができます。

testing/index.ts (click helper)

      
import {DebugElement} from '@angular/core';import {ComponentFixture, tick} from '@angular/core/testing';export * from './async-observable-helpers';export * from './jasmine-matchers';///// Short utilities //////** Wait a tick, then detect changes */export function advance(f: ComponentFixture<any>): void {  tick();  f.detectChanges();}// See https://developer.mozilla.org/docs/Web/API/MouseEvent/button/** 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);  }}

最初の引数は、クリックする要素です。 必要に応じて、2番目の引数としてカスタムイベントオブジェクトを渡すことができます。 デフォルトは、RouterLinkディレクティブを含む多くのハンドラーで受け入れられる、部分的な左ボタンのマウスイベントオブジェクトです。

IMPORTANT: click()ヘルパー関数は、Angularのテストユーティリティの1つではありません。 これは、このガイドのサンプルコードで定義された関数です。 サンプルテストはすべてこれを利用しています。 気に入ったら、自分のヘルパーのコレクションに追加してください。

次に、クリックヘルパーを使用した前のテストを示します。

app/dashboard/dashboard-hero.component.spec.ts (test with click helper)

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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は、ホストコンポーネントに適切にデータバインドされている場合、正しく動作するでしょうか?

app/dashboard/dashboard-hero.component.spec.ts (test host)

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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;  }}

テストホストは、コンポーネントのhero入力プロパティをテストヒーローで設定します。 これは、コンポーネントのselectedイベントをonSelectedハンドラーにバインドし、これはselectedHeroプロパティに発行されたヒーローを記録します。

後で、テストはselectedHeroをチェックして、DashboardHeroComponent.selectedイベントが期待どおりのヒーローを発行したことを確認できます。

test-hostテストの設定は、スタンドアロンテストの設定に似ています。

app/dashboard/dashboard-hero.component.spec.ts (test host setup)

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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;  }}

このテストモジュール設定は、3つの重要な違いを示しています。

  • DashboardHeroComponentTestHostComponentの両方をインポートします
  • DashboardHeroComponentではなくTestHostComponent作成します
  • TestHostComponentは、バインディングでDashboardHeroComponent.heroを設定します

createComponentは、DashboardHeroComponentのインスタンスではなく、TestHostComponentのインスタンスを保持するfixtureを返します。

TestHostComponentを作成すると、後者が前者のテンプレート内に表示されているため、DashboardHeroComponentが作成されます。 ヒーロー要素(heroEl)のクエリは、テストDOM内で見つかりますが、前のテストよりも要素ツリーの深さが大きくなります。

テスト自体は、スタンドアロンバージョンとほとんど同じです。

app/dashboard/dashboard-hero.component.spec.ts (test-host)

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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;  }}

選択されたイベントテストのみが異なります。 これは、選択されたDashboardHeroComponentのヒーローが、実際にイベントバインディングを通じてホストコンポーネントに到達することを確認します。

ルーティングコンポーネント

ルーティングコンポーネントは、Routerに別のコンポーネントにナビゲートするように指示するコンポーネントです。 DashboardComponentは、ユーザーがダッシュボードのヒーローボタンの1つをクリックすることでHeroDetailComponentにナビゲートできるため、ルーティングコンポーネントです。

Angularは、ルーターに依存するコードをより効果的にテストするために、テストヘルパーを提供しています。provideRouter関数はテストモジュール内でも直接使えます。

app/dashboard/dashboard.component.spec.ts

      
import {provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {NO_ERRORS_SCHEMA} from '@angular/core';import {TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {NavigationEnd, provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {firstValueFrom} from 'rxjs';import {filter} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {HeroService} from '../model/hero.service';import {getTestHeroes} from '../model/testing/test-heroes';import {DashboardComponent} from './dashboard.component';import {appConfig} from '../app.config';import {HeroDetailComponent} from '../hero/hero-detail.component';beforeEach(addMatchers);let comp: DashboardComponent;let harness: RouterTestingHarness;////////  Deep  ////////////////describe('DashboardComponent (deep)', () => {  compileAndCreate();  tests(clickForDeep);  function clickForDeep() {    // get first <div class="hero">    const heroEl: HTMLElement = harness.routeNativeElement!.querySelector('.hero')!;    click(heroEl);    return firstValueFrom(      TestBed.inject(Router).events.pipe(filter((e) => e instanceof NavigationEnd)),    );  }});////////  Shallow ////////////////describe('DashboardComponent (shallow)', () => {  beforeEach(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [DashboardComponent, HeroDetailComponent],        providers: [provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}])],        schemas: [NO_ERRORS_SCHEMA],      }),    );  });  compileAndCreate();  tests(clickForShallow);  function clickForShallow() {    // get first <dashboard-hero> DebugElement    const heroDe = harness.routeDebugElement!.query(By.css('dashboard-hero'));    heroDe.triggerEventHandler('selected', comp.heroes[0]);    return Promise.resolve();  }});/** Add TestBed providers, compile, and create DashboardComponent */function compileAndCreate() {  beforeEach(async () => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [DashboardComponent],        providers: [          provideRouter([{path: '**', component: DashboardComponent}]),          provideHttpClient(),          provideHttpClientTesting(),          HeroService,        ],      }),    );    harness = await RouterTestingHarness.create();    comp = await harness.navigateByUrl('/', DashboardComponent);    TestBed.inject(HttpTestingController).expectOne('api/heroes').flush(getTestHeroes());  });}/** * The (almost) same tests for both. * Only change: the way that the first hero is clicked */function tests(heroClick: () => Promise<unknown>) {  describe('after get dashboard heroes', () => {    let router: Router;    // Trigger component so it gets heroes and binds to them    beforeEach(waitForAsync(() => {      router = TestBed.inject(Router);      harness.detectChanges(); // runs ngOnInit -> getHeroes    }));    it('should HAVE heroes', () => {      expect(comp.heroes.length)        .withContext('should have heroes after service promise resolves')        .toBeGreaterThan(0);    });    it('should DISPLAY heroes', () => {      // Find and examine the displayed heroes      // Look for them in the DOM by css class      const heroes = harness.routeNativeElement!.querySelectorAll('dashboard-hero');      expect(heroes.length).withContext('should display 4 heroes').toBe(4);    });    it('should tell navigate when hero clicked', async () => {      await heroClick(); // trigger click on first inner <div class="hero">      // expecting to navigate to id of the component's first hero      const id = comp.heroes[0].id;      expect(TestBed.inject(Router).url)        .withContext('should nav to HeroDetail for first hero')        .toEqual(`/heroes/${id}`);    });  });}

次のテストは、表示されているヒーローをクリックし、期待されるURLにナビゲートしたことを確認します。

app/dashboard/dashboard.component.spec.ts (navigate test)

      
import {provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {NO_ERRORS_SCHEMA} from '@angular/core';import {TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {NavigationEnd, provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {firstValueFrom} from 'rxjs';import {filter} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {HeroService} from '../model/hero.service';import {getTestHeroes} from '../model/testing/test-heroes';import {DashboardComponent} from './dashboard.component';import {appConfig} from '../app.config';import {HeroDetailComponent} from '../hero/hero-detail.component';beforeEach(addMatchers);let comp: DashboardComponent;let harness: RouterTestingHarness;////////  Deep  ////////////////describe('DashboardComponent (deep)', () => {  compileAndCreate();  tests(clickForDeep);  function clickForDeep() {    // get first <div class="hero">    const heroEl: HTMLElement = harness.routeNativeElement!.querySelector('.hero')!;    click(heroEl);    return firstValueFrom(      TestBed.inject(Router).events.pipe(filter((e) => e instanceof NavigationEnd)),    );  }});////////  Shallow ////////////////describe('DashboardComponent (shallow)', () => {  beforeEach(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [DashboardComponent, HeroDetailComponent],        providers: [provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}])],        schemas: [NO_ERRORS_SCHEMA],      }),    );  });  compileAndCreate();  tests(clickForShallow);  function clickForShallow() {    // get first <dashboard-hero> DebugElement    const heroDe = harness.routeDebugElement!.query(By.css('dashboard-hero'));    heroDe.triggerEventHandler('selected', comp.heroes[0]);    return Promise.resolve();  }});/** Add TestBed providers, compile, and create DashboardComponent */function compileAndCreate() {  beforeEach(async () => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [DashboardComponent],        providers: [          provideRouter([{path: '**', component: DashboardComponent}]),          provideHttpClient(),          provideHttpClientTesting(),          HeroService,        ],      }),    );    harness = await RouterTestingHarness.create();    comp = await harness.navigateByUrl('/', DashboardComponent);    TestBed.inject(HttpTestingController).expectOne('api/heroes').flush(getTestHeroes());  });}/** * The (almost) same tests for both. * Only change: the way that the first hero is clicked */function tests(heroClick: () => Promise<unknown>) {  describe('after get dashboard heroes', () => {    let router: Router;    // Trigger component so it gets heroes and binds to them    beforeEach(waitForAsync(() => {      router = TestBed.inject(Router);      harness.detectChanges(); // runs ngOnInit -> getHeroes    }));    it('should HAVE heroes', () => {      expect(comp.heroes.length)        .withContext('should have heroes after service promise resolves')        .toBeGreaterThan(0);    });    it('should DISPLAY heroes', () => {      // Find and examine the displayed heroes      // Look for them in the DOM by css class      const heroes = harness.routeNativeElement!.querySelectorAll('dashboard-hero');      expect(heroes.length).withContext('should display 4 heroes').toBe(4);    });    it('should tell navigate when hero clicked', async () => {      await heroClick(); // trigger click on first inner <div class="hero">      // expecting to navigate to id of the component's first hero      const id = comp.heroes[0].id;      expect(TestBed.inject(Router).url)        .withContext('should nav to HeroDetail for first hero')        .toEqual(`/heroes/${id}`);    });  });}

ルーティングされたコンポーネント

ルーティングされたコンポーネントは、Routerナビゲーションの宛先です。 特に、コンポーネントへのルートにパラメーターが含まれている場合、テストが難しくなる場合があります。 HeroDetailComponentは、このようなルートの宛先であるルーティングされたコンポーネントです。

ユーザーがDashboardのヒーローをクリックすると、DashboardComponentRouterheroes/:idにナビゲートするように指示します。 :idは、編集するヒーローのidであるルートパラメーターです。

Routerは、そのURLをHeroDetailComponentへのルートと照合します。 これは、ルーティング情報を持つActivatedRouteオブジェクトを作成し、HeroDetailComponentの新しいインスタンスに注入します。

HeroDetailComponentのコンストラクターを次に示します。

app/hero/hero-detail.component.ts (constructor)

      
import {Component, OnInit} from '@angular/core';import {ActivatedRoute, Router, RouterLink} from '@angular/router';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailService} from './hero-detail.service';@Component({  standalone: true,  selector: 'app-hero-detail',  templateUrl: './hero-detail.component.html',  styleUrls: ['./hero-detail.component.css'],  providers: [HeroDetailService],  imports: [sharedImports, RouterLink],})export class HeroDetailComponent implements OnInit {  constructor(    private heroDetailService: HeroDetailService,    private route: ActivatedRoute,    private router: Router,  ) {}  hero!: Hero;  ngOnInit(): void {    // get hero when `id` param changes    this.route.paramMap.subscribe((pmap) => this.getHero(pmap.get('id')));  }  private getHero(id: string | null): void {    // when no id or id===0, create new blank hero    if (!id) {      this.hero = {id: 0, name: ''} as Hero;      return;    }    this.heroDetailService.getHero(id).subscribe((hero) => {      if (hero) {        this.hero = hero;      } else {        this.gotoList(); // id not found; navigate to list      }    });  }  save(): void {    this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList());  }  cancel() {    this.gotoList();  }  gotoList() {    this.router.navigate(['../'], {relativeTo: this.route});  }}

HeroDetailコンポーネントは、idパラメーターが必要であり、これによりHeroDetailServiceを使用して対応するヒーローを取得できます。 コンポーネントは、ObservableであるActivatedRoute.paramMapプロパティからidを取得する必要があります。

コンポーネントは、ActivatedRoute.paramMapidプロパティを参照することはできません。 コンポーネントは、ActivatedRoute.paramMapObservableを購読し、ライフタイム中にidが変更される場合に備えておく必要があります。

app/hero/hero-detail.component.ts (ngOnInit)

      
import {Component, OnInit} from '@angular/core';import {ActivatedRoute, Router, RouterLink} from '@angular/router';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailService} from './hero-detail.service';@Component({  standalone: true,  selector: 'app-hero-detail',  templateUrl: './hero-detail.component.html',  styleUrls: ['./hero-detail.component.css'],  providers: [HeroDetailService],  imports: [sharedImports, RouterLink],})export class HeroDetailComponent implements OnInit {  constructor(    private heroDetailService: HeroDetailService,    private route: ActivatedRoute,    private router: Router,  ) {}  hero!: Hero;  ngOnInit(): void {    // get hero when `id` param changes    this.route.paramMap.subscribe((pmap) => this.getHero(pmap.get('id')));  }  private getHero(id: string | null): void {    // when no id or id===0, create new blank hero    if (!id) {      this.hero = {id: 0, name: ''} as Hero;      return;    }    this.heroDetailService.getHero(id).subscribe((hero) => {      if (hero) {        this.hero = hero;      } else {        this.gotoList(); // id not found; navigate to list      }    });  }  save(): void {    this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList());  }  cancel() {    this.gotoList();  }  gotoList() {    this.router.navigate(['../'], {relativeTo: this.route});  }}

テストでは、異なるルートにナビゲートすることで、HeroDetailComponentが異なるidパラメーター値にどのように応答するかを調べることができます。

RouterTestingHarnessによるテスト

次に、観察されたidが既存のヒーローを参照している場合に、コンポーネントの動作を示すテストを示します。

app/hero/hero-detail.component.spec.ts (existing id)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

HELPFUL: 後のセクションでは、createComponent()メソッドとpageオブジェクトについて説明します。 今のところ、直感的に理解してください。

idが見つからない場合、コンポーネントはHeroListComponentにリダイレクトする必要があります。

テストスイートの設定では、同じルーターハーネスが提供されました上記を参照

このテストは、コンポーネントがHeroListComponentへのナビゲーションを試みると予想しています。

app/hero/hero-detail.component.spec.ts (bad id)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

ネストされたコンポーネントテスト

コンポーネントテンプレートには、多くの場合、ネストされたコンポーネントが含まれています。そのテンプレートには、さらに多くのコンポーネントが含まれている場合があります。

コンポーネントツリーは非常に深くなる可能性があり、ネストされたコンポーネントはツリーの先頭に配置されたコンポーネントをテストする際に役割を果たさないことがあります。

たとえば、AppComponentは、アンカーとRouterLinkディレクティブを持つナビゲーションバーを表示します。

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>

ナビゲーションではなくリンクを検証するために、Routerルーティングされたコンポーネントを挿入する場所を示す<router-outlet>も必要ありません。

BannerComponentWelcomeComponent<app-banner><app-welcome>で示されています)も関係ありません。

しかし、DOMにAppComponentを作成するテストは、これらの3つのコンポーネントのインスタンスも作成し、それを許可した場合、TestBedを設定してそれらを作成する必要があります。

それらを宣言することを怠ると、AngularコンパイラーはAppComponentテンプレートの<app-banner><app-welcome>、および<router-outlet>タグを認識せず、エラーをスローします。

実際のコンポーネントを宣言すると、それらのネストされたコンポーネントも宣言し、ツリー内の任意のコンポーネントに注入されたすべてのサービスを提供する必要があります。

このセクションでは、セットアップを最小限にするための2つのテクニックについて説明します。 これらを単独で、または組み合わせて使用して、主要なコンポーネントのテストに集中してください。

不要なコンポーネントのスタブ化

最初のテクニックでは、テストでほとんど役割を果たさないコンポーネントとディレクティブのスタブバージョンを作成して宣言します。

app/app.component.spec.ts (stub declaration)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';@Component({standalone: true, selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({standalone: true, selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({standalone: true, selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,          RouterOutletStubComponent,          WelcomeStubComponent,        ],        providers: [provideRouter([]), UserService],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,        ],        providers: [provideRouter([]), UserService],        schemas: [NO_ERRORS_SCHEMA],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});function tests() {  let routerLinks: RouterLink[];  let linkDes: DebugElement[];  beforeEach(() => {    fixture.detectChanges(); // trigger initial data binding    // find DebugElements with an attached RouterLinkStubDirective    linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));    // get attached link directive instances    // using each DebugElement's injector    routerLinks = linkDes.map((de) => de.injector.get(RouterLink));  });  it('can instantiate the component', () => {    expect(comp).not.toBeNull();  });  it('can get RouterLinks from template', () => {    expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);    expect(routerLinks[0].href).toBe('/dashboard');    expect(routerLinks[1].href).toBe('/heroes');    expect(routerLinks[2].href).toBe('/about');  });  it('can click Heroes link in template', fakeAsync(() => {    const heroesLinkDe = linkDes[1]; // heroes link DebugElement    TestBed.inject(Router).resetConfig([{path: '**', children: []}]);    heroesLinkDe.triggerEventHandler('click', {button: 0});    tick();    fixture.detectChanges();    expect(TestBed.inject(Router).url).toBe('/heroes');  }));}

スタブセレクターは、対応する実際のコンポーネントのセレクターと一致します。 しかし、それらのテンプレートとクラスは空です。

次に、TestBed設定内で、実際にする必要があるコンポーネント、ディレクティブ、パイプの横に宣言します。

app/app.component.spec.ts (TestBed stubs)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';@Component({standalone: true, selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({standalone: true, selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({standalone: true, selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,          RouterOutletStubComponent,          WelcomeStubComponent,        ],        providers: [provideRouter([]), UserService],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,        ],        providers: [provideRouter([]), UserService],        schemas: [NO_ERRORS_SCHEMA],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});function tests() {  let routerLinks: RouterLink[];  let linkDes: DebugElement[];  beforeEach(() => {    fixture.detectChanges(); // trigger initial data binding    // find DebugElements with an attached RouterLinkStubDirective    linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));    // get attached link directive instances    // using each DebugElement's injector    routerLinks = linkDes.map((de) => de.injector.get(RouterLink));  });  it('can instantiate the component', () => {    expect(comp).not.toBeNull();  });  it('can get RouterLinks from template', () => {    expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);    expect(routerLinks[0].href).toBe('/dashboard');    expect(routerLinks[1].href).toBe('/heroes');    expect(routerLinks[2].href).toBe('/about');  });  it('can click Heroes link in template', fakeAsync(() => {    const heroesLinkDe = linkDes[1]; // heroes link DebugElement    TestBed.inject(Router).resetConfig([{path: '**', children: []}]);    heroesLinkDe.triggerEventHandler('click', {button: 0});    tick();    fixture.detectChanges();    expect(TestBed.inject(Router).url).toBe('/heroes');  }));}

AppComponentはテスト対象なので、当然、実際のバージョンを宣言します。

残りはスタブです。

NO_ERRORS_SCHEMA

2番目の方法では、TestBed.schemasメタデータにNO_ERRORS_SCHEMAを追加します。

app/app.component.spec.ts (NO_ERRORS_SCHEMA)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';@Component({standalone: true, selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({standalone: true, selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({standalone: true, selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,          RouterOutletStubComponent,          WelcomeStubComponent,        ],        providers: [provideRouter([]), UserService],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,        ],        providers: [provideRouter([]), UserService],        schemas: [NO_ERRORS_SCHEMA],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});function tests() {  let routerLinks: RouterLink[];  let linkDes: DebugElement[];  beforeEach(() => {    fixture.detectChanges(); // trigger initial data binding    // find DebugElements with an attached RouterLinkStubDirective    linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));    // get attached link directive instances    // using each DebugElement's injector    routerLinks = linkDes.map((de) => de.injector.get(RouterLink));  });  it('can instantiate the component', () => {    expect(comp).not.toBeNull();  });  it('can get RouterLinks from template', () => {    expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);    expect(routerLinks[0].href).toBe('/dashboard');    expect(routerLinks[1].href).toBe('/heroes');    expect(routerLinks[2].href).toBe('/about');  });  it('can click Heroes link in template', fakeAsync(() => {    const heroesLinkDe = linkDes[1]; // heroes link DebugElement    TestBed.inject(Router).resetConfig([{path: '**', children: []}]);    heroesLinkDe.triggerEventHandler('click', {button: 0});    tick();    fixture.detectChanges();    expect(TestBed.inject(Router).url).toBe('/heroes');  }));}

NO_ERRORS_SCHEMAは、Angularコンパイラーに、認識されていない要素と属性を無視するように指示します。

コンパイラーは、TestBed設定で対応するAppComponentRouterLinkを宣言したため、<app-root>要素とrouterLink属性を認識します。

しかし、コンパイラーは<app-banner><app-welcome>、または<router-outlet>に遭遇してもエラーをスローしません。 単にそれらを空のタグとしてレンダリングし、ブラウザはそれらを無視します。

スタブコンポーネントは不要になりました。

2つのテクニックを組み合わせて使用する

これらは、シャローコンポーネントテストのためのテクニックであり、コンポーネントの視覚的な表面を、テストにとって重要なコンポーネントのテンプレート内の要素だけに制限するため、そう呼ばれています。

NO_ERRORS_SCHEMAアプローチは2つのうちより簡単ですが、使い過ぎないでください。

NO_ERRORS_SCHEMAは、コンパイラーが意図的に省略した、または誤ってスペルミスをした、見逃したコンポーネントと属性について警告するのを防ぎます。 コンパイラーが瞬時に検出できたはずの幽霊バグを追いかけて何時間も無駄にする可能性があります。

スタブコンポーネントアプローチには、もう1つの利点があります。 この例ではスタブは空でしたが、テストでそれらと何らかの形で対話する必要がある場合は、縮小されたテンプレートとクラスを与えることができます。

実際には、次の例のように、2つのテクニックを同じセットアップに組み合わせます。

app/app.component.spec.ts (mixed setup)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';@Component({standalone: true, selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({standalone: true, selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({standalone: true, selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,          RouterOutletStubComponent,          WelcomeStubComponent,        ],        providers: [provideRouter([]), UserService],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,        ],        providers: [provideRouter([]), UserService],        schemas: [NO_ERRORS_SCHEMA],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});function tests() {  let routerLinks: RouterLink[];  let linkDes: DebugElement[];  beforeEach(() => {    fixture.detectChanges(); // trigger initial data binding    // find DebugElements with an attached RouterLinkStubDirective    linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));    // get attached link directive instances    // using each DebugElement's injector    routerLinks = linkDes.map((de) => de.injector.get(RouterLink));  });  it('can instantiate the component', () => {    expect(comp).not.toBeNull();  });  it('can get RouterLinks from template', () => {    expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);    expect(routerLinks[0].href).toBe('/dashboard');    expect(routerLinks[1].href).toBe('/heroes');    expect(routerLinks[2].href).toBe('/about');  });  it('can click Heroes link in template', fakeAsync(() => {    const heroesLinkDe = linkDes[1]; // heroes link DebugElement    TestBed.inject(Router).resetConfig([{path: '**', children: []}]);    heroesLinkDe.triggerEventHandler('click', {button: 0});    tick();    fixture.detectChanges();    expect(TestBed.inject(Router).url).toBe('/heroes');  }));}

Angularコンパイラーは、<app-banner>要素に対してBannerStubComponentを作成し、routerLink属性を持つアンカーにRouterLinkを適用しますが、<app-welcome><router-outlet>タグは無視します。

By.directiveと注入されたディレクティブ

さらに少しセットアップすると、初期データバインディングがトリガーされ、ナビゲーションリンクへの参照が取得されます。

app/app.component.spec.ts (test setup)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';@Component({standalone: true, selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({standalone: true, selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({standalone: true, selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,          RouterOutletStubComponent,          WelcomeStubComponent,        ],        providers: [provideRouter([]), UserService],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,        ],        providers: [provideRouter([]), UserService],        schemas: [NO_ERRORS_SCHEMA],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});function tests() {  let routerLinks: RouterLink[];  let linkDes: DebugElement[];  beforeEach(() => {    fixture.detectChanges(); // trigger initial data binding    // find DebugElements with an attached RouterLinkStubDirective    linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));    // get attached link directive instances    // using each DebugElement's injector    routerLinks = linkDes.map((de) => de.injector.get(RouterLink));  });  it('can instantiate the component', () => {    expect(comp).not.toBeNull();  });  it('can get RouterLinks from template', () => {    expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);    expect(routerLinks[0].href).toBe('/dashboard');    expect(routerLinks[1].href).toBe('/heroes');    expect(routerLinks[2].href).toBe('/about');  });  it('can click Heroes link in template', fakeAsync(() => {    const heroesLinkDe = linkDes[1]; // heroes link DebugElement    TestBed.inject(Router).resetConfig([{path: '**', children: []}]);    heroesLinkDe.triggerEventHandler('click', {button: 0});    tick();    fixture.detectChanges();    expect(TestBed.inject(Router).url).toBe('/heroes');  }));}

3つの重要なポイントを次に示します。

  • By.directiveを使用して、アタッチされたディレクティブを持つアンカー要素を見つけます
  • クエリは、一致する要素をラップするDebugElementラッパーを返します
  • DebugElementは、その要素にアタッチされたディレクティブの特定のインスタンスを含む依存関係インジェクターを公開します

AppComponentが検証するリンクは次のとおりです。

app/app.component.html (navigation links)

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

次に、これらのリンクが期待どおりにrouterLinkディレクティブに配線されていることを確認するテストを示します。

app/app.component.spec.ts (selected tests)

      
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';@Component({standalone: true, selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({standalone: true, selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({standalone: true, selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,          RouterOutletStubComponent,          WelcomeStubComponent,        ],        providers: [provideRouter([]), UserService],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => {  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [          AppComponent,          BannerStubComponent,          RouterLink,        ],        providers: [provideRouter([]), UserService],        schemas: [NO_ERRORS_SCHEMA],      }),    )      .compileComponents()      .then(() => {        fixture = TestBed.createComponent(AppComponent);        comp = fixture.componentInstance;      });  }));  tests();});function tests() {  let routerLinks: RouterLink[];  let linkDes: DebugElement[];  beforeEach(() => {    fixture.detectChanges(); // trigger initial data binding    // find DebugElements with an attached RouterLinkStubDirective    linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));    // get attached link directive instances    // using each DebugElement's injector    routerLinks = linkDes.map((de) => de.injector.get(RouterLink));  });  it('can instantiate the component', () => {    expect(comp).not.toBeNull();  });  it('can get RouterLinks from template', () => {    expect(routerLinks.length).withContext('should have 3 routerLinks').toBe(3);    expect(routerLinks[0].href).toBe('/dashboard');    expect(routerLinks[1].href).toBe('/heroes');    expect(routerLinks[2].href).toBe('/about');  });  it('can click Heroes link in template', fakeAsync(() => {    const heroesLinkDe = linkDes[1]; // heroes link DebugElement    TestBed.inject(Router).resetConfig([{path: '**', children: []}]);    heroesLinkDe.triggerEventHandler('click', {button: 0});    tick();    fixture.detectChanges();    expect(TestBed.inject(Router).url).toBe('/heroes');  }));}

pageオブジェクトの使用

HeroDetailComponentは、タイトル、2つのヒーローフィールド、2つのボタンを持つ単純なビューです。

しかし、この単純な形式でも、テンプレートの複雑さはたくさんあります。

app/hero/hero-detail.component.html

      
@if (hero) {  <div>    <h2>      <span>{{ hero.name | titlecase }}</span> Details    </h2>    <div><span>id: </span>{{ hero.id }}</div>    <div>      <label for="name">name: </label>      <input id="name" [(ngModel)]="hero.name" placeholder="name" />    </div>    <button type="button" (click)="save()">Save</button>    <button type="button" (click)="cancel()">Cancel</button>  </div>}

コンポーネントをテストするものは、…

  • ヒーローが到着するまで待つ必要がある
  • タイトルテキストへの参照
  • 検査および設定するための名前入力ボックスへの参照
  • クリックできる2つのボタンへの参照

このような小さなフォームでも、むち打ちの条件付きセットアップとCSS要素の選択の混乱を招く可能性があります。

コンポーネントのプロパティへのアクセスを処理し、それらを設定するロジックをカプセル化するPageクラスを使用して、複雑さを抑制します。

次に、hero-detail.component.spec.tsPageクラスを示します。

app/hero/hero-detail.component.spec.ts (Page)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

これで、コンポーネントの操作と検査のための重要なフックが、整理され、Pageのインスタンスからアクセスできるようになりました。

createComponentメソッドは、pageオブジェクトを作成し、heroが到着すると空白を埋めます。

app/hero/hero-detail.component.spec.ts (createComponent)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

次に、ポイントを強化するためのHeroDetailComponentのテストをいくつか示します。

app/hero/hero-detail.component.spec.ts (selected tests)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

compileComponents()の呼び出し

HELPFUL: CLIng testコマンドでのみテストを実行している場合は、このセクションを無視してください。なぜなら、CLIはテストを実行する前にアプリケーションをコンパイルするからです。

CLI以外の環境でテストを実行する場合、テストは次のようなメッセージで失敗する可能性があります。

      
Error: This test module uses the component BannerComponentwhich is using a "templateUrl" or "styleUrls", but they were never compiled.Please call "TestBed.compileComponents" before your test.

問題の根本は、テストに関与するコンポーネントの少なくとも1つが、BannerComponentの次のバージョンで行われているように、外部テンプレートまたはCSSファイルを指定していることです。

app/banner/banner-external.component.ts (external template & css)

      
import {Component} from '@angular/core';@Component({  standalone: true,  selector: 'app-banner',  templateUrl: './banner-external.component.html',  styleUrls: ['./banner-external.component.css'],})export class BannerComponent {  title = 'Test Tour of Heroes';}

テストは、TestBedがコンポーネントの作成を試みたときに失敗します。

app/banner/banner-external.component.spec.ts (setup that fails)

      
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner-external.component';describe('BannerComponent (external files)', () => {  let component: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  describe('setup that may fail', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }); // missing call to compileComponents()      fixture = TestBed.createComponent(BannerComponent);    });    it('should create', () => {      expect(fixture.componentInstance).toBeDefined();    });  });  describe('Two beforeEach', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }).compileComponents(); // compile template and css    });    // synchronous beforeEach    beforeEach(() => {      fixture = TestBed.createComponent(BannerComponent);      component = fixture.componentInstance; // BannerComponent test instance      h1 = fixture.nativeElement.querySelector('h1');    });    tests();  });  describe('One beforeEach', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }).compileComponents();      fixture = TestBed.createComponent(BannerComponent);      component = fixture.componentInstance;      h1 = fixture.nativeElement.querySelector('h1');    });    tests();  });  function tests() {    it('no title in the DOM until manually call `detectChanges`', () => {      expect(h1.textContent).toEqual('');    });    it('should display original title', () => {      fixture.detectChanges();      expect(h1.textContent).toContain(component.title);    });    it('should display a different test title', () => {      component.title = 'Test Title';      fixture.detectChanges();      expect(h1.textContent).toContain('Test Title');    });  }});

アプリケーションはコンパイルされていないことを思い出してください。 そのため、createComponent()を呼び出すと、TestBedは暗黙的にコンパイルします。

これは、ソースコードがメモリ内にある場合は問題ありません。 しかし、BannerComponentは外部ファイルが必要であり、コンパイラーはファイルシステムからそれらを読み取る必要があります。これは本質的に非同期操作です。

TestBedが続行することを許可すると、テストが実行され、コンパイラーが完了する前に、不可解な理由で失敗します。

予防的なエラーメッセージは、compileComponents()で明示的にコンパイルするように指示しています。

compileComponents()は非同期です

compileComponents()は、非同期テスト関数内で呼び出す必要があります。

CRITICAL: テスト関数を非同期にするのを怠ると(たとえば、waitForAsync()の使用を忘れると)、次のようなエラーメッセージが表示されます。

      
Error: ViewDestroyedError: Attempt to use a destroyed view

一般的なアプローチは、セットアップロジックを2つの別のbeforeEach()関数に分割することです。

関数 詳細
非同期beforeEach() コンポーネントをコンパイルする
同期beforeEach() 残りのセットアップを実行する

非同期beforeEach

最初の非同期beforeEachは、次のように記述します。

app/banner/banner-external.component.spec.ts (async beforeEach)

      
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner-external.component';describe('BannerComponent (external files)', () => {  let component: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  describe('setup that may fail', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }); // missing call to compileComponents()      fixture = TestBed.createComponent(BannerComponent);    });    it('should create', () => {      expect(fixture.componentInstance).toBeDefined();    });  });  describe('Two beforeEach', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }).compileComponents(); // compile template and css    });    // synchronous beforeEach    beforeEach(() => {      fixture = TestBed.createComponent(BannerComponent);      component = fixture.componentInstance; // BannerComponent test instance      h1 = fixture.nativeElement.querySelector('h1');    });    tests();  });  describe('One beforeEach', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }).compileComponents();      fixture = TestBed.createComponent(BannerComponent);      component = fixture.componentInstance;      h1 = fixture.nativeElement.querySelector('h1');    });    tests();  });  function tests() {    it('no title in the DOM until manually call `detectChanges`', () => {      expect(h1.textContent).toEqual('');    });    it('should display original title', () => {      fixture.detectChanges();      expect(h1.textContent).toContain(component.title);    });    it('should display a different test title', () => {      component.title = 'Test Title';      fixture.detectChanges();      expect(h1.textContent).toContain('Test Title');    });  }});

TestBed.configureTestingModule()メソッドは、TestBedクラスを返し、compileComponents()などの他のTestBedの静的メソッドへの呼び出しをチェーンすることができます。

この例では、BannerComponentはコンパイルする必要がある唯一のコンポーネントです。 他の例では、複数のコンポーネントでテストモジュールを設定し、さらに多くのコンポーネントを保持するアプリケーションモジュールをインポートする場合があります。 それらのいずれかが外部ファイルが必要になる可能性があります。

TestBed.compileComponentsメソッドは、テストモジュールで設定されたすべてのコンポーネントを非同期的にコンパイルします。

IMPORTANT: compileComponents()を呼び出した後、TestBedを再設定しないでください。

compileComponents()を呼び出すと、現在のTestBedインスタンスがさらに設定されなくなります。 configureTestingModule()override...メソッドなど、TestBedの構成メソッドをさらに呼び出すことはできません。 TestBedは、試行するとエラーをスローします。

compileComponents()を、TestBed.createComponent()を呼び出す前の最後のステップにしてください。

同期beforeEach

2番目の、同期beforeEach()には、残りのセットアップ手順が含まれます。これには、コンポーネントの作成と、検査する要素のクエリが含まれます。

app/banner/banner-external.component.spec.ts (synchronous beforeEach)

      
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner-external.component';describe('BannerComponent (external files)', () => {  let component: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  describe('setup that may fail', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }); // missing call to compileComponents()      fixture = TestBed.createComponent(BannerComponent);    });    it('should create', () => {      expect(fixture.componentInstance).toBeDefined();    });  });  describe('Two beforeEach', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }).compileComponents(); // compile template and css    });    // synchronous beforeEach    beforeEach(() => {      fixture = TestBed.createComponent(BannerComponent);      component = fixture.componentInstance; // BannerComponent test instance      h1 = fixture.nativeElement.querySelector('h1');    });    tests();  });  describe('One beforeEach', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }).compileComponents();      fixture = TestBed.createComponent(BannerComponent);      component = fixture.componentInstance;      h1 = fixture.nativeElement.querySelector('h1');    });    tests();  });  function tests() {    it('no title in the DOM until manually call `detectChanges`', () => {      expect(h1.textContent).toEqual('');    });    it('should display original title', () => {      fixture.detectChanges();      expect(h1.textContent).toContain(component.title);    });    it('should display a different test title', () => {      component.title = 'Test Title';      fixture.detectChanges();      expect(h1.textContent).toContain('Test Title');    });  }});

テストランナーは、最初の非同期beforeEachが終了するまで待ってから、2番目を呼び出します。

統合された設定

2つのbeforeEach()関数を、1つの非同期beforeEach()に統合できます。

compileComponents()メソッドはプロミスを返すため、同期セットアップタスクをコンパイル後に実行することができます。そのため、同期コードをawaitキーワードの後に移動します。この時点で、プロミスは解決されています。

app/banner/banner-external.component.spec.ts (one beforeEach)

      
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner-external.component';describe('BannerComponent (external files)', () => {  let component: BannerComponent;  let fixture: ComponentFixture<BannerComponent>;  let h1: HTMLElement;  describe('setup that may fail', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }); // missing call to compileComponents()      fixture = TestBed.createComponent(BannerComponent);    });    it('should create', () => {      expect(fixture.componentInstance).toBeDefined();    });  });  describe('Two beforeEach', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }).compileComponents(); // compile template and css    });    // synchronous beforeEach    beforeEach(() => {      fixture = TestBed.createComponent(BannerComponent);      component = fixture.componentInstance; // BannerComponent test instance      h1 = fixture.nativeElement.querySelector('h1');    });    tests();  });  describe('One beforeEach', () => {    beforeEach(async () => {      await TestBed.configureTestingModule({        imports: [BannerComponent],      }).compileComponents();      fixture = TestBed.createComponent(BannerComponent);      component = fixture.componentInstance;      h1 = fixture.nativeElement.querySelector('h1');    });    tests();  });  function tests() {    it('no title in the DOM until manually call `detectChanges`', () => {      expect(h1.textContent).toEqual('');    });    it('should display original title', () => {      fixture.detectChanges();      expect(h1.textContent).toContain(component.title);    });    it('should display a different test title', () => {      component.title = 'Test Title';      fixture.detectChanges();      expect(h1.textContent).toContain('Test Title');    });  }});

compileComponents()は安全です

compileComponents()を呼び出しても、必要ない場合でも害はありません。

CLIによって生成されたコンポーネントテストファイルは、ng testを実行しているときは不要ですが、compileComponents()を呼び出します。

このガイドのテストでは、必要に応じてのみcompileComponentsを呼び出します。

モジュールインポートによるセットアップ

以前のコンポーネントテストでは、次のようにテストモジュールをいくつかのdeclarationsで設定していました。

app/dashboard/dashboard-hero.component.spec.ts (configure TestBed)

      
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => {  let comp: DashboardHeroComponent;  let expectedHero: Hero;  let fixture: ComponentFixture<DashboardHeroComponent>;  let heroDe: DebugElement;  let heroEl: HTMLElement;  beforeEach(() => {    TestBed.configureTestingModule({      providers: appProviders,    });  });  beforeEach(async () => {    fixture = TestBed.createComponent(DashboardHeroComponent);    fixture.autoDetectChanges();    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    fixture.componentRef.setInput('hero', expectedHero);    // wait for initial data binding    await fixture.whenStable();  });  it('should display hero name in uppercase', () => {    const expectedPipedName = expectedHero.name.toUpperCase();    expect(heroEl.textContent).toContain(expectedPipedName);  });  it('should raise selected event when clicked (triggerEventHandler)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    heroDe.triggerEventHandler('click');    expect(selectedHero).toBe(expectedHero);  });  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);  });  it('should raise selected event when clicked (click helper with DebugElement)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroDe); // click helper with DebugElement    expect(selectedHero).toBe(expectedHero);  });  it('should raise selected event when clicked (click helper with native element)', () => {    let selectedHero: Hero | undefined;    comp.selected.subscribe((hero: Hero) => (selectedHero = hero));    click(heroEl); // click helper with native element    expect(selectedHero).toBe(expectedHero);  });});//////////////////describe('DashboardHeroComponent when inside a test host', () => {  let testHost: TestHostComponent;  let fixture: ComponentFixture<TestHostComponent>;  let heroEl: HTMLElement;  beforeEach(waitForAsync(() => {    TestBed.configureTestingModule({      providers: appProviders,      imports: [DashboardHeroComponent, TestHostComponent],    })      .compileComponents();  }));  beforeEach(() => {    // create TestHostComponent instead of DashboardHeroComponent    fixture = TestBed.createComponent(TestHostComponent);    testHost = fixture.componentInstance;    heroEl = fixture.nativeElement.querySelector('.hero');    fixture.detectChanges(); // trigger initial data binding  });  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);  });});////// Test Host Component //////import {Component} from '@angular/core';@Component({  standalone: true,  imports: [DashboardHeroComponent],  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はシンプルです。 助けは必要ありません。 しかし、より複雑なコンポーネントは、多くの場合、他のコンポーネント、ディレクティブ、パイプ、およびプロバイダーに依存しており、これらをテストモジュールにも追加する必要があります。

幸いなことに、TestBed.configureTestingModuleのパラメーターは、@NgModuleデコレーターに渡されるメタデータと並行しているため、providersimportsも指定できます。

HeroDetailComponentは、小さいサイズでシンプルな構造にもかかわらず、多くの助けを必要としています。 デフォルトのテストモジュールCommonModuleからサポートを受けることに加えて、次のようなものが必要です。

  • FormsModuleNgModelなど、双方向データバインディングを有効にする
  • sharedフォルダのTitleCasePipe
  • ルーターサービス
  • ヒーローのデータアクセスサービス

1つのアプローチは、次の例のように、個々のピースからテストモジュールを設定することです。

app/hero/hero-detail.component.spec.ts (FormsModule setup)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

HELPFUL: beforeEach()が非同期であり、TestBed.compileComponentsを呼び出していることに注意してください。なぜなら、HeroDetailComponentは外部テンプレートとcssファイルを持っているからです。

compileComponentsの呼び出しで説明されているように、これらのテストは、Angularがブラウザでそれらをコンパイルする必要がある、CLI以外の環境で実行することができます。

共有モジュールのインポート

多くのアプリケーションコンポーネントがFormsModuleTitleCasePipeを必要とするため、開発者はSharedModuleを作成して、これらと他の頻繁に要求される部分を組み合わせました。

テスト設定では、次の例のように、SharedModuleも使用できます。

app/hero/hero-detail.component.spec.ts (SharedModule setup)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

これは少しタイトで小さく、インポートステートメントが少なくなります。この例では示されていません。

機能モジュールのインポート

HeroDetailComponentは、SharedModuleなど、相互依存する部分をさらにまとめたHeroModule 機能モジュールの一部です。 次のようなHeroModuleをインポートするテスト設定を試してみましょう。

app/hero/hero-detail.component.spec.ts (HeroModule setup)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

providersテストダブルのみが残ります。 HeroDetailComponentの宣言でさえなくなっています。

実際、宣言しようとすると、Angularはエラーをスローします。なぜなら、HeroDetailComponentHeroModuleTestBedによって作成されたDynamicTestModuleの両方で宣言されているからです。

HELPFUL: コンポーネントの機能モジュールをインポートすると、モジュール内に多くの相互依存関係があり、モジュールが小さい場合(機能モジュールは通常小さい)にテストを設定する最良の方法になる場合があります。

コンポーネントプロバイダーのオーバーライド

HeroDetailComponentは独自のHeroDetailServiceを提供します。

app/hero/hero-detail.component.ts (prototype)

      
import {Component, OnInit} from '@angular/core';import {ActivatedRoute, Router, RouterLink} from '@angular/router';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailService} from './hero-detail.service';@Component({  standalone: true,  selector: 'app-hero-detail',  templateUrl: './hero-detail.component.html',  styleUrls: ['./hero-detail.component.css'],  providers: [HeroDetailService],  imports: [sharedImports, RouterLink],})export class HeroDetailComponent implements OnInit {  constructor(    private heroDetailService: HeroDetailService,    private route: ActivatedRoute,    private router: Router,  ) {}  hero!: Hero;  ngOnInit(): void {    // get hero when `id` param changes    this.route.paramMap.subscribe((pmap) => this.getHero(pmap.get('id')));  }  private getHero(id: string | null): void {    // when no id or id===0, create new blank hero    if (!id) {      this.hero = {id: 0, name: ''} as Hero;      return;    }    this.heroDetailService.getHero(id).subscribe((hero) => {      if (hero) {        this.hero = hero;      } else {        this.gotoList(); // id not found; navigate to list      }    });  }  save(): void {    this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList());  }  cancel() {    this.gotoList();  }  gotoList() {    this.router.navigate(['../'], {relativeTo: this.route});  }}

TestBed.configureTestingModuleprovidersでコンポーネントのHeroDetailServiceをスタブすることはできません。 それらはテストモジュールのプロバイダーであり、コンポーネントのプロバイダーではありません。 それらはフィクスチャレベルで依存関係インジェクターを準備します。

Angularは、コンポーネントを独自のインジェクターで作成します。これは、フィクスチャインジェクターのです。 これは、コンポーネントのプロバイダー(この場合はHeroDetailService)を子インジェクターに登録します。

テストは、フィクスチャインジェクターから子インジェクターのサービスを取得できません。 TestBed.configureTestingModuleもそれらを設定することはできません。

Angularは、ずっと前から実際のHeroDetailServiceの新しいインスタンスを作成していました!

HELPFUL: これらのテストは、HeroDetailServiceがリモートサーバーに独自のXHR呼び出しを行う場合、失敗したり、タイムアウトしたりする可能性があります。 呼び出すリモートサーバーがない可能性があります。

幸いなことに、HeroDetailServiceは、リモートデータアクセスの責任を注入されたHeroServiceに委任しています。

app/hero/hero-detail.service.ts (prototype)

      
import {Injectable} from '@angular/core';import {Observable} from 'rxjs';import {map} from 'rxjs/operators';import {Hero} from '../model/hero';import {HeroService} from '../model/hero.service';@Injectable({providedIn: 'root'})export class HeroDetailService {  constructor(private heroService: HeroService) {}  // Returns a clone which caller may modify safely  getHero(id: number | string): Observable<Hero | null> {    if (typeof id === 'string') {      id = parseInt(id, 10);    }    return this.heroService.getHero(id).pipe(      map((hero) => (hero ? Object.assign({}, hero) : null)), // clone or null    );  }  saveHero(hero: Hero) {    return this.heroService.updateHero(hero);  }}

前のテスト設定は、実際のHeroServiceTestHeroServiceに置き換えます。これは、サーバーリクエストをインターセプトし、その応答を偽造します。

もし、そんなに恵まれていなかったらどうでしょうか? HeroServiceを偽造するのが難しい場合はどうでしょうか? HeroDetailServiceが独自のサーバーリクエストを行う場合はどうでしょうか?

TestBed.overrideComponentメソッドは、次のようなセットアップのバリエーションのように、コンポーネントのprovidersを管理しやすいテストダブルに置き換えることができます。

app/hero/hero-detail.component.spec.ts (Override setup)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

TestBed.configureTestingModuleは、不要になったため、偽のHeroServiceを提供しなくなっていることに注意してください。

overrideComponentメソッド

overrideComponentメソッドに注目してください。

app/hero/hero-detail.component.spec.ts (overrideComponent)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

これは、2つの引数を取ります。オーバーライドするコンポーネントタイプ(HeroDetailComponent)と、オーバーライドメタデータオブジェクトです。 オーバーライドメタデータオブジェクトは、次のように定義された汎用型です。

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

メタデータオーバーライドオブジェクトは、メタデータプロパティの要素を追加および削除するか、それらのプロパティを完全にリセットできます。 この例では、コンポーネントのprovidersメタデータをリセットします。

型パラメーターTは、@Componentデコレーターに渡すメタデータの種類です。

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

スパイスタブHeroDetailServiceSpy)を提供する

この例では、コンポーネントのproviders配列を、HeroDetailServiceSpyを含む新しい配列に完全に置き換えます。

HeroDetailServiceSpyは、実際のHeroDetailServiceのスタブバージョンであり、そのサービスに必要なすべての機能を偽造します。 これは、下位のHeroServiceを注入したり、委任したりしないため、そのためのテストダブルを提供する必要はありません。

関連するHeroDetailComponentのテストは、サービスメソッドをスパイすることで、HeroDetailServiceのメソッドが呼び出されたことをアサートします。 それに応じて、スタブはメソッドをスパイとして実装します。

app/hero/hero-detail.component.spec.ts (HeroDetailServiceSpy)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

オーバーライドテスト

これでテストは、スパイスタブのtestHeroを操作することでコンポーネントのヒーローを直接制御し、サービスメソッドが呼び出されたことを確認できます。

app/hero/hero-detail.component.spec.ts (override tests)

      
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => {  describe('with HeroModule setup', heroModuleSetup);  describe('when override its provided HeroDetailService', overrideSetup);  describe('with FormsModule setup', formsModuleSetup);  describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() {  class HeroDetailServiceSpy {    testHero: Hero = {...testHero};    /* 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)));  }  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes', component: HeroListComponent},            {path: 'heroes/:id', component: HeroDetailComponent},          ]),          HttpClient,          HttpHandler,          // HeroDetailService at this level is IRRELEVANT!          {provide: HeroDetailService, useValue: {}},        ],      }),    )      .overrideComponent(HeroDetailComponent, {        set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},      })      .compileComponents();  });  let hdsSpy: HeroDetailServiceSpy;  beforeEach(async () => {    harness = await RouterTestingHarness.create();    component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);    page = new Page();    // get the component's injected HeroDetailServiceSpy    hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;    harness.detectChanges();  });  it('should have called `getHero`', () => {    expect(hdsSpy.getHero.calls.count())      .withContext('getHero called once')      .toBe(1, 'getHero called once');  });  it("should display stub hero's name", () => {    expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);  });  it('should save stub hero change', fakeAsync(() => {    const origName = hdsSpy.testHero.name;    const newName = 'New Name';    page.nameInput.value = newName;    page.nameInput.dispatchEvent(new Event('input')); // tell Angular    expect(component.hero.name).withContext('component hero has new name').toBe(newName);    expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName);    click(page.saveBtn);    expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);    tick(); // wait for async save to complete    expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName);    expect(TestBed.inject(Router).url).toEqual('/heroes');  }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, HeroListComponent],        providers: [          provideRouter([            {path: 'heroes/:id', component: HeroDetailComponent},            {path: 'heroes', component: HeroListComponent},          ]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  describe('when navigate to existing hero', () => {    let expectedHero: Hero;    beforeEach(async () => {      expectedHero = firstHero;      await createComponent(expectedHero.id);    });    it("should display that hero's name", () => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });    it('should navigate when click cancel', () => {      click(page.cancelBtn);      expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);    });    it('should save when click save but not navigate immediately', () => {      click(page.saveBtn);      expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    });    it('should navigate when click save and save resolves', fakeAsync(() => {      click(page.saveBtn);      tick(); // wait for async save to complete      expect(TestBed.inject(Router).url).toEqual('/heroes/41');    }));    it('should convert hero name to Title Case', async () => {      harness.fixture.autoDetectChanges();      // get the name's input and display elements from the DOM      const hostElement: HTMLElement = harness.routeNativeElement!;      const nameInput: HTMLInputElement = hostElement.querySelector('input')!;      const nameDisplay: HTMLElement = hostElement.querySelector('span')!;      // simulate user entering a new name into the input box      nameInput.value = 'quick BROWN  fOx';      // Dispatch a DOM event so that Angular learns of input value change.      nameInput.dispatchEvent(new Event('input'));      // Wait for Angular to update the display binding through the title pipe      await harness.fixture.whenStable();      expect(nameDisplay.textContent).toBe('Quick Brown  Fox');    });  });  describe('when navigate to non-existent hero id', () => {    beforeEach(async () => {      await createComponent(999);    });    it('should try to navigate back to hero list', () => {      expect(TestBed.inject(Router).url).toEqual('/heroes');    });  });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [FormsModule, HeroDetailComponent, TitleCasePipe],        providers: [          provideHttpClient(),          provideHttpClientTesting(),          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}///////////////////////function sharedModuleSetup() {  beforeEach(async () => {    await TestBed.configureTestingModule(      Object.assign({}, appConfig, {        imports: [HeroDetailComponent, sharedImports],        providers: [          provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),          provideHttpClient(),          provideHttpClientTesting(),        ],      }),    ).compileComponents();  });  it("should display 1st hero's name", async () => {    const expectedHero = firstHero;    await createComponent(expectedHero.id).then(() => {      expect(page.nameDisplay.textContent).toBe(expectedHero.name);    });  });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables  */async function createComponent(id: number) {  harness = await RouterTestingHarness.create();  component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);  page = new Page();  const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);  const hero = getTestHeroes().find((h) => h.id === Number(id));  request.flush(hero ? [hero] : []);  harness.detectChanges();}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');  }  //// query helpers ////  private query<T>(selector: string): T {    return harness.routeNativeElement!.querySelector(selector)! as T;  }  private queryAll<T>(selector: string): T[] {    return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];  }}

さらなるオーバーライド

TestBed.overrideComponentメソッドは、同じコンポーネントまたは異なるコンポーネントに対して複数回呼び出すことができます。 TestBedはこれらの他のクラスの一部を掘り下げて置き換えるために、overrideDirectiveoverrideModuleoverridePipeなどの類似のメソッドを提供します。

これらのオプションと組み合わせを自分で調べてみてください。