docs(testing): import test module and override component providers (#2428)

This commit is contained in:
Ward Bell 2016-09-21 11:27:11 -07:00 committed by GitHub
parent 36a3ea2c21
commit 4c71a32e36
6 changed files with 346 additions and 40 deletions

View File

@ -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<HeroDetailComponent>;
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<Hero> {
return Promise.resolve(true).then(() => Object.assign({}, this.testHero) );
}
saveHero(hero: Hero): Promise<Hero> {
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 */

View File

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

View File

@ -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<Hero> {
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

View File

@ -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<Hero> {
return this.getHero(hero.id).then(h => {
return h ?
Object.assign(h, hero) :
Promise.reject(`Hero ${hero.id} not found`) as any as Promise<Hero>;
if (!h) {
throw new Error(`Hero ${hero.id} not found`);
}
return Object.assign(h, hero);
});
}
}

View File

@ -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] ? '' : '/';

View File

@ -52,6 +52,10 @@ block includes
- [_Observable_ test double](#stub-observable)
1. [Use a _page_ object to simplify setup](#page-object)
<br><br>
1. [Setup with module imports](#import-module)
<br><br>
1. [Override component providers](#component-override)
<br><br>
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.
<live-example embedded img="devguide/testing/app-plunker.png"></live-example>
<br><br>
: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<T> = {
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&lt;SchemaMetadata | any[]&gt;;
};
#metadata-override-object
:marked
Each overide method takes a `MetadataOverride<T>` 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