このガイドでは、一般的なコンポーネントテストのユースケースについて説明します。
コンポーネントバインディング
サンプルアプリケーションでは、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()
TestBed
にfixture.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のテスト環境が本番環境のように自動的に変更検知を実行することを好みます。
これは、TestBed
をComponentFixtureAutoDetect
プロバイダーで設定することで可能です。
まず、テストユーティリティライブラリからインポートします。
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()関数は、millis
とtickOptions
をパラメーターとして受け取ります。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の場合、outside
とnested
の両方がトリガーされます。
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.clock
とfakeAsync()
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つの重要な違いを示しています。
DashboardHeroComponent
とTestHostComponent
の両方をインポートします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のヒーローをクリックすると、DashboardComponent
はRouter
にheroes/: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.paramMap
のid
プロパティを参照することはできません。
コンポーネントは、ActivatedRoute.paramMap
Observableを購読し、ライフタイム中に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>
も必要ありません。
BannerComponent
とWelcomeComponent
(<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
設定で対応するAppComponent
とRouterLink
を宣言したため、<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.ts
のPage
クラスを示します。
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
デコレーターに渡されるメタデータと並行しているため、providers
とimports
も指定できます。
HeroDetailComponent
は、小さいサイズでシンプルな構造にもかかわらず、多くの助けを必要としています。
デフォルトのテストモジュールCommonModule
からサポートを受けることに加えて、次のようなものが必要です。
FormsModule
のNgModel
など、双方向データバインディングを有効にする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以外の環境で実行することができます。
共有モジュールのインポート
多くのアプリケーションコンポーネントがFormsModule
とTitleCasePipe
を必要とするため、開発者は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はエラーをスローします。なぜなら、HeroDetailComponent
はHeroModule
とTestBed
によって作成された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.configureTestingModule
のproviders
でコンポーネントの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); }}
前のテスト設定は、実際のHeroService
をTestHeroService
に置き換えます。これは、サーバーリクエストをインターセプトし、その応答を偽造します。
もし、そんなに恵まれていなかったらどうでしょうか?
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
はこれらの他のクラスの一部を掘り下げて置き換えるために、overrideDirective
やoverrideModule
、overridePipe
などの類似のメソッドを提供します。
これらのオプションと組み合わせを自分で調べてみてください。