From 4c71a32e36361bd73c873f21e13e85f9eb781eb8 Mon Sep 17 00:00:00 2001 From: Ward Bell Date: Wed, 21 Sep 2016 11:27:11 -0700 Subject: [PATCH] docs(testing): import test module and override component providers (#2428) --- .../ts/app/hero/hero-detail.component.spec.ts | 198 +++++++++++++++--- .../ts/app/hero/hero-detail.component.ts | 14 +- .../ts/app/hero/hero-detail.service.ts | 5 + .../testing/ts/app/model/hero.service.ts | 9 +- .../_examples/testing/ts/browser-test-shim.js | 2 +- public/docs/ts/latest/guide/testing.jade | 158 +++++++++++++- 6 files changed, 346 insertions(+), 40 deletions(-) diff --git a/public/docs/_examples/testing/ts/app/hero/hero-detail.component.spec.ts b/public/docs/_examples/testing/ts/app/hero/hero-detail.component.spec.ts index 272d69f579..68f8d17582 100644 --- a/public/docs/_examples/testing/ts/app/hero/hero-detail.component.spec.ts +++ b/public/docs/_examples/testing/ts/app/hero/hero-detail.component.spec.ts @@ -7,16 +7,13 @@ import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; import { - addMatchers, newEvent, - ActivatedRoute, ActivatedRouteStub, Router, RouterStub + ActivatedRoute, ActivatedRouteStub, newEvent, Router, RouterStub } from '../../testing'; -import { HEROES, FakeHeroService } from '../model/testing'; - -import { HeroModule } from './hero.module'; +import { Hero } from '../model'; import { HeroDetailComponent } from './hero-detail.component'; import { HeroDetailService } from './hero-detail.service'; -import { Hero, HeroService } from '../model'; +import { HeroModule } from './hero.module'; ////// Testing Vars ////// let activatedRoute: ActivatedRouteStub; @@ -24,20 +21,117 @@ let comp: HeroDetailComponent; let fixture: ComponentFixture; let page: Page; -////////// Tests //////////////////// - +////// Tests ////// describe('HeroDetailComponent', () => { + beforeEach(() => { + activatedRoute = new ActivatedRouteStub(); + }); + describe('with HeroModule setup', heroModuleSetup); + describe('when override its provided HeroDetailService', overrideSetup); + describe('with FormsModule setup', formsModuleSetup); + describe('with SharedModule setup', sharedModuleSetup); +}); + +//////////////////// +function overrideSetup() { + // #docregion stub-hds + class StubHeroDetailService { + testHero = new Hero(42, 'Test Hero'); + + getHero(id: number | string): Promise { + return Promise.resolve(true).then(() => Object.assign({}, this.testHero) ); + } + + saveHero(hero: Hero): Promise { + return Promise.resolve(true).then(() => Object.assign(this.testHero, hero) ); + } + } + // #enddocregion stub-hds + + // the `id` value is irrelevant because ignored by service stub + beforeEach(() => activatedRoute.testParams = { id: 99999 } ); + + // #docregion setup-override + beforeEach( async(() => { + TestBed.configureTestingModule({ + imports: [ HeroModule ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useClass: RouterStub}, + // #enddocregion setup-override + // HeroDetailService at this level is IRRELEVANT! + { provide: HeroDetailService, useValue: {} } + // #docregion setup-override + ] + }) + + // Override component's own provider + // #docregion override-component-method + .overrideComponent(HeroDetailComponent, { + set: { + providers: [ + { provide: HeroDetailService, useClass: StubHeroDetailService } + ] + } + }) + // #enddocregion override-component-method + + .compileComponents(); + })); + // #enddocregion setup-override + + // #docregion override-tests + let hds: StubHeroDetailService; beforeEach( async(() => { - addMatchers(); - activatedRoute = new ActivatedRouteStub(); + createComponent(); + // get the component's injected StubHeroDetailService + hds = fixture.debugElement.injector.get(HeroDetailService); + })); - TestBed.configureTestingModule({ - imports: [ HeroModule ], + it('should display stub hero\'s name', () => { + expect(page.nameDisplay.textContent).toBe(hds.testHero.name); + }); - // DON'T RE-DECLARE because already declared in HeroModule - // declarations: [HeroDetailComponent, TitleCasePipe], // No! + it('should save stub hero change', fakeAsync(() => { + const origName = hds.testHero.name; + const newName = 'New Name'; + page.nameInput.value = newName; + page.nameInput.dispatchEvent(newEvent('input')); // tell Angular + + expect(comp.hero.name).toBe(newName, 'component hero has new name'); + expect(hds.testHero.name).toBe(origName, 'service hero unchanged before save'); + + page.saveBtn.triggerEventHandler('click', null); + tick(); // wait for async save to complete + expect(hds.testHero.name).toBe(newName, 'service hero has new name after save'); + expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called'); + })); + // #enddocregion override-tests + + it('fixture injected service is not the component injected service', + inject([HeroDetailService], (service: HeroDetailService) => { + + expect(service).toEqual({}, 'service injected from fixture'); + expect(hds).toBeTruthy('service injected into component'); + })); +} + +//////////////////// +import { HEROES, FakeHeroService } from '../model/testing'; +import { HeroService } from '../model'; + +const firstHero = HEROES[0]; + +function heroModuleSetup() { + // #docregion setup-hero-module + beforeEach( async(() => { + TestBed.configureTestingModule({ + imports: [ HeroModule ], + // #enddocregion setup-hero-module + // declarations: [ HeroDetailComponent ], // NO! DOUBLE DECLARATION + // #docregion setup-hero-module providers: [ { provide: ActivatedRoute, useValue: activatedRoute }, { provide: HeroService, useClass: FakeHeroService }, @@ -46,13 +140,14 @@ describe('HeroDetailComponent', () => { }) .compileComponents(); })); + // #enddocregion setup-hero-module // #docregion route-good-id - describe('when navigate to hero id=' + HEROES[0].id, () => { + describe('when navigate to existing hero', () => { let expectedHero: Hero; beforeEach( async(() => { - expectedHero = HEROES[0]; + expectedHero = firstHero; activatedRoute.testParams = { id: expectedHero.id }; createComponent(); })); @@ -76,7 +171,7 @@ describe('HeroDetailComponent', () => { it('should navigate when click save and save resolves', fakeAsync(() => { page.saveBtn.triggerEventHandler('click', null); - tick(); // wait for async save to "complete" before navigating + tick(); // wait for async save to complete expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called'); })); @@ -91,8 +186,7 @@ describe('HeroDetailComponent', () => { // dispatch a DOM event so that Angular learns of input value change. page.nameInput.dispatchEvent(newEvent('input')); - // detectChanges() makes [(ngModel)] push input value to component property - // and Angular updates the output span through the title pipe + // Tell Angular to update the output span through the title pipe fixture.detectChanges(); expect(page.nameDisplay.textContent).toBe(titleCaseName); @@ -131,10 +225,8 @@ describe('HeroDetailComponent', () => { }); // #enddocregion route-bad-id - /////////////////////////// - // Why we must use `fixture.debugElement.injector` in `Page()` - it('cannot use `inject` to get component\'s provided service', () => { + it('cannot use `inject` to get component\'s provided HeroDetailService', () => { let service: HeroDetailService; fixture = TestBed.createComponent(HeroDetailComponent); expect( @@ -148,7 +240,64 @@ describe('HeroDetailComponent', () => { service = fixture.debugElement.injector.get(HeroDetailService); expect(service).toBeDefined('debugElement.injector'); }); -}); +} + +///////////////////// +import { FormsModule } from '@angular/forms'; +import { TitleCasePipe } from '../shared/title-case.pipe'; + +function formsModuleSetup() { + // #docregion setup-forms-module + beforeEach( async(() => { + TestBed.configureTestingModule({ + imports: [ FormsModule ], + declarations: [ HeroDetailComponent, TitleCasePipe ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: HeroService, useClass: FakeHeroService }, + { provide: Router, useClass: RouterStub}, + ] + }) + .compileComponents(); + })); + // #enddocregion setup-forms-module + + it('should display 1st hero\'s name', fakeAsync(() => { + const expectedHero = firstHero; + activatedRoute.testParams = { id: expectedHero.id }; + createComponent().then(() => { + expect(page.nameDisplay.textContent).toBe(expectedHero.name); + }); + })); +} + +/////////////////////// +import { SharedModule } from '../shared/shared.module'; + +function sharedModuleSetup() { + // #docregion setup-shared-module + beforeEach( async(() => { + TestBed.configureTestingModule({ + imports: [ SharedModule ], + declarations: [ HeroDetailComponent ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: HeroService, useClass: FakeHeroService }, + { provide: Router, useClass: RouterStub}, + ] + }) + .compileComponents(); + })); + // #enddocregion setup-shared-module + + it('should display 1st hero\'s name', fakeAsync(() => { + const expectedHero = firstHero; + activatedRoute.testParams = { id: expectedHero.id }; + createComponent().then(() => { + expect(page.nameDisplay.textContent).toBe(expectedHero.name); + }); + })); +} /////////// Helpers ///// @@ -185,9 +334,10 @@ class Page { const compInjector = fixture.debugElement.injector; const hds = compInjector.get(HeroDetailService); const router = compInjector.get(Router); + this.gotoSpy = spyOn(comp, 'gotoList').and.callThrough(); - this.saveSpy = spyOn(hds, 'saveHero').and.callThrough(); this.navSpy = spyOn(router, 'navigate'); + this.saveSpy = spyOn(hds, 'saveHero').and.callThrough(); } /** Add page elements after hero arrives */ diff --git a/public/docs/_examples/testing/ts/app/hero/hero-detail.component.ts b/public/docs/_examples/testing/ts/app/hero/hero-detail.component.ts index 1cbf5389b9..32a59ef2c2 100644 --- a/public/docs/_examples/testing/ts/app/hero/hero-detail.component.ts +++ b/public/docs/_examples/testing/ts/app/hero/hero-detail.component.ts @@ -1,3 +1,5 @@ +/* tslint:disable:member-ordering */ +// #docplaster import { Component, Input, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import 'rxjs/add/operator/pluck'; @@ -5,22 +7,24 @@ import 'rxjs/add/operator/pluck'; import { Hero } from '../model'; import { HeroDetailService } from './hero-detail.service'; +// #docregion prototype @Component({ - selector: 'app-hero-detail', + selector: 'app-hero-detail', templateUrl: 'app/hero/hero-detail.component.html', styleUrls: ['app/hero/hero-detail.component.css'], providers: [ HeroDetailService ] }) export class HeroDetailComponent implements OnInit { - @Input() hero: Hero; - // #docregion ctor constructor( private heroDetailService: HeroDetailService, - private route: ActivatedRoute, + private route: ActivatedRoute, private router: Router) { } // #enddocregion ctor +// #enddocregion prototype + + @Input() hero: Hero; // #docregion ng-on-init ngOnInit(): void { @@ -50,4 +54,6 @@ export class HeroDetailComponent implements OnInit { gotoList() { this.router.navigate(['../'], {relativeTo: this.route}); } +// #docregion prototype } +// #enddocregion prototype diff --git a/public/docs/_examples/testing/ts/app/hero/hero-detail.service.ts b/public/docs/_examples/testing/ts/app/hero/hero-detail.service.ts index 970cb1b98b..6239ae5b80 100644 --- a/public/docs/_examples/testing/ts/app/hero/hero-detail.service.ts +++ b/public/docs/_examples/testing/ts/app/hero/hero-detail.service.ts @@ -2,10 +2,13 @@ import { Injectable } from '@angular/core'; import { Hero, HeroService } from '../model'; +// #docregion prototype @Injectable() export class HeroDetailService { constructor(private heroService: HeroService) { } +// #enddocregion prototype + // Returns a clone which caller may modify safely getHero(id: number | string): Promise { if (typeof id === 'string') { id = parseInt(id as string, 10); @@ -18,4 +21,6 @@ export class HeroDetailService { saveHero(hero: Hero) { return this.heroService.updateHero(hero); } +// #docregion prototype } +// #enddocregion prototype diff --git a/public/docs/_examples/testing/ts/app/model/hero.service.ts b/public/docs/_examples/testing/ts/app/model/hero.service.ts index 7f2931a7f6..667d47312b 100644 --- a/public/docs/_examples/testing/ts/app/model/hero.service.ts +++ b/public/docs/_examples/testing/ts/app/model/hero.service.ts @@ -4,7 +4,7 @@ import { Hero } from './hero'; import { HEROES } from './test-heroes'; @Injectable() -/** Dummy HeroService that pretends to be real */ +/** Dummy HeroService. Pretend it makes real http requests */ export class HeroService { getHeroes() { return Promise.resolve(HEROES); @@ -21,9 +21,10 @@ export class HeroService { updateHero(hero: Hero): Promise { return this.getHero(hero.id).then(h => { - return h ? - Object.assign(h, hero) : - Promise.reject(`Hero ${hero.id} not found`) as any as Promise; + if (!h) { + throw new Error(`Hero ${hero.id} not found`); + } + return Object.assign(h, hero); }); } } diff --git a/public/docs/_examples/testing/ts/browser-test-shim.js b/public/docs/_examples/testing/ts/browser-test-shim.js index 1573c72ebd..1cbabc3f64 100644 --- a/public/docs/_examples/testing/ts/browser-test-shim.js +++ b/public/docs/_examples/testing/ts/browser-test-shim.js @@ -9,7 +9,7 @@ Error.stackTraceLimit = 0; // "No stacktrace"" is usually best for app testing. // Uncomment to get full stacktrace output. Sometimes helpful, usually not. // Error.stackTraceLimit = Infinity; // -jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000; +jasmine.DEFAULT_TIMEOUT_INTERVAL = 3000; var baseURL = document.baseURI; baseURL = baseURL + baseURL[baseURL.length-1] ? '' : '/'; diff --git a/public/docs/ts/latest/guide/testing.jade b/public/docs/ts/latest/guide/testing.jade index dc4e9e873a..ac38744aa6 100644 --- a/public/docs/ts/latest/guide/testing.jade +++ b/public/docs/ts/latest/guide/testing.jade @@ -52,6 +52,10 @@ block includes - [_Observable_ test double](#stub-observable) 1. [Use a _page_ object to simplify setup](#page-object)

+ 1. [Setup with module imports](#import-module) +

+ 1. [Override component providers](#component-override) +

1. [Test a _RouterOutlet_ component](#router-outlet-component) - [stubbing unneeded components](#stub-component) - [Stubbing the _RouterLink_](#router-link-stub) @@ -446,6 +450,7 @@ a(href="#top").to-top Back to top This chapter tests a cut-down version of the _Tour of Heroes_ [tutorial app](../tutorial). The following live example shows how it works and provides the complete source code. + Give it some time to load and warm up.

:marked @@ -1210,7 +1215,151 @@ figure.image-display There are no distractions: no waiting for promises to resolve and no searching the DOM for element values to compare. Here are a few more `HeroDetailComponent` tests to drive the point home. +makeExample('testing/ts/app/hero/hero-detail.component.spec.ts', 'selected-tests', 'app/hero/hero-detail.component.spec.ts (selected tests)')(format='.') + +a(href="#top").to-top Back to top +.l-hr + +#import-module :marked + # Setup with module imports + Earlier component tests configured the testing module with a few `declarations` like this: ++makeExample('testing/ts/app/dashboard/dashboard-hero.component.spec.ts', 'compile-components', 'app/dashboard/dashboard-hero.component.spec.ts (config)')(format='.') +:marked + The `DashboardComponent` is simple. It needs no help. + But more complex components often depend on other components, directives, pipes, and providers + and these must be added to the testing module too. + + Fortunately, the `TestBed.configureTestingModule` parameter parallels + the metadata passed to the `@NgModule` decorator + which means you can also specify `providers` and `imports. + + The `HeroDetailComponent` requires a lot of help despite its small size and simple construction. + In addition to the support it receives from the default testing module `CommonModule`, it needs: + * `NgModel` and friends in the `FormsModule` enable two-way data binding + * The `TitleCasePipe` from the `shared` folder + * Router services (which these tests are stubbing) + * Hero data access services (also stubbed) + + One approach is to configure the testing module from the individual pieces as in this example: ++makeExample('testing/ts/app/hero/hero-detail.component.spec.ts', 'setup-forms-module', 'app/hero/hero-detail.component.spec.ts (FormsModule setup)')(format='.') +:marked + Because many app components need the `FormsModule` and the `TitleCasePipe`, the developer created + a `SharedModule` to combine these and other frequently requested parts. + The test configuration can use the `SharedModule` too as seen in this alternative setup: ++makeExample('testing/ts/app/hero/hero-detail.component.spec.ts', 'setup-shared-module', 'app/hero/hero-detail.component.spec.ts (SharedModule setup)')(format='.') +:marked + It's a bit tighter and smaller, with fewer import statements (not shown). + +#feature-module-import +:marked + ### Import the feature module + The `HeroDetailComponent` is part of the `HeroModule` [Feature Module](ngmodule.html#feature-modules) that aggregates more of the interdependent pieces + including the `SharedModule`. + Try a test configuration that imports the `HeroModule` like this one: ++makeExample('testing/ts/app/hero/hero-detail.component.spec.ts', 'setup-hero-module', 'app/hero/hero-detail.component.spec.ts (HeroModule setup)')(format='.') +:marked + That's _really_ crisp. Only the _test doubles_ in the `providers` remain. Even the `HeroDetailComponent` declaration is gone. +.l-sub-section + :marked + In fact, if you try to declare it, Angular throws an error because + `HeroDetailComponent` is declared in both the `HeroModule` and the `DynamicTestModule` (the testing module). + +.alert.is-helpful + :marked + Importing the component's feature module is often the easiest way to configure the tests, + especially when the feature module is small and mostly self-contained ... as feature modules should be. +:marked + +a(href="#top").to-top Back to top +.l-hr + +#component-override +:marked + # Override component providers + + The `HeroDetailComponent` provides its own `HeroDetailService`. ++makeExample('testing/ts/app/hero/hero-detail.component.ts', 'prototype', 'app/hero/hero-detail.component.ts (prototype)')(format='.') +:marked + It's not possible to stub the component's `HeroDetailService` in the `providers` of the `TestBed.configureTestingModule`. + Those are providers for the _testing module_, not the component. They prepare the dependency injector at the _fixture level_. + + Angular creates the component with its _own_ injector which is a _child_ of the fixture injector. + It registers the component's providers (the `HeroDetailService` in this case) with the child injector. + A test cannot get to child injector services from the fixture injector. + And `TestBed.configureTestingModule` can't configure them either. + + Angular has been creating new instances of the real `HeroDetailService` all along! + +.l-sub-section + :marked + These tests could fail or timeout if the `HeroDetailService` made its own XHR calls to a remote server. + There might not be a remote server to call. + + Fortunately, the `HeroDetailService` delegates responsibility for remote data access to an injected `HeroService`. + + +makeExample('testing/ts/app/hero/hero-detail.service.ts', 'prototype', 'app/hero/hero-detail.service.ts (prototype)')(format='.') + :marked + The [previous test configuration](#feature-module-import) replaces the real `HeroService` with a `FakeHeroService` + that intercepts server requests and fakes their responses. + +:marked + What if you aren't so lucky. What if faking the `HeroService` is hard? + What if `HeroDetailService` makes its own server requests? + + The `TestBed.overrideComponent` method can replace the component's `providers` with easy-to-manage _test doubles_ + as seen in the following setup variation: ++makeExample('testing/ts/app/hero/hero-detail.component.spec.ts', 'setup-override', 'app/hero/hero-detail.component.spec.ts (Override setup)')(format='.') +:marked + Notice that `TestBed.configureTestingModule` no longer provides a (fake) `HeroService` because it's [not needed](#stub-hero-detail-service). + +#override-component-method +:marked + ### The _overrideComponent_ method + + Focus on the `overrideComponent` method. ++makeExample('testing/ts/app/hero/hero-detail.component.spec.ts', 'override-component-method', 'app/hero/hero-detail.component.spec.ts (overrideComponent)')(format='.') +:marked + It takes two arguments: the component type to override (`HeroDetailComponent`) and an override metadata object. + The [overide metadata object](#metadata-override-object) is a generic defined as follows: + +code-example(format="." language="javascript"). + type MetadataOverride = { + add?: T; + remove?: T; + set?: T; + }; +:marked + A metadata override object can either add-and-remove elements in metadata properties or completely reset those properties. + This example resets the component's `providers` metadata. + + The type parameter, `T`, is the kind of metadata you'd pass to the `@Component` decorator: +code-example(format="." language="javascript"). + selector?: string; + template?: string; + templateUrl?: string; + providers?: any[]; + ... + +#stub-hero-detail-service +:marked + ### _StubHeroDetailService_ + + This example completely replaces the component's `providers` with an array containing the `StubHeroDetailService`. + The `StubHeroDetailService` is dead simple. It doesn't need a `HeroService` (fake or otherwise). ++makeExample('testing/ts/app/hero/hero-detail.component.spec.ts', 'stub-hds', 'app/hero/hero-detail.component.spec.ts (StubHeroDetailService)')(format='.') +:marked + ### The override tests + + Now the tests can control the component's hero directly by manipulating the stub's `testHero`. ++makeExample('testing/ts/app/hero/hero-detail.component.spec.ts', 'override-tests', 'app/hero/hero-detail.component.spec.ts (override tests)')(format='.') +:marked + ### More overrides + The `TestBed.overrideComponent` method can be called multiple times for the same or different components. + The `TestBed` offers similar `overrideDirective`, `overrideModule`, and `overridePipe` methods + for digging into and replacing parts of these other classes. + + Explore the options and combinations on your own. + a(href="#top").to-top Back to top .l-hr @@ -1299,8 +1448,7 @@ a(href="#top").to-top Back to top ### What good are these tests? Stubbed `RouterLink` tests can confirm that a component with links and an outlet is setup properly, - that the component has the links it should have and - that they are all pointing in the expected direction. + that the component has the links it should have, and that they are all pointing in the expected direction. These tests do not concern whether the app will succeed in navigating to the target component when the user clicks a link. Stubbing the RouterLink and RouterOutlet is the best option for such limited testing goals. @@ -1664,6 +1812,7 @@ code-example(format="." language="javascript"). schemas?: Array<SchemaMetadata | any[]>; }; +#metadata-override-object :marked Each overide method takes a `MetadataOverride` where `T` is the kind of metadata appropriate to the method, the parameter of an `@NgModule`, `@Component`, `@Directive`, or `@Pipe`. @@ -1913,11 +2062,6 @@ table From the test root component's `DebugElement`, returned by `fixture.debugElement`, you can walk (and query) the fixture's entire element and component sub-trees. -.alert.is-important - :marked - The _DebugElement_ is officially _experimental_ and thus subject to change. - Consult the [API reference](../api/core/index/DebugElement-class.html) for the latest status. -:marked Here are the most useful `DebugElement` members for testers in approximate order of utility. table