From a7e1f236ff525a93146d47f0886d1f6ac329ede2 Mon Sep 17 00:00:00 2001 From: Ward Bell Date: Fri, 27 Oct 2017 15:48:50 -0700 Subject: [PATCH] docs: testing guide for CLI (#20697) - updates tests - heavy prose revisions - uses HttpClient (with angular-in-memory-web-api) - test HeroService using `HttpClientTestingModule` - scrub away most By.CSS - fake async observable with `asyncData()` - extensive Twain work - different take on retryWhen - remove app barrels (& systemjs.extras) which troubled plunker/systemjs - add dummy export const to hero.ts (plunkr/systemjs fails w/o it) - shrink and re-organize TOC - add marble testing package and tests - demonstrate the "no beforeEach()" test coding style - add section on Http service testing - prepare for stackblitz - confirm works in plunker except excluded marble test - add tests for avoidFile class feature of CodeExampleComponent PR Close #20697 --- .../examples/testing/specs.stackblitz.json | 2 +- .../examples/testing/src/app/1st.spec.ts | 5 - .../app/{ => about}/about.component.spec.ts | 7 +- .../src/app/{ => about}/about.component.ts | 3 +- .../src/app/app-initial.component.spec.ts | 76 + .../testing/src/app/app-initial.component.ts | 11 + .../testing/src/app/app-routing.module.ts | 2 +- .../testing/src/app/app.component.html | 4 +- .../src/app/app.component.router.spec.ts | 65 +- .../testing/src/app/app.component.spec.ts | 100 +- .../examples/testing/src/app/app.module.ts | 41 +- .../src/app/bag/bag.no-testbed.spec.ts | 130 - .../examples/testing/src/app/bag/bag.spec.ts | 681 --- .../src/app/banner-inline.component.spec.ts | 55 - .../testing/src/app/banner.component.spec.ts | 53 - .../banner-external.component.css} | 0 .../banner-external.component.html} | 0 .../banner/banner-external.component.spec.ts | 72 + .../banner-external.component.ts} | 7 +- .../banner/banner-initial.component.spec.ts | 119 + .../app/banner/banner-initial.component.ts | 10 + .../banner.component.detect-changes.spec.ts | 22 +- .../src/app/banner/banner.component.spec.ts | 56 + .../src/app/{ => banner}/banner.component.ts | 8 +- .../dashboard/dashboard-hero.component.html | 4 - .../dashboard-hero.component.spec.ts | 107 +- .../app/dashboard/dashboard-hero.component.ts | 10 +- .../dashboard.component.no-testbed.spec.ts | 22 +- .../app/dashboard/dashboard.component.spec.ts | 57 +- .../src/app/dashboard/dashboard.component.ts | 4 +- .../app/{bag => demo}/async-helper.spec.ts | 11 +- .../demo-external-template.html} | 0 .../{bag/bag-main.ts => demo/demo-main.ts} | 4 +- .../testing/src/app/demo/demo.spec.ts | 153 + .../testing/src/app/demo/demo.testbed.spec.ts | 706 +++ .../src/app/{bag/bag.ts => demo/demo.ts} | 83 +- .../examples/testing/src/app/dummy.module.ts | 15 + .../hero-detail.component.no-testbed.spec.ts | 25 +- .../app/hero/hero-detail.component.spec.ts | 170 +- .../src/app/hero/hero-detail.component.ts | 11 +- .../src/app/hero/hero-detail.service.ts | 13 +- .../src/app/hero/hero-list.component.css | 2 +- .../src/app/hero/hero-list.component.spec.ts | 30 +- .../src/app/hero/hero-list.component.ts | 4 +- .../testing/src/app/in-memory-data.service.ts | 26 + .../src/app/model/hero.service.spec.ts | 215 + .../testing/src/app/model/hero.service.ts | 98 +- .../testing/src/app/model/hero.spec.ts | 20 - .../examples/testing/src/app/model/hero.ts | 10 +- .../src/app/model/http-hero.service.spec.ts | 157 +- .../src/app/model/http-hero.service.ts | 41 +- .../testing/src/app/model/test-heroes.ts | 11 - .../app/model/testing/fake-hero.service.ts | 41 - .../src/app/model/testing/http-client.spec.ts | 192 + .../app/model/testing/test-hero.service.ts | 61 + .../src/app/model/testing/test-heroes.ts | 13 + .../testing/src/app/shared/shared.module.ts | 14 +- .../src/app/shared/twain.component.spec.ts | 92 - .../twain.component.timer.spec.ts.no-work | 116 - .../shared/twain.component.timer.ts.no-work | 27 - .../testing/src/app/shared/twain.component.ts | 20 - .../testing/src/app/shared/twain.service.ts | 32 - .../examples/testing/src/app/twain/quote.ts | 4 + .../app/twain/twain.component.marbles.spec.ts | 93 + .../src/app/twain/twain.component.spec.ts | 184 + .../testing/src/app/twain/twain.component.ts | 49 + .../testing/src/app/twain/twain.data.ts | 15 + .../testing/src/app/twain/twain.service.ts | 47 + .../{ => welcome}/welcome.component.spec.ts | 63 +- .../app/{ => welcome}/welcome.component.ts | 13 +- aio/content/examples/testing/src/bag.html | 26 - .../src/testing/activated-route-stub.ts | 29 + .../src/testing/async-observable-helpers.ts | 30 + .../examples/testing/src/testing/index.ts | 4 +- .../src/testing/router-link-directive-stub.ts | 30 + .../testing/src/testing/router-stubs.ts | 58 - aio/content/examples/testing/src/tests.html | 64 + aio/content/examples/testing/src/tests.sb.ts | 42 +- .../karma-test-shim.1.js | 1 + aio/content/guide/change-log.md | 4 +- aio/content/guide/testing.md | 3803 ++++++++--------- .../testing/initial-jasmine-html-reporter.png | Bin 0 -> 17717 bytes .../code/code-example.component.spec.ts | 30 +- .../embedded/code/code-example.component.ts | 3 +- .../shared/boilerplate/cli/package.json | 1 + .../shared/boilerplate/testing/package.json | 51 - aio/tools/examples/shared/yarn.lock | 12 +- aio/tools/stackblitz-builder/builder.js | 3 +- 88 files changed, 4831 insertions(+), 3974 deletions(-) delete mode 100644 aio/content/examples/testing/src/app/1st.spec.ts rename aio/content/examples/testing/src/app/{ => about}/about.component.spec.ts (73%) rename aio/content/examples/testing/src/app/{ => about}/about.component.ts (84%) create mode 100644 aio/content/examples/testing/src/app/app-initial.component.spec.ts create mode 100644 aio/content/examples/testing/src/app/app-initial.component.ts delete mode 100644 aio/content/examples/testing/src/app/bag/bag.no-testbed.spec.ts delete mode 100644 aio/content/examples/testing/src/app/bag/bag.spec.ts delete mode 100644 aio/content/examples/testing/src/app/banner-inline.component.spec.ts delete mode 100644 aio/content/examples/testing/src/app/banner.component.spec.ts rename aio/content/examples/testing/src/app/{banner.component.css => banner/banner-external.component.css} (100%) rename aio/content/examples/testing/src/app/{banner.component.html => banner/banner-external.component.html} (100%) create mode 100644 aio/content/examples/testing/src/app/banner/banner-external.component.spec.ts rename aio/content/examples/testing/src/app/{banner-inline.component.ts => banner/banner-external.component.ts} (50%) create mode 100644 aio/content/examples/testing/src/app/banner/banner-initial.component.spec.ts create mode 100644 aio/content/examples/testing/src/app/banner/banner-initial.component.ts rename aio/content/examples/testing/src/app/{ => banner}/banner.component.detect-changes.spec.ts (76%) create mode 100644 aio/content/examples/testing/src/app/banner/banner.component.spec.ts rename aio/content/examples/testing/src/app/{ => banner}/banner.component.ts (52%) delete mode 100644 aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.html rename aio/content/examples/testing/src/app/{bag => demo}/async-helper.spec.ts (89%) rename aio/content/examples/testing/src/app/{bag/bag-external-template.html => demo/demo-external-template.html} (100%) rename aio/content/examples/testing/src/app/{bag/bag-main.ts => demo/demo-main.ts} (52%) create mode 100644 aio/content/examples/testing/src/app/demo/demo.spec.ts create mode 100644 aio/content/examples/testing/src/app/demo/demo.testbed.spec.ts rename aio/content/examples/testing/src/app/{bag/bag.ts => demo/demo.ts} (85%) create mode 100644 aio/content/examples/testing/src/app/dummy.module.ts create mode 100644 aio/content/examples/testing/src/app/in-memory-data.service.ts create mode 100644 aio/content/examples/testing/src/app/model/hero.service.spec.ts delete mode 100644 aio/content/examples/testing/src/app/model/hero.spec.ts delete mode 100644 aio/content/examples/testing/src/app/model/test-heroes.ts delete mode 100644 aio/content/examples/testing/src/app/model/testing/fake-hero.service.ts create mode 100644 aio/content/examples/testing/src/app/model/testing/http-client.spec.ts create mode 100644 aio/content/examples/testing/src/app/model/testing/test-hero.service.ts create mode 100644 aio/content/examples/testing/src/app/model/testing/test-heroes.ts delete mode 100644 aio/content/examples/testing/src/app/shared/twain.component.spec.ts delete mode 100644 aio/content/examples/testing/src/app/shared/twain.component.timer.spec.ts.no-work delete mode 100644 aio/content/examples/testing/src/app/shared/twain.component.timer.ts.no-work delete mode 100644 aio/content/examples/testing/src/app/shared/twain.component.ts delete mode 100644 aio/content/examples/testing/src/app/shared/twain.service.ts create mode 100644 aio/content/examples/testing/src/app/twain/quote.ts create mode 100644 aio/content/examples/testing/src/app/twain/twain.component.marbles.spec.ts create mode 100644 aio/content/examples/testing/src/app/twain/twain.component.spec.ts create mode 100644 aio/content/examples/testing/src/app/twain/twain.component.ts create mode 100644 aio/content/examples/testing/src/app/twain/twain.data.ts create mode 100644 aio/content/examples/testing/src/app/twain/twain.service.ts rename aio/content/examples/testing/src/app/{ => welcome}/welcome.component.spec.ts (67%) rename aio/content/examples/testing/src/app/{ => welcome}/welcome.component.ts (50%) delete mode 100644 aio/content/examples/testing/src/bag.html create mode 100644 aio/content/examples/testing/src/testing/activated-route-stub.ts create mode 100644 aio/content/examples/testing/src/testing/async-observable-helpers.ts create mode 100644 aio/content/examples/testing/src/testing/router-link-directive-stub.ts delete mode 100644 aio/content/examples/testing/src/testing/router-stubs.ts create mode 100644 aio/content/examples/testing/src/tests.html create mode 100644 aio/content/images/guide/testing/initial-jasmine-html-reporter.png delete mode 100644 aio/tools/examples/shared/boilerplate/testing/package.json diff --git a/aio/content/examples/testing/specs.stackblitz.json b/aio/content/examples/testing/specs.stackblitz.json index a133b0619f..627630f6ed 100644 --- a/aio/content/examples/testing/specs.stackblitz.json +++ b/aio/content/examples/testing/specs.stackblitz.json @@ -1,5 +1,5 @@ { - "description": "Testing - app.specs", + "description": "Testing - specs", "files":[ "src/styles.css", diff --git a/aio/content/examples/testing/src/app/1st.spec.ts b/aio/content/examples/testing/src/app/1st.spec.ts deleted file mode 100644 index 63f1ab134c..0000000000 --- a/aio/content/examples/testing/src/app/1st.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -// #docplaster -// #docregion -describe('1st tests', () => { - it('true is true', () => expect(true).toBe(true)); -}); diff --git a/aio/content/examples/testing/src/app/about.component.spec.ts b/aio/content/examples/testing/src/app/about/about.component.spec.ts similarity index 73% rename from aio/content/examples/testing/src/app/about.component.spec.ts rename to aio/content/examples/testing/src/app/about/about.component.spec.ts index 0909e74434..80bebd99a7 100644 --- a/aio/content/examples/testing/src/app/about.component.spec.ts +++ b/aio/content/examples/testing/src/app/about/about.component.spec.ts @@ -1,9 +1,8 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; import { AboutComponent } from './about.component'; -import { HighlightDirective } from './shared/highlight.directive'; +import { HighlightDirective } from '../shared/highlight.directive'; let fixture: ComponentFixture; @@ -19,8 +18,8 @@ describe('AboutComponent (highlightDirective)', () => { }); it('should have skyblue

', () => { - const de = fixture.debugElement.query(By.css('h2')); - const bgColor = de.nativeElement.style.backgroundColor; + const h2: HTMLElement = fixture.nativeElement.querySelector('h2'); + const bgColor = h2.style.backgroundColor; expect(bgColor).toBe('skyblue'); }); // #enddocregion tests diff --git a/aio/content/examples/testing/src/app/about.component.ts b/aio/content/examples/testing/src/app/about/about.component.ts similarity index 84% rename from aio/content/examples/testing/src/app/about.component.ts rename to aio/content/examples/testing/src/app/about/about.component.ts index 90e7132b4c..465d081d25 100644 --- a/aio/content/examples/testing/src/app/about.component.ts +++ b/aio/content/examples/testing/src/app/about/about.component.ts @@ -3,7 +3,8 @@ import { Component } from '@angular/core'; @Component({ template: `

About

+

Quote of the day:

-

All about this sample

` + ` }) export class AboutComponent { } diff --git a/aio/content/examples/testing/src/app/app-initial.component.spec.ts b/aio/content/examples/testing/src/app/app-initial.component.spec.ts new file mode 100644 index 0000000000..3e3e6a847c --- /dev/null +++ b/aio/content/examples/testing/src/app/app-initial.component.spec.ts @@ -0,0 +1,76 @@ +// #docplaster +// #docregion +import { TestBed, async } from '@angular/core/testing'; +// #enddocregion +import { AppComponent } from './app-initial.component'; +/* +// #docregion +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { +// #enddocregion +*/ +describe('AppComponent (initial CLI version)', () => { + // #docregion + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + AppComponent + ], + }).compileComponents(); + })); + it('should create the app', async(() => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app).toBeTruthy(); + })); + it(`should have as title 'app'`, async(() => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app.title).toEqual('app'); + })); + it('should render title in a h1 tag', async(() => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement; + expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); + })); +}); +// #enddocregion + +/// As it should be +import { DebugElement } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; + +describe('AppComponent (initial CLI version - as it should be)', () => { + + let app: AppComponent; + let de: DebugElement; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + AppComponent + ], + }); + + fixture = TestBed.createComponent(AppComponent); + app = fixture.componentInstance; + de = fixture.debugElement; + }); + + it('should create the app', () => { + expect(app).toBeDefined(); + }); + + it(`should have as title 'app'`, () => { + expect(app.title).toEqual('app'); + }); + + it('should render title in an h1 tag', () => { + fixture.detectChanges(); + expect(de.nativeElement.querySelector('h1').textContent) + .toContain('Welcome to app!'); + }); +}); diff --git a/aio/content/examples/testing/src/app/app-initial.component.ts b/aio/content/examples/testing/src/app/app-initial.component.ts new file mode 100644 index 0000000000..0f06d4dd07 --- /dev/null +++ b/aio/content/examples/testing/src/app/app-initial.component.ts @@ -0,0 +1,11 @@ +// #docregion +// Reduced version of the initial AppComponent generated by CLI +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + template: '

Welcome to {{title}}!

' +}) +export class AppComponent { + title = 'app'; +} diff --git a/aio/content/examples/testing/src/app/app-routing.module.ts b/aio/content/examples/testing/src/app/app-routing.module.ts index 6096a513df..f9fd0bdc83 100644 --- a/aio/content/examples/testing/src/app/app-routing.module.ts +++ b/aio/content/examples/testing/src/app/app-routing.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { AboutComponent } from './about.component'; +import { AboutComponent } from './about/about.component'; @NgModule({ imports: [ diff --git a/aio/content/examples/testing/src/app/app.component.html b/aio/content/examples/testing/src/app/app.component.html index 232bcebb6d..d73c1162c0 100644 --- a/aio/content/examples/testing/src/app/app.component.html +++ b/aio/content/examples/testing/src/app/app.component.html @@ -1,11 +1,11 @@ - + - + diff --git a/aio/content/examples/testing/src/app/app.component.router.spec.ts b/aio/content/examples/testing/src/app/app.component.router.spec.ts index 46bd1abce6..4c4ba01579 100644 --- a/aio/content/examples/testing/src/app/app.component.router.spec.ts +++ b/aio/content/examples/testing/src/app/app.component.router.spec.ts @@ -4,11 +4,11 @@ import { async, ComponentFixture, fakeAsync, TestBed, tick, } from '@angular/core/testing'; +import { asyncData } from '../testing'; + import { RouterTestingModule } from '@angular/router/testing'; import { SpyLocation } from '@angular/common/testing'; -import { click } from '../testing'; - // r - for relatively obscure router symbols import * as r from '@angular/router'; import { Router, RouterLinkWithHref } from '@angular/router'; @@ -17,11 +17,15 @@ import { By } from '@angular/platform-browser'; import { DebugElement, Type } from '@angular/core'; import { Location } from '@angular/common'; +import { click } from '../testing'; + import { AppModule } from './app.module'; import { AppComponent } from './app.component'; -import { AboutComponent } from './about.component'; +import { AboutComponent } from './about/about.component'; import { DashboardComponent } from './dashboard/dashboard.component'; -import { TwainService } from './shared/twain.service'; +import { TwainService } from './twain/twain.service'; + +import { HeroService, TestHeroService } from './model/testing/test-hero.service'; let comp: AppComponent; let fixture: ComponentFixture; @@ -31,15 +35,19 @@ let location: SpyLocation; describe('AppComponent & RouterTestingModule', () => { - beforeEach( async(() => { + beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [ AppModule, RouterTestingModule ] + imports: [ AppModule, RouterTestingModule ], + providers: [ + { provide: HeroService, useClass: TestHeroService } + ] }) .compileComponents(); })); it('should navigate to "Dashboard" immediately', fakeAsync(() => { createComponent(); + tick(); // wait for async data to arrive expect(location.path()).toEqual('/dashboard', 'after initialNavigation()'); expectElementOf(DashboardComponent); })); @@ -64,7 +72,7 @@ describe('AppComponent & RouterTestingModule', () => { })); // Can't navigate to lazy loaded modules with this technique - xit('should navigate to "Heroes" on click', fakeAsync(() => { + xit('should navigate to "Heroes" on click (not working yet)', fakeAsync(() => { createComponent(); page.heroesLinkDe.nativeElement.click(); advance(); @@ -84,9 +92,9 @@ import { HeroListComponent } from './hero/hero-list.component'; let loader: SpyNgModuleFactoryLoader; ///////// Can't get lazy loaded Heroes to work yet -xdescribe('AppComponent & Lazy Loading', () => { +xdescribe('AppComponent & Lazy Loading (not working yet)', () => { - beforeEach( async(() => { + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ AppModule, RouterTestingModule ] }) @@ -95,14 +103,11 @@ xdescribe('AppComponent & Lazy Loading', () => { beforeEach(fakeAsync(() => { createComponent(); - loader = TestBed.get(NgModuleFactoryLoader); - loader.stubbedModules = {expected: HeroModule}; + loader = TestBed.get(NgModuleFactoryLoader); + loader.stubbedModules = { expected: HeroModule }; router.resetConfig([{path: 'heroes', loadChildren: 'expected'}]); })); - it('dummy', () => expect(true).toBe(true) ); - - it('should navigate to "Heroes" on click', async(() => { page.heroesLinkDe.nativeElement.click(); advance(); @@ -110,25 +115,24 @@ xdescribe('AppComponent & Lazy Loading', () => { expectElementOf(HeroListComponent); })); - xit('can navigate to "Heroes" w/ browser location URL change', fakeAsync(() => { + it('can navigate to "Heroes" w/ browser location URL change', fakeAsync(() => { location.go('/heroes'); advance(); expectPathToBe('/heroes'); expectElementOf(HeroListComponent); - - page.expectEvents([ - [r.NavigationStart, '/heroes'], [r.RoutesRecognized, '/heroes'], - [r.NavigationEnd, '/heroes'] - ]); })); }); ////// Helpers ///////// -/** Wait a tick, then detect changes */ +/** + * Advance to the routed page + * Wait a tick, then detect changes, and tick again + */ function advance(): void { - tick(); - fixture.detectChanges(); + tick(); // wait while navigating + fixture.detectChanges(); // update view + tick(); // wait for async data to arrive } function createComponent() { @@ -140,8 +144,8 @@ function createComponent() { router = injector.get(Router); router.initialNavigation(); spyOn(injector.get(TwainService), 'getQuote') - .and.returnValue(Promise.resolve('Test Quote')); // fakes it - + // fake fast async observable + .and.returnValue(asyncData('Test Quote')); advance(); page = new Page(); @@ -151,7 +155,6 @@ class Page { aboutLinkDe: DebugElement; dashboardLinkDe: DebugElement; heroesLinkDe: DebugElement; - recordedEvents: any[] = []; // for debugging comp: AppComponent; @@ -159,17 +162,7 @@ class Page { router: Router; fixture: ComponentFixture; - expectEvents(pairs: any[]) { - const events = this.recordedEvents; - expect(events.length).toEqual(pairs.length, 'actual/expected events length mismatch'); - for (let i = 0; i < events.length; ++i) { - expect((events[i].constructor).name).toBe(pairs[i][0].name, 'unexpected event name'); - expect((events[i]).url).toBe(pairs[i][1], 'unexpected event url'); - } - } - constructor() { - router.events.subscribe(e => this.recordedEvents.push(e)); const links = fixture.debugElement.queryAll(By.directive(RouterLinkWithHref)); this.aboutLinkDe = links[2]; this.dashboardLinkDe = links[0]; diff --git a/aio/content/examples/testing/src/app/app.component.spec.ts b/aio/content/examples/testing/src/app/app.component.spec.ts index 53a27af176..e759aa51a2 100644 --- a/aio/content/examples/testing/src/app/app.component.spec.ts +++ b/aio/content/examples/testing/src/app/app.component.spec.ts @@ -1,69 +1,67 @@ // #docplaster -import { async, ComponentFixture, TestBed -} from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { DebugElement } from '@angular/core'; +import { Component, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { By } from '@angular/platform-browser'; - // #docregion setup-schemas - import { NO_ERRORS_SCHEMA } from '@angular/core'; - // #enddocregion setup-schemas - // #docregion setup-stubs-w-imports - import { Component } from '@angular/core'; - // #docregion setup-schemas - import { AppComponent } from './app.component'; - // #enddocregion setup-schemas - import { BannerComponent } from './banner.component'; - import { RouterLinkStubDirective } from '../testing'; - // #docregion setup-schemas - import { RouterOutletStubComponent } from '../testing'; +import { AppComponent } from './app.component'; +import { RouterLinkDirectiveStub } from '../testing'; - // #enddocregion setup-schemas - @Component({selector: 'app-welcome', template: ''}) - class WelcomeStubComponent {} +// #docregion component-stubs +@Component({selector: 'app-banner', template: ''}) +class BannerStubComponent {} - // #enddocregion setup-stubs-w-imports +@Component({selector: 'router-outlet', template: ''}) +class RouterOutletStubComponent { } + +@Component({selector: 'app-welcome', template: ''}) +class WelcomeStubComponent {} +// #enddocregion component-stubs let comp: AppComponent; let fixture: ComponentFixture; describe('AppComponent & TestModule', () => { - // #docregion setup-stubs, setup-stubs-w-imports - beforeEach( async(() => { + beforeEach(async(() => { + // #docregion testbed-stubs TestBed.configureTestingModule({ declarations: [ AppComponent, - BannerComponent, WelcomeStubComponent, - RouterLinkStubDirective, RouterOutletStubComponent + RouterLinkDirectiveStub, + BannerStubComponent, + RouterOutletStubComponent, + WelcomeStubComponent ] }) - - .compileComponents() - .then(() => { + // #enddocregion testbed-stubs + .compileComponents().then(() => { fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); })); - // #enddocregion setup-stubs, setup-stubs-w-imports tests(); }); //////// Testing w/ NO_ERRORS_SCHEMA ////// describe('AppComponent & NO_ERRORS_SCHEMA', () => { - // #docregion setup-schemas - beforeEach( async(() => { + beforeEach(async(() => { + // #docregion no-errors-schema, mixed-setup TestBed.configureTestingModule({ - declarations: [ AppComponent, RouterLinkStubDirective ], - schemas: [ NO_ERRORS_SCHEMA ] + declarations: [ + AppComponent, + // #enddocregion no-errors-schema + BannerStubComponent, + // #docregion no-errors-schema + RouterLinkDirectiveStub + ], + schemas: [ NO_ERRORS_SCHEMA ] }) - - .compileComponents() - .then(() => { + // #enddocregion no-errors-schema, mixed-setup + .compileComponents().then(() => { fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); })); - // #enddocregion setup-schemas tests(); }); @@ -75,7 +73,7 @@ import { AppRoutingModule } from './app-routing.module'; describe('AppComponent & AppModule', () => { - beforeEach( async(() => { + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ AppModule ] @@ -88,7 +86,7 @@ describe('AppComponent & AppModule', () => { imports: [ AppRoutingModule ] }, add: { - declarations: [ RouterLinkStubDirective, RouterOutletStubComponent ] + declarations: [ RouterLinkDirectiveStub, RouterOutletStubComponent ] } }) @@ -104,40 +102,40 @@ describe('AppComponent & AppModule', () => { }); function tests() { - let links: RouterLinkStubDirective[]; + let routerLinks: RouterLinkDirectiveStub[]; let linkDes: DebugElement[]; // #docregion test-setup beforeEach(() => { - // trigger initial data binding - fixture.detectChanges(); + fixture.detectChanges(); // trigger initial data binding // find DebugElements with an attached RouterLinkStubDirective linkDes = fixture.debugElement - .queryAll(By.directive(RouterLinkStubDirective)); + .queryAll(By.directive(RouterLinkDirectiveStub)); - // get the attached link directive instances using the DebugElement injectors - links = linkDes - .map(de => de.injector.get(RouterLinkStubDirective) as RouterLinkStubDirective); + // get attached link directive instances + // using each DebugElement's injector + routerLinks = linkDes.map(de => de.injector.get(RouterLinkDirectiveStub)); }); // #enddocregion test-setup - it('can instantiate it', () => { + it('can instantiate the component', () => { expect(comp).not.toBeNull(); }); // #docregion tests it('can get RouterLinks from template', () => { - expect(links.length).toBe(3, 'should have 3 links'); - expect(links[0].linkParams).toBe('/dashboard', '1st link should go to Dashboard'); - expect(links[1].linkParams).toBe('/heroes', '2nd link should go to Heroes'); + expect(routerLinks.length).toBe(3, 'should have 3 routerLinks'); + expect(routerLinks[0].linkParams).toBe('/dashboard'); + expect(routerLinks[1].linkParams).toBe('/heroes'); + expect(routerLinks[2].linkParams).toBe('/about'); }); it('can click Heroes link in template', () => { - const heroesLinkDe = linkDes[1]; - const heroesLink = links[1]; + const heroesLinkDe = linkDes[1]; // heroes link DebugElement + const heroesLink = routerLinks[1]; // heroes link directive - expect(heroesLink.navigatedTo).toBeNull('link should not have navigated yet'); + expect(heroesLink.navigatedTo).toBeNull('should not have navigated yet'); heroesLinkDe.triggerEventHandler('click', null); fixture.detectChanges(); diff --git a/aio/content/examples/testing/src/app/app.module.ts b/aio/content/examples/testing/src/app/app.module.ts index 7ec766e323..aecf6ea5e2 100644 --- a/aio/content/examples/testing/src/app/app.module.ts +++ b/aio/content/examples/testing/src/app/app.module.ts @@ -1,29 +1,50 @@ import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; +import { HttpClientModule } from '@angular/common/http'; import { AppComponent } from './app.component'; import { AppRoutingModule } from './app-routing.module'; -import { AboutComponent } from './about.component'; -import { BannerComponent } from './banner.component'; +import { AboutComponent } from './about/about.component'; +import { BannerComponent } from './banner/banner.component'; +import { HeroService } from './model/hero.service'; import { UserService } from './model/user.service'; -import { HeroService } from './model/hero.service'; -import { TwainService } from './shared/twain.service'; -import { WelcomeComponent } from './welcome.component'; - +import { TwainComponent } from './twain/twain.component'; +import { TwainService } from './twain/twain.service'; +import { WelcomeComponent } from './welcome/welcome.component'; import { DashboardModule } from './dashboard/dashboard.module'; import { SharedModule } from './shared/shared.module'; +import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api'; +import { InMemoryDataService } from './in-memory-data.service'; + @NgModule({ imports: [ BrowserModule, DashboardModule, AppRoutingModule, - SharedModule + SharedModule, + HttpClientModule, + + // The HttpClientInMemoryWebApiModule module intercepts HTTP requests + // and returns simulated server responses. + // Remove it when a real server is ready to receive requests. + HttpClientInMemoryWebApiModule.forRoot( + InMemoryDataService, { dataEncapsulation: false } + ) ], - providers: [ HeroService, TwainService, UserService ], - declarations: [ AppComponent, AboutComponent, BannerComponent, WelcomeComponent ], - bootstrap: [ AppComponent ] + providers: [ + HeroService, + TwainService, + UserService + ], + declarations: [ + AppComponent, + AboutComponent, + BannerComponent, + TwainComponent, + WelcomeComponent ], + bootstrap: [ AppComponent ] }) export class AppModule { } diff --git a/aio/content/examples/testing/src/app/bag/bag.no-testbed.spec.ts b/aio/content/examples/testing/src/app/bag/bag.no-testbed.spec.ts deleted file mode 100644 index 4cb60a8d50..0000000000 --- a/aio/content/examples/testing/src/app/bag/bag.no-testbed.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -// #docplaster -import { DependentService, FancyService } from './bag'; - -///////// Fakes ///////// -export class FakeFancyService extends FancyService { - value = 'faked value'; -} -//////////////////////// -// #docregion FancyService -// Straight Jasmine - no imports from Angular test libraries - -describe('FancyService without the TestBed', () => { - let service: FancyService; - - beforeEach(() => { service = new FancyService(); }); - - it('#getValue should return real value', () => { - expect(service.getValue()).toBe('real value'); - }); - - it('#getAsyncValue should return async value', (done: DoneFn) => { - service.getAsyncValue().then(value => { - expect(value).toBe('async value'); - done(); - }); - }); - - // #docregion getTimeoutValue - it('#getTimeoutValue should return timeout value', (done: DoneFn) => { - service = new FancyService(); - service.getTimeoutValue().then(value => { - expect(value).toBe('timeout value'); - done(); - }); - }); - // #enddocregion getTimeoutValue - - it('#getObservableValue should return observable value', (done: DoneFn) => { - service.getObservableValue().subscribe(value => { - expect(value).toBe('observable value'); - done(); - }); - }); - -}); -// #enddocregion FancyService - -// DependentService requires injection of a FancyService -// #docregion DependentService -describe('DependentService without the TestBed', () => { - let service: DependentService; - - it('#getValue should return real value by way of the real FancyService', () => { - service = new DependentService(new FancyService()); - expect(service.getValue()).toBe('real value'); - }); - - it('#getValue should return faked value by way of a fakeService', () => { - service = new DependentService(new FakeFancyService()); - expect(service.getValue()).toBe('faked value'); - }); - - it('#getValue should return faked value from a fake object', () => { - const fake = { getValue: () => 'fake value' }; - service = new DependentService(fake as FancyService); - expect(service.getValue()).toBe('fake value'); - }); - - it('#getValue should return stubbed value from a FancyService spy', () => { - const fancy = new FancyService(); - const stubValue = 'stub value'; - const spy = spyOn(fancy, 'getValue').and.returnValue(stubValue); - service = new DependentService(fancy); - - expect(service.getValue()).toBe(stubValue, 'service returned stub value'); - expect(spy.calls.count()).toBe(1, 'stubbed method was called once'); - expect(spy.calls.mostRecent().returnValue).toBe(stubValue); - }); -}); -// #enddocregion DependentService - -// #docregion ReversePipe -import { ReversePipe } from './bag'; - -describe('ReversePipe', () => { - let pipe: ReversePipe; - - beforeEach(() => { pipe = new ReversePipe(); }); - - it('transforms "abc" to "cba"', () => { - expect(pipe.transform('abc')).toBe('cba'); - }); - - it('no change to palindrome: "able was I ere I saw elba"', () => { - const palindrome = 'able was I ere I saw elba'; - expect(pipe.transform(palindrome)).toBe(palindrome); - }); - -}); -// #enddocregion ReversePipe - - -import { ButtonComponent } from './bag'; -// #docregion ButtonComp -describe('ButtonComp', () => { - let comp: ButtonComponent; - beforeEach(() => comp = new ButtonComponent()); - - it('#isOn should be false initially', () => { - expect(comp.isOn).toBe(false); - }); - - it('#clicked() should set #isOn to true', () => { - comp.clicked(); - expect(comp.isOn).toBe(true); - }); - - it('#clicked() should set #message to "is on"', () => { - comp.clicked(); - expect(comp.message).toMatch(/is on/i); - }); - - it('#clicked() should toggle #isOn', () => { - comp.clicked(); - expect(comp.isOn).toBe(true); - comp.clicked(); - expect(comp.isOn).toBe(false); - }); -}); -// #enddocregion ButtonComp diff --git a/aio/content/examples/testing/src/app/bag/bag.spec.ts b/aio/content/examples/testing/src/app/bag/bag.spec.ts deleted file mode 100644 index 7b3a20cf95..0000000000 --- a/aio/content/examples/testing/src/app/bag/bag.spec.ts +++ /dev/null @@ -1,681 +0,0 @@ -// #docplaster -import { - BagModule, - BankAccountComponent, BankAccountParentComponent, - ButtonComponent, - Child1Component, Child2Component, Child3Component, - FancyService, - ExternalTemplateComponent, - InputComponent, - IoComponent, IoParentComponent, - MyIfComponent, MyIfChildComponent, MyIfParentComponent, - NeedsContentComponent, ParentComponent, - TestProvidersComponent, TestViewProvidersComponent, - ReversePipeComponent, ShellComponent -} from './bag'; - -import { By } from '@angular/platform-browser'; -import { Component, - DebugElement, - Injectable } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -// Forms symbols imported only for a specific test below -import { NgModel, NgControl } from '@angular/forms'; - -import { async, ComponentFixture, fakeAsync, inject, TestBed, tick -} from '@angular/core/testing'; - -import { addMatchers, newEvent, click } from '../../testing'; - -beforeEach( addMatchers ); - -//////// Service Tests ///////////// -// #docregion FancyService -describe('use inject helper in beforeEach', () => { - let service: FancyService; - - beforeEach(() => { - TestBed.configureTestingModule({ providers: [FancyService] }); - - // `TestBed.get` returns the injectable or an - // alternative object (including null) if the service provider is not found. - // Of course it will be found in this case because we're providing it. - // #docregion testbed-get - service = TestBed.get(FancyService, null); - // #enddocregion testbed-get - }); - - it('should use FancyService', () => { - expect(service.getValue()).toBe('real value'); - }); - - it('should use FancyService', () => { - expect(service.getValue()).toBe('real value'); - }); - - it('test should wait for FancyService.getAsyncValue', async(() => { - service.getAsyncValue().then( - value => expect(value).toBe('async value') - ); - })); - - it('test should wait for FancyService.getTimeoutValue', async(() => { - service.getTimeoutValue().then( - value => expect(value).toBe('timeout value') - ); - })); - - it('test should wait for FancyService.getObservableValue', async(() => { - service.getObservableValue().subscribe( - value => expect(value).toBe('observable value') - ); - })); - - // Must use done. See https://github.com/angular/angular/issues/10127 - it('test should wait for FancyService.getObservableDelayValue', (done: DoneFn) => { - service.getObservableDelayValue().subscribe(value => { - expect(value).toBe('observable delay value'); - done(); - }); - }); - - it('should allow the use of fakeAsync', fakeAsync(() => { - let value: any; - service.getAsyncValue().then((val: any) => value = val); - tick(); // Trigger JS engine cycle until all promises resolve. - expect(value).toBe('async value'); - })); -}); -// #enddocregion FancyService - -describe('use inject within `it`', () => { - // #docregion getTimeoutValue - beforeEach(() => { - TestBed.configureTestingModule({ providers: [FancyService] }); - }); - - // #enddocregion getTimeoutValue - - it('should use modified providers', - inject([FancyService], (service: FancyService) => { - service.setValue('value modified in beforeEach'); - expect(service.getValue()).toBe('value modified in beforeEach'); - }) - ); - - // #docregion getTimeoutValue - it('test should wait for FancyService.getTimeoutValue', - async(inject([FancyService], (service: FancyService) => { - - service.getTimeoutValue().then( - value => expect(value).toBe('timeout value') - ); - }))); - // #enddocregion getTimeoutValue -}); - -describe('using async(inject) within beforeEach', () => { - let serviceValue: string; - - beforeEach(() => { - TestBed.configureTestingModule({ providers: [FancyService] }); - }); - - beforeEach( async(inject([FancyService], (service: FancyService) => { - service.getAsyncValue().then(value => serviceValue = value); - }))); - - it('should use asynchronously modified value ... in synchronous test', () => { - expect(serviceValue).toBe('async value'); - }); -}); - - -/////////// Component Tests ////////////////// - -describe('TestBed Component Tests', () => { - - beforeEach( async(() => { - TestBed - .configureTestingModule({ - imports: [BagModule], - }) - // Compile everything in BagModule - .compileComponents(); - })); - - it('should create a component with inline template', () => { - const fixture = TestBed.createComponent(Child1Component); - fixture.detectChanges(); - - expect(fixture).toHaveText('Child'); - }); - - it('should create a component with external template', () => { - const fixture = TestBed.createComponent(ExternalTemplateComponent); - fixture.detectChanges(); - - expect(fixture).toHaveText('from external template'); - }); - - it('should allow changing members of the component', () => { - const fixture = TestBed.createComponent(MyIfComponent); - - fixture.detectChanges(); - expect(fixture).toHaveText('MyIf()'); - - fixture.componentInstance.showMore = true; - fixture.detectChanges(); - expect(fixture).toHaveText('MyIf(More)'); - }); - - it('should create a nested component bound to inputs/outputs', () => { - const fixture = TestBed.createComponent(IoParentComponent); - - fixture.detectChanges(); - const heroes = fixture.debugElement.queryAll(By.css('.hero')); - expect(heroes.length).toBeGreaterThan(0, 'has heroes'); - - const comp = fixture.componentInstance; - const hero = comp.heroes[0]; - - click(heroes[0]); - fixture.detectChanges(); - - const selected = fixture.debugElement.query(By.css('p')); - expect(selected).toHaveText(hero.name); - }); - - it('can access the instance variable of an `*ngFor` row component', () => { - const fixture = TestBed.createComponent(IoParentComponent); - const comp = fixture.componentInstance; - const heroName = comp.heroes[0].name; // first hero's name - - fixture.detectChanges(); - const ngForRow = fixture.debugElement.query(By.directive(IoComponent)); // first hero ngForRow - - const hero = ngForRow.context['hero']; // the hero object passed into the row - expect(hero.name).toBe(heroName, 'ngRow.context.hero'); - - const rowComp = ngForRow.componentInstance; - // jasmine.any is an "instance-of-type" test. - expect(rowComp).toEqual(jasmine.any(IoComponent), 'component is IoComp'); - expect(rowComp.hero.name).toBe(heroName, 'component.hero'); - }); - - - // #docregion ButtonComp - it('should support clicking a button', () => { - const fixture = TestBed.createComponent(ButtonComponent); - const btn = fixture.debugElement.query(By.css('button')); - const span = fixture.debugElement.query(By.css('span')).nativeElement; - - fixture.detectChanges(); - expect(span.textContent).toMatch(/is off/i, 'before click'); - - click(btn); - fixture.detectChanges(); - expect(span.textContent).toMatch(/is on/i, 'after click'); - }); - // #enddocregion ButtonComp - - // ngModel is async so we must wait for it with promise-based `whenStable` - it('should support entering text in input box (ngModel)', async(() => { - const expectedOrigName = 'John'; - const expectedNewName = 'Sally'; - - const fixture = TestBed.createComponent(InputComponent); - fixture.detectChanges(); - - const comp = fixture.componentInstance; - const input = fixture.debugElement.query(By.css('input')).nativeElement; - - expect(comp.name).toBe(expectedOrigName, - `At start name should be ${expectedOrigName} `); - - // wait until ngModel binds comp.name to input box - fixture.whenStable().then(() => { - expect(input.value).toBe(expectedOrigName, - `After ngModel updates input box, input.value should be ${expectedOrigName} `); - - // simulate user entering new name in input - input.value = expectedNewName; - - // that change doesn't flow to the component immediately - expect(comp.name).toBe(expectedOrigName, - `comp.name should still be ${expectedOrigName} after value change, before binding happens`); - - // dispatch a DOM event so that Angular learns of input value change. - // then wait while ngModel pushes input.box value to comp.name - input.dispatchEvent(newEvent('input')); - return fixture.whenStable(); - }) - .then(() => { - expect(comp.name).toBe(expectedNewName, - `After ngModel updates the model, comp.name should be ${expectedNewName} `); - }); - })); - - // fakeAsync version of ngModel input test enables sync test style - // synchronous `tick` replaces asynchronous promise-base `whenStable` - it('should support entering text in input box (ngModel) - fakeAsync', fakeAsync(() => { - const expectedOrigName = 'John'; - const expectedNewName = 'Sally'; - - const fixture = TestBed.createComponent(InputComponent); - fixture.detectChanges(); - - const comp = fixture.componentInstance; - const input = fixture.debugElement.query(By.css('input')).nativeElement; - - expect(comp.name).toBe(expectedOrigName, - `At start name should be ${expectedOrigName} `); - - // wait until ngModel binds comp.name to input box - tick(); - expect(input.value).toBe(expectedOrigName, - `After ngModel updates input box, input.value should be ${expectedOrigName} `); - - // simulate user entering new name in input - input.value = expectedNewName; - - // that change doesn't flow to the component immediately - expect(comp.name).toBe(expectedOrigName, - `comp.name should still be ${expectedOrigName} after value change, before binding happens`); - - // dispatch a DOM event so that Angular learns of input value change. - // then wait a tick while ngModel pushes input.box value to comp.name - input.dispatchEvent(newEvent('input')); - tick(); - expect(comp.name).toBe(expectedNewName, - `After ngModel updates the model, comp.name should be ${expectedNewName} `); - })); - - // #docregion ReversePipeComp - it('ReversePipeComp should reverse the input text', fakeAsync(() => { - const inputText = 'the quick brown fox.'; - const expectedText = '.xof nworb kciuq eht'; - - const fixture = TestBed.createComponent(ReversePipeComponent); - fixture.detectChanges(); - - const comp = fixture.componentInstance; - const input = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement; - const span = fixture.debugElement.query(By.css('span')).nativeElement as HTMLElement; - - // simulate user entering new name in input - input.value = inputText; - - // dispatch a DOM event so that Angular learns of input value change. - // then wait a tick while ngModel pushes input.box value to comp.text - // and Angular updates the output span - input.dispatchEvent(newEvent('input')); - tick(); - fixture.detectChanges(); - expect(span.textContent).toBe(expectedText, 'output span'); - expect(comp.text).toBe(inputText, 'component.text'); - })); - // #enddocregion ReversePipeComp - - // Use this technique to find attached directives of any kind - it('can examine attached directives and listeners', () => { - const fixture = TestBed.createComponent(InputComponent); - fixture.detectChanges(); - - const inputEl = fixture.debugElement.query(By.css('input')); - - expect(inputEl.providerTokens).toContain(NgModel, 'NgModel directive'); - - const ngControl = inputEl.injector.get(NgControl); - expect(ngControl).toEqual(jasmine.any(NgControl), 'NgControl directive'); - - expect(inputEl.listeners.length).toBeGreaterThan(2, 'several listeners attached'); - }); - - // #docregion dom-attributes - it('BankAccountComponent should set attributes, styles, classes, and properties', () => { - const fixture = TestBed.createComponent(BankAccountParentComponent); - fixture.detectChanges(); - const comp = fixture.componentInstance; - - // the only child is debugElement of the BankAccount component - const el = fixture.debugElement.children[0]; - const childComp = el.componentInstance as BankAccountComponent; - expect(childComp).toEqual(jasmine.any(BankAccountComponent)); - - expect(el.context).toBe(childComp, 'context is the child component'); - - expect(el.attributes['account']).toBe(childComp.id, 'account attribute'); - expect(el.attributes['bank']).toBe(childComp.bank, 'bank attribute'); - - expect(el.classes['closed']).toBe(true, 'closed class'); - expect(el.classes['open']).toBe(false, 'open class'); - - expect(el.styles['color']).toBe(comp.color, 'color style'); - expect(el.styles['width']).toBe(comp.width + 'px', 'width style'); - // #enddocregion dom-attributes - - // Removed on 12/02/2016 when ceased public discussion of the `Renderer`. Revive in future? - // expect(el.properties['customProperty']).toBe(true, 'customProperty'); - - // #docregion dom-attributes - }); - // #enddocregion dom-attributes - - -}); - -describe('TestBed Component Overrides:', () => { - - it('should override ChildComp\'s template', () => { - - const fixture = TestBed.configureTestingModule({ - declarations: [Child1Component], - }) - .overrideComponent(Child1Component, { - set: { template: 'Fake' } - }) - .createComponent(Child1Component); - - fixture.detectChanges(); - expect(fixture).toHaveText('Fake'); - }); - - it('should override TestProvidersComp\'s FancyService provider', () => { - const fixture = TestBed.configureTestingModule({ - declarations: [TestProvidersComponent], - }) - .overrideComponent(TestProvidersComponent, { - remove: { providers: [FancyService]}, - add: { providers: [{ provide: FancyService, useClass: FakeFancyService }] }, - - // Or replace them all (this component has only one provider) - // set: { providers: [{ provide: FancyService, useClass: FakeFancyService }] }, - }) - .createComponent(TestProvidersComponent); - - fixture.detectChanges(); - expect(fixture).toHaveText('injected value: faked value', 'text'); - - // Explore the providerTokens - const tokens = fixture.debugElement.providerTokens; - expect(tokens).toContain(fixture.componentInstance.constructor, 'component ctor'); - expect(tokens).toContain(TestProvidersComponent, 'TestProvidersComp'); - expect(tokens).toContain(FancyService, 'FancyService'); - }); - - it('should override TestViewProvidersComp\'s FancyService viewProvider', () => { - const fixture = TestBed.configureTestingModule({ - declarations: [TestViewProvidersComponent], - }) - .overrideComponent(TestViewProvidersComponent, { - // remove: { viewProviders: [FancyService]}, - // add: { viewProviders: [{ provide: FancyService, useClass: FakeFancyService }] }, - - // Or replace them all (this component has only one viewProvider) - set: { viewProviders: [{ provide: FancyService, useClass: FakeFancyService }] }, - }) - .createComponent(TestViewProvidersComponent); - - fixture.detectChanges(); - expect(fixture).toHaveText('injected value: faked value'); - }); - - it('injected provider should not be same as component\'s provider', () => { - - // TestComponent is parent of TestProvidersComponent - @Component({ template: '' }) - class TestComponent {} - - // 3 levels of FancyService provider: module, TestCompomponent, TestProvidersComponent - const fixture = TestBed.configureTestingModule({ - declarations: [TestComponent, TestProvidersComponent], - providers: [FancyService] - }) - .overrideComponent(TestComponent, { - set: { providers: [{ provide: FancyService, useValue: {} }] } - }) - .overrideComponent(TestProvidersComponent, { - set: { providers: [{ provide: FancyService, useClass: FakeFancyService }] } - }) - .createComponent(TestComponent); - - let testBedProvider: FancyService; - let tcProvider: {}; - let tpcProvider: FakeFancyService; - - // `inject` uses TestBed's injector - inject([FancyService], (s: FancyService) => testBedProvider = s)(); - tcProvider = fixture.debugElement.injector.get(FancyService); - tpcProvider = fixture.debugElement.children[0].injector.get(FancyService) as FakeFancyService; - - expect(testBedProvider).not.toBe( tcProvider, 'testBed/tc not same providers'); - expect(testBedProvider).not.toBe(tpcProvider, 'testBed/tpc not same providers'); - - expect(testBedProvider instanceof FancyService).toBe(true, 'testBedProvider is FancyService'); - expect(tcProvider).toEqual({}, 'tcProvider is {}'); - expect(tpcProvider instanceof FakeFancyService).toBe(true, 'tpcProvider is FakeFancyService'); - }); - - it('can access template local variables as references', () => { - const fixture = TestBed.configureTestingModule({ - declarations: [ShellComponent, NeedsContentComponent, Child1Component, Child2Component, Child3Component], - }) - .overrideComponent(ShellComponent, { - set: { - selector: 'test-shell', - template: ` - - - - - -
!
-
- ` - } - }) - .createComponent(ShellComponent); - - fixture.detectChanges(); - - // NeedsContentComp is the child of ShellComp - const el = fixture.debugElement.children[0]; - const comp = el.componentInstance; - - expect(comp.children.toArray().length).toBe(4, - 'three different child components and an ElementRef with #content'); - - expect(el.references['nc']).toBe(comp, '#nc reference to component'); - - // #docregion custom-predicate - // Filter for DebugElements with a #content reference - const contentRefs = el.queryAll( de => de.references['content']); - // #enddocregion custom-predicate - expect(contentRefs.length).toBe(4, 'elements w/ a #content reference'); - }); - -}); - -describe('Nested (one-deep) component override', () => { - - beforeEach( async(() => { - TestBed.configureTestingModule({ - declarations: [ParentComponent, FakeChildComponent] - }) - .compileComponents(); - })); - - it('ParentComp should use Fake Child component', () => { - const fixture = TestBed.createComponent(ParentComponent); - fixture.detectChanges(); - expect(fixture).toHaveText('Parent(Fake Child)'); - }); -}); - -describe('Nested (two-deep) component override', () => { - - beforeEach( async(() => { - TestBed.configureTestingModule({ - declarations: [ParentComponent, FakeChildWithGrandchildComponent, FakeGrandchildComponent] - }) - .compileComponents(); - })); - - it('should use Fake Grandchild component', () => { - const fixture = TestBed.createComponent(ParentComponent); - fixture.detectChanges(); - expect(fixture).toHaveText('Parent(Fake Child(Fake Grandchild))'); - }); -}); - -describe('Lifecycle hooks w/ MyIfParentComp', () => { - let fixture: ComponentFixture; - let parent: MyIfParentComponent; - let child: MyIfChildComponent; - - beforeEach( async(() => { - TestBed.configureTestingModule({ - imports: [FormsModule], - declarations: [MyIfChildComponent, MyIfParentComponent] - }) - .compileComponents().then(() => { - fixture = TestBed.createComponent(MyIfParentComponent); - parent = fixture.componentInstance; - }); - })); - - it('should instantiate parent component', () => { - expect(parent).not.toBeNull('parent component should exist'); - }); - - it('parent component OnInit should NOT be called before first detectChanges()', () => { - expect(parent.ngOnInitCalled).toBe(false); - }); - - it('parent component OnInit should be called after first detectChanges()', () => { - fixture.detectChanges(); - expect(parent.ngOnInitCalled).toBe(true); - }); - - it('child component should exist after OnInit', () => { - fixture.detectChanges(); - getChild(); - expect(child instanceof MyIfChildComponent).toBe(true, 'should create child'); - }); - - it('should have called child component\'s OnInit ', () => { - fixture.detectChanges(); - getChild(); - expect(child.ngOnInitCalled).toBe(true); - }); - - it('child component called OnChanges once', () => { - fixture.detectChanges(); - getChild(); - expect(child.ngOnChangesCounter).toBe(1); - }); - - it('changed parent value flows to child', () => { - fixture.detectChanges(); - getChild(); - - parent.parentValue = 'foo'; - fixture.detectChanges(); - - expect(child.ngOnChangesCounter).toBe(2, - 'expected 2 changes: initial value and changed value'); - expect(child.childValue).toBe('foo', - 'childValue should eq changed parent value'); - }); - - // must be async test to see child flow to parent - it('changed child value flows to parent', async(() => { - fixture.detectChanges(); - getChild(); - - child.childValue = 'bar'; - - return new Promise(resolve => { - // Wait one JS engine turn! - setTimeout(() => resolve(), 0); - }) - .then(() => { - fixture.detectChanges(); - - expect(child.ngOnChangesCounter).toBe(2, - 'expected 2 changes: initial value and changed value'); - expect(parent.parentValue).toBe('bar', - 'parentValue should eq changed parent value'); - }); - - })); - - it('clicking "Close Child" triggers child OnDestroy', () => { - fixture.detectChanges(); - getChild(); - - const btn = fixture.debugElement.query(By.css('button')); - click(btn); - - fixture.detectChanges(); - expect(child.ngOnDestroyCalled).toBe(true); - }); - - ////// helpers /// - /** - * Get the MyIfChildComp from parent; fail w/ good message if cannot. - */ - function getChild() { - - let childDe: DebugElement; // DebugElement that should hold the MyIfChildComp - - // The Hard Way: requires detailed knowledge of the parent template - try { - childDe = fixture.debugElement.children[4].children[0]; - } catch (err) { /* we'll report the error */ } - - // DebugElement.queryAll: if we wanted all of many instances: - childDe = fixture.debugElement - .queryAll(function (de) { return de.componentInstance instanceof MyIfChildComponent; })[0]; - - // WE'LL USE THIS APPROACH ! - // DebugElement.query: find first instance (if any) - childDe = fixture.debugElement - .query(function (de) { return de.componentInstance instanceof MyIfChildComponent; }); - - if (childDe && childDe.componentInstance) { - child = childDe.componentInstance; - } else { - fail('Unable to find MyIfChildComp within MyIfParentComp'); - } - - return child; - } -}); - -////////// Fakes /////////// - -@Component({ - selector: 'child-1', - template: `Fake Child` -}) -class FakeChildComponent { } - -@Component({ - selector: 'child-1', - template: `Fake Child()` -}) -class FakeChildWithGrandchildComponent { } - -@Component({ - selector: 'grandchild-1', - template: `Fake Grandchild` -}) -class FakeGrandchildComponent { } - -@Injectable() -class FakeFancyService extends FancyService { - value = 'faked value'; -} diff --git a/aio/content/examples/testing/src/app/banner-inline.component.spec.ts b/aio/content/examples/testing/src/app/banner-inline.component.spec.ts deleted file mode 100644 index 7f35d6b956..0000000000 --- a/aio/content/examples/testing/src/app/banner-inline.component.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -// #docplaster -// #docregion imports -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; - -import { BannerComponent } from './banner-inline.component'; -// #enddocregion imports - -// #docregion setup -describe('BannerComponent (inline template)', () => { - - let comp: BannerComponent; - let fixture: ComponentFixture; - let de: DebugElement; - let el: HTMLElement; - -// #docregion before-each - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ BannerComponent ], // declare the test component - }); - - fixture = TestBed.createComponent(BannerComponent); - - comp = fixture.componentInstance; // BannerComponent test instance - - // query for the title

by CSS element selector - de = fixture.debugElement.query(By.css('h1')); - el = de.nativeElement; - }); -// #enddocregion before-each -// #enddocregion setup - - // #docregion test-w-o-detect-changes - it('no title in the DOM until manually call `detectChanges`', () => { - expect(el.textContent).toEqual(''); - }); - // #enddocregion test-w-o-detect-changes - - // #docregion tests - it('should display original title', () => { - fixture.detectChanges(); - expect(el.textContent).toContain(comp.title); - }); - - it('should display a different test title', () => { - comp.title = 'Test Title'; - fixture.detectChanges(); - expect(el.textContent).toContain('Test Title'); - }); - // #enddocregion tests -// #docregion setup -}); -// #enddocregion setup diff --git a/aio/content/examples/testing/src/app/banner.component.spec.ts b/aio/content/examples/testing/src/app/banner.component.spec.ts deleted file mode 100644 index a564c45c85..0000000000 --- a/aio/content/examples/testing/src/app/banner.component.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -// #docplaster -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; - -import { BannerComponent } from './banner.component'; - -describe('BannerComponent (templateUrl)', () => { - - let comp: BannerComponent; - let fixture: ComponentFixture; - let de: DebugElement; - let el: HTMLElement; - - // #docregion async-before-each - // async beforeEach - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ BannerComponent ], // declare the test component - }) - .compileComponents(); // compile template and css - })); - // #enddocregion async-before-each - - // #docregion sync-before-each - // synchronous beforeEach - beforeEach(() => { - fixture = TestBed.createComponent(BannerComponent); - - comp = fixture.componentInstance; // BannerComponent test instance - - // query for the title

by CSS element selector - de = fixture.debugElement.query(By.css('h1')); - el = de.nativeElement; - }); - // #enddocregion sync-before-each - - it('no title in the DOM until manually call `detectChanges`', () => { - expect(el.textContent).toEqual(''); - }); - - it('should display original title', () => { - fixture.detectChanges(); - expect(el.textContent).toContain(comp.title); - }); - - it('should display a different test title', () => { - comp.title = 'Test Title'; - fixture.detectChanges(); - expect(el.textContent).toContain('Test Title'); - }); - -}); diff --git a/aio/content/examples/testing/src/app/banner.component.css b/aio/content/examples/testing/src/app/banner/banner-external.component.css similarity index 100% rename from aio/content/examples/testing/src/app/banner.component.css rename to aio/content/examples/testing/src/app/banner/banner-external.component.css diff --git a/aio/content/examples/testing/src/app/banner.component.html b/aio/content/examples/testing/src/app/banner/banner-external.component.html similarity index 100% rename from aio/content/examples/testing/src/app/banner.component.html rename to aio/content/examples/testing/src/app/banner/banner-external.component.html diff --git a/aio/content/examples/testing/src/app/banner/banner-external.component.spec.ts b/aio/content/examples/testing/src/app/banner/banner-external.component.spec.ts new file mode 100644 index 0000000000..d5fc9c8cbe --- /dev/null +++ b/aio/content/examples/testing/src/app/banner/banner-external.component.spec.ts @@ -0,0 +1,72 @@ +// #docplaster +// #docregion import-async +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +// #enddocregion import-async +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; + +import { BannerComponent } from './banner-external.component'; + +describe('BannerComponent (external files)', () => { + let component: BannerComponent; + let fixture: ComponentFixture; + let h1: HTMLElement; + + describe('Two beforeEach', () => { + // #docregion async-before-each + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ BannerComponent ], + }) + .compileComponents(); // compile template and css + })); + // #enddocregion async-before-each + + // synchronous beforeEach + // #docregion sync-before-each + beforeEach(() => { + fixture = TestBed.createComponent(BannerComponent); + component = fixture.componentInstance; // BannerComponent test instance + h1 = fixture.nativeElement.querySelector('h1'); + }); + // #enddocregion sync-before-each + + tests(); + }); + + describe('One beforeEach', () => { + // #docregion one-before-each + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ BannerComponent ], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(BannerComponent); + component = fixture.componentInstance; + h1 = fixture.nativeElement.querySelector('h1'); + }); + })); + // #enddocregion one-before-each + + 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'); + }); + } +}); + diff --git a/aio/content/examples/testing/src/app/banner-inline.component.ts b/aio/content/examples/testing/src/app/banner/banner-external.component.ts similarity index 50% rename from aio/content/examples/testing/src/app/banner-inline.component.ts rename to aio/content/examples/testing/src/app/banner/banner-external.component.ts index 7ec4ef6999..dfec36074d 100644 --- a/aio/content/examples/testing/src/app/banner-inline.component.ts +++ b/aio/content/examples/testing/src/app/banner/banner-external.component.ts @@ -1,11 +1,14 @@ +// #docplaster // #docregion import { Component } from '@angular/core'; +// #docregion metadata @Component({ selector: 'app-banner', - template: '

{{title}}

' + templateUrl: './banner-external.component.html', + styleUrls: ['./banner-external.component.css'] }) +// #enddocregion metadata export class BannerComponent { title = 'Test Tour of Heroes'; } - diff --git a/aio/content/examples/testing/src/app/banner/banner-initial.component.spec.ts b/aio/content/examples/testing/src/app/banner/banner-initial.component.spec.ts new file mode 100644 index 0000000000..8a45df5023 --- /dev/null +++ b/aio/content/examples/testing/src/app/banner/banner-initial.component.spec.ts @@ -0,0 +1,119 @@ +// #docplaster +// #docregion import-by +import { By } from '@angular/platform-browser'; +// #enddocregion import-by +// #docregion import-debug-element +import { DebugElement } from '@angular/core'; +// #enddocregion import-debug-element +// #docregion v1 +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +// #enddocregion v1 +import { BannerComponent } from './banner-initial.component'; +/* +// #docregion v1 +import { BannerComponent } from './banner.component'; + +describe('BannerComponent', () => { +// #enddocregion v1 +*/ +describe('BannerComponent (initial CLI generated)', () => { +// #docregion v1 + let component: BannerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ BannerComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BannerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeDefined(); + }); +}); +// #enddocregion v1 + +// #docregion v2 +describe('BannerComponent (minimal)', () => { + it('should create', () => { + // #docregion configureTestingModule + TestBed.configureTestingModule({ + declarations: [ BannerComponent ] + }); + // #enddocregion configureTestingModule + // #docregion createComponent + const fixture = TestBed.createComponent(BannerComponent); + // #enddocregion createComponent + // #docregion componentInstance + const component = fixture.componentInstance; + expect(component).toBeDefined(); + // #enddocregion componentInstance + }); +}); +// #enddocregion v2 + +// #docregion v3, v4 +describe('BannerComponent (with beforeEach)', () => { + let component: BannerComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ BannerComponent ] + }); + fixture = TestBed.createComponent(BannerComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeDefined(); + }); +// #enddocregion v3 + +// #docregion v4-test-2 + it('should contain "banner works!"', () => { + const bannerElement: HTMLElement = fixture.nativeElement; + expect(bannerElement.textContent).toContain('banner works!'); + }); +// #enddocregion v4-test-2 + +// #docregion v4-test-3 + it('should have

with "banner works!"', () => { + // #docregion nativeElement + const bannerElement: HTMLElement = fixture.nativeElement; + // #enddocregion nativeElement + const p = bannerElement.querySelector('p'); + expect(p.textContent).toEqual('banner works!'); + }); +// #enddocregion v4-test-3 + + +// #docregion v4-test-4 +it('should find the

with fixture.debugElement.nativeElement)', () => { + // #docregion debugElement-nativeElement + const bannerDe: DebugElement = fixture.debugElement; + const bannerEl: HTMLElement = bannerDe.nativeElement; + // #enddocregion debugElement-nativeElement + const p = bannerEl.querySelector('p'); + expect(p.textContent).toEqual('banner works!'); +}); +// #enddocregion v4-test-4 + +// #docregion v4-test-5 +it('should find the

with fixture.debugElement.query(By.css)', () => { + const bannerDe: DebugElement = fixture.debugElement; + const paragraphDe = bannerDe.query(By.css('p')); + const p: HTMLElement = paragraphDe.nativeElement; + expect(p.textContent).toEqual('banner works!'); +}); +// #enddocregion v4-test-5 +// #docregion v3 +}); +// #enddocregion v3, v4 diff --git a/aio/content/examples/testing/src/app/banner/banner-initial.component.ts b/aio/content/examples/testing/src/app/banner/banner-initial.component.ts new file mode 100644 index 0000000000..a7959e5573 --- /dev/null +++ b/aio/content/examples/testing/src/app/banner/banner-initial.component.ts @@ -0,0 +1,10 @@ +// BannerComponent as initially generated by the CLI +// #docregion +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-banner', + template: `

banner works!

`, + styles: [] +}) +export class BannerComponent { } diff --git a/aio/content/examples/testing/src/app/banner.component.detect-changes.spec.ts b/aio/content/examples/testing/src/app/banner/banner.component.detect-changes.spec.ts similarity index 76% rename from aio/content/examples/testing/src/app/banner.component.detect-changes.spec.ts rename to aio/content/examples/testing/src/app/banner/banner.component.detect-changes.spec.ts index 412f5be586..3310fbdad0 100644 --- a/aio/content/examples/testing/src/app/banner.component.detect-changes.spec.ts +++ b/aio/content/examples/testing/src/app/banner/banner.component.detect-changes.spec.ts @@ -7,53 +7,45 @@ import { async } from '@angular/core/testing'; import { ComponentFixtureAutoDetect } from '@angular/core/testing'; // #enddocregion import-ComponentFixtureAutoDetect import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; import { BannerComponent } from './banner.component'; describe('BannerComponent (AutoChangeDetect)', () => { let comp: BannerComponent; let fixture: ComponentFixture; - let de: DebugElement; - let el: HTMLElement; + let h1: HTMLElement; - beforeEach(async(() => { + beforeEach(() => { // #docregion auto-detect TestBed.configureTestingModule({ declarations: [ BannerComponent ], providers: [ { provide: ComponentFixtureAutoDetect, useValue: true } ] - }) + }); // #enddocregion auto-detect - .compileComponents(); - })); - - beforeEach(() => { fixture = TestBed.createComponent(BannerComponent); comp = fixture.componentInstance; - de = fixture.debugElement.query(By.css('h1')); - el = de.nativeElement; + h1 = fixture.nativeElement.querySelector('h1'); }); // #docregion auto-detect-tests it('should display original title', () => { // Hooray! No `fixture.detectChanges()` needed - expect(el.textContent).toContain(comp.title); + expect(h1.textContent).toContain(comp.title); }); it('should still see original title after comp.title change', () => { const oldTitle = comp.title; comp.title = 'Test Title'; // Displayed title is old because Angular didn't hear the change :( - expect(el.textContent).toContain(oldTitle); + expect(h1.textContent).toContain(oldTitle); }); it('should display updated title after detectChanges', () => { comp.title = 'Test Title'; fixture.detectChanges(); // detect changes explicitly - expect(el.textContent).toContain(comp.title); + expect(h1.textContent).toContain(comp.title); }); // #enddocregion auto-detect-tests }); diff --git a/aio/content/examples/testing/src/app/banner/banner.component.spec.ts b/aio/content/examples/testing/src/app/banner/banner.component.spec.ts new file mode 100644 index 0000000000..a731debbd1 --- /dev/null +++ b/aio/content/examples/testing/src/app/banner/banner.component.spec.ts @@ -0,0 +1,56 @@ +// #docplaster +// #docregion +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DebugElement } from '@angular/core'; + +import { BannerComponent } from './banner.component'; + +describe('BannerComponent (inline template)', () => { +// #docregion setup + let component: BannerComponent; + let fixture: ComponentFixture; + let h1: HTMLElement; + + // #docregion configure-and-create + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ BannerComponent ], + }); + fixture = TestBed.createComponent(BannerComponent); + // #enddocregion configure-and-create + component = fixture.componentInstance; // BannerComponent test instance + h1 = fixture.nativeElement.querySelector('h1'); + // #docregion configure-and-create + }); +// #enddocregion setup, configure-and-create + +// #docregion test-w-o-detect-changes + it('no title in the DOM after createComponent()', () => { + expect(h1.textContent).toEqual(''); + }); +// #enddocregion test-w-o-detect-changes + +// #docregion expect-h1-default-v1 + it('should display original title', () => { + // #enddocregion expect-h1-default-v1 + fixture.detectChanges(); + // #docregion expect-h1-default-v1 + expect(h1.textContent).toContain(component.title); + }); + // #enddocregion expect-h1-default-v1 + +// #docregion expect-h1-default +it('should display original title after detectChanges()', () => { + fixture.detectChanges(); + expect(h1.textContent).toContain(component.title); +}); +// #enddocregion expect-h1-default + +// #docregion after-change +it('should display a different test title', () => { + component.title = 'Test Title'; + fixture.detectChanges(); + expect(h1.textContent).toContain('Test Title'); +}); +// #enddocregion after-change +}); diff --git a/aio/content/examples/testing/src/app/banner.component.ts b/aio/content/examples/testing/src/app/banner/banner.component.ts similarity index 52% rename from aio/content/examples/testing/src/app/banner.component.ts rename to aio/content/examples/testing/src/app/banner/banner.component.ts index 4355a40867..3354801d0a 100644 --- a/aio/content/examples/testing/src/app/banner.component.ts +++ b/aio/content/examples/testing/src/app/banner/banner.component.ts @@ -1,12 +1,12 @@ -// #docregion import { Component } from '@angular/core'; +// #docregion component @Component({ selector: 'app-banner', - templateUrl: './banner.component.html', - styleUrls: ['./banner.component.css'] + template: '

{{title}}

', + styles: ['h1 { color: green; font-size: 350%}'] }) export class BannerComponent { title = 'Test Tour of Heroes'; } - +// #enddocregion component diff --git a/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.html b/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.html deleted file mode 100644 index ff49bd17a5..0000000000 --- a/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.html +++ /dev/null @@ -1,4 +0,0 @@ - -
- {{hero.name | uppercase}} -
diff --git a/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.spec.ts b/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.spec.ts index 40c01571e6..84d73e45f4 100644 --- a/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.spec.ts +++ b/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.spec.ts @@ -1,7 +1,9 @@ + +// #docplaster import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; +import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; import { addMatchers, click } from '../../testing'; @@ -11,64 +13,96 @@ import { DashboardHeroComponent } from './dashboard-hero.component'; beforeEach( addMatchers ); +describe('DashboardHeroComponent class only', () => { + // #docregion class-only + it('raises the selected event when clicked', () => { + const comp = new DashboardHeroComponent(); + const hero: Hero = { id: 42, name: 'Test' }; + comp.hero = hero; + + comp.selected.subscribe(selectedHero => expect(selectedHero).toBe(hero)); + comp.click(); + }); + // #enddocregion class-only +}); + describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture; - let heroEl: DebugElement; + let heroDe: DebugElement; + let heroEl: HTMLElement; - // #docregion setup, compile-components - // async beforeEach - beforeEach( async(() => { + beforeEach(async(() => { + // #docregion setup, config-testbed TestBed.configureTestingModule({ - declarations: [ DashboardHeroComponent ], + declarations: [ DashboardHeroComponent ] }) - .compileComponents(); // compile template and css + // #enddocregion setup, config-testbed + .compileComponents(); })); - // #enddocregion compile-components - // synchronous beforeEach beforeEach(() => { + // #docregion setup fixture = TestBed.createComponent(DashboardHeroComponent); comp = fixture.componentInstance; - heroEl = fixture.debugElement.query(By.css('.hero')); // find hero element - // pretend that it was wired to something that supplied a hero - expectedHero = new Hero(42, 'Test Name'); + // find the hero's DebugElement and element + heroDe = fixture.debugElement.query(By.css('.hero')); + heroEl = heroDe.nativeElement; + + // mock the hero supplied by the parent component + expectedHero = { id: 42, name: 'Test Name' }; + + // simulate the parent setting the input property with that hero comp.hero = expectedHero; - fixture.detectChanges(); // trigger initial data binding + + // trigger initial data binding + fixture.detectChanges(); + // #enddocregion setup }); - // #enddocregion setup // #docregion name-test - it('should display hero name', () => { + it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); - expect(heroEl.nativeElement.textContent).toContain(expectedPipedName); + expect(heroEl.textContent).toContain(expectedPipedName); }); // #enddocregion name-test // #docregion click-test - it('should raise selected event when clicked', () => { + it('should raise selected event when clicked (triggerEventHandler)', () => { let selectedHero: Hero; comp.selected.subscribe((hero: Hero) => selectedHero = hero); // #docregion trigger-event-handler - heroEl.triggerEventHandler('click', null); + heroDe.triggerEventHandler('click', null); // #enddocregion trigger-event-handler expect(selectedHero).toBe(expectedHero); }); // #enddocregion click-test - // #docregion click-test-2 - it('should raise selected event when clicked', () => { - let selectedHero: Hero; - comp.selected.subscribe((hero: Hero) => selectedHero = hero); + // #docregion click-test-2 + it('should raise selected event when clicked (element.click)', () => { + let selectedHero: Hero; + comp.selected.subscribe((hero: Hero) => selectedHero = hero); + + heroEl.click(); + expect(selectedHero).toBe(expectedHero); + }); + // #enddocregion click-test-2 + + // #docregion click-test-3 + it('should raise selected event when clicked (click helper)', () => { + let selectedHero: Hero; + comp.selected.subscribe(hero => selectedHero = hero); + + click(heroDe); // click helper with DebugElement + click(heroEl); // click helper with native element - click(heroEl); // triggerEventHandler helper expect(selectedHero).toBe(expectedHero); }); - // #enddocregion click-test-2 + // #enddocregion click-test-3 }); ////////////////// @@ -76,28 +110,31 @@ describe('DashboardHeroComponent when tested directly', () => { describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture; - let heroEl: DebugElement; + let heroEl: HTMLElement; - // #docregion test-host-setup - beforeEach( async(() => { + beforeEach(async(() => { + // #docregion test-host-setup TestBed.configureTestingModule({ - declarations: [ DashboardHeroComponent, TestHostComponent ], // declare both - }).compileComponents(); + declarations: [ DashboardHeroComponent, TestHostComponent ] + }) + // #enddocregion test-host-setup + .compileComponents(); })); beforeEach(() => { + // #docregion test-host-setup // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; - heroEl = fixture.debugElement.query(By.css('.hero')); // find hero + heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding + // #enddocregion test-host-setup }); - // #enddocregion test-host-setup // #docregion test-host-tests it('should display hero name', () => { const expectedPipedName = testHost.hero.name.toUpperCase(); - expect(heroEl.nativeElement.textContent).toContain(expectedPipedName); + expect(heroEl.textContent).toContain(expectedPipedName); }); it('should raise selected event when clicked', () => { @@ -114,10 +151,12 @@ import { Component } from '@angular/core'; // #docregion test-host @Component({ template: ` - ` + + ` }) class TestHostComponent { - hero = new Hero(42, 'Test Name'); + hero: Hero = {id: 42, name: 'Test Name' }; selectedHero: Hero; onSelected(hero: Hero) { this.selectedHero = hero; } } diff --git a/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.ts b/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.ts index 3b6de56cf4..ca1ff573bf 100644 --- a/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.ts +++ b/aio/content/examples/testing/src/app/dashboard/dashboard-hero.component.ts @@ -5,13 +5,17 @@ import { Hero } from '../model/hero'; // #docregion component @Component({ - selector: 'dashboard-hero', - templateUrl: './dashboard-hero.component.html', + selector: 'dashboard-hero', + template: ` +
+ {{hero.name | uppercase}} +
`, styleUrls: [ './dashboard-hero.component.css' ] }) +// #docregion class export class DashboardHeroComponent { @Input() hero: Hero; @Output() selected = new EventEmitter(); click() { this.selected.emit(this.hero); } } -// #enddocregion component +// #enddocregion component, class diff --git a/aio/content/examples/testing/src/app/dashboard/dashboard.component.no-testbed.spec.ts b/aio/content/examples/testing/src/app/dashboard/dashboard.component.no-testbed.spec.ts index 125e5193b7..1d372bfe0e 100644 --- a/aio/content/examples/testing/src/app/dashboard/dashboard.component.no-testbed.spec.ts +++ b/aio/content/examples/testing/src/app/dashboard/dashboard.component.no-testbed.spec.ts @@ -1,24 +1,24 @@ import { Router } from '@angular/router'; import { DashboardComponent } from './dashboard.component'; -import { Hero } from '../model'; +import { Hero } from '../model/hero'; import { addMatchers } from '../../testing'; -import { FakeHeroService } from '../model/testing'; +import { TestHeroService, HeroService } from '../model/testing/test-hero.service'; class FakeRouter { navigateByUrl(url: string) { return url; } } -describe('DashboardComponent: w/o Angular TestBed', () => { +describe('DashboardComponent class only', () => { let comp: DashboardComponent; - let heroService: FakeHeroService; + let heroService: TestHeroService; let router: Router; beforeEach(() => { addMatchers(); router = new FakeRouter() as any as Router; - heroService = new FakeHeroService(); + heroService = new TestHeroService(); comp = new DashboardComponent(router, heroService); }); @@ -35,17 +35,19 @@ describe('DashboardComponent: w/o Angular TestBed', () => { it('should HAVE heroes after HeroService gets them', (done: DoneFn) => { comp.ngOnInit(); // ngOnInit -> getHeroes - heroService.lastPromise // the one from getHeroes - .then(() => { + heroService.lastResult // the one from getHeroes + .subscribe( + () => { // throw new Error('deliberate error'); // see it fail gracefully expect(comp.heroes.length).toBeGreaterThan(0, 'should have heroes after service promise resolves'); - }) - .then(done, done.fail); + done(); + }, + done.fail); }); it('should tell ROUTER to navigate by hero id', () => { - const hero = new Hero(42, 'Abbracadabra'); + const hero: Hero = {id: 42, name: 'Abbracadabra' }; const spy = spyOn(router, 'navigateByUrl'); comp.gotoDetail(hero); diff --git a/aio/content/examples/testing/src/app/dashboard/dashboard.component.spec.ts b/aio/content/examples/testing/src/app/dashboard/dashboard.component.spec.ts index 0b0f9e213a..9beef762a0 100644 --- a/aio/content/examples/testing/src/app/dashboard/dashboard.component.spec.ts +++ b/aio/content/examples/testing/src/app/dashboard/dashboard.component.spec.ts @@ -2,9 +2,9 @@ import { async, inject, ComponentFixture, TestBed } from '@angular/core/testing'; -import { addMatchers, click } from '../../testing'; -import { HeroService } from '../model'; -import { FakeHeroService } from '../model/testing'; +import { addMatchers, asyncData, click } from '../../testing'; +import { HeroService } from '../model/hero.service'; +import { getTestHeroes } from '../model/testing/test-heroes'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; @@ -12,12 +12,6 @@ import { Router } from '@angular/router'; import { DashboardComponent } from './dashboard.component'; import { DashboardModule } from './dashboard.module'; -// #docregion router-stub -class RouterStub { - navigateByUrl(url: string) { return url; } -} -// #enddocregion router-stub - beforeEach ( addMatchers ); let comp: DashboardComponent; @@ -37,8 +31,8 @@ describe('DashboardComponent (deep)', () => { tests(clickForDeep); function clickForDeep() { - // get first
DebugElement - const heroEl = fixture.debugElement.query(By.css('.hero')); + // get first
+ const heroEl: HTMLElement = fixture.nativeElement.querySelector('.hero'); click(heroEl); } }); @@ -61,24 +55,32 @@ describe('DashboardComponent (shallow)', () => { function clickForShallow() { // get first DebugElement - const heroEl = fixture.debugElement.query(By.css('dashboard-hero')); - heroEl.triggerEventHandler('selected', comp.heroes[0]); + const heroDe = fixture.debugElement.query(By.css('dashboard-hero')); + heroDe.triggerEventHandler('selected', comp.heroes[0]); } }); /** Add TestBed providers, compile, and create DashboardComponent */ function compileAndCreate() { // #docregion compile-and-create-body - beforeEach( async(() => { + beforeEach(async(() => { + // #docregion router-spy + const routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']); + const heroServiceSpy = jasmine.createSpyObj('HeroService', ['getHeroes']); + TestBed.configureTestingModule({ providers: [ - { provide: HeroService, useClass: FakeHeroService }, - { provide: Router, useClass: RouterStub } + { provide: HeroService, useValue: heroServiceSpy }, + { provide: Router, useValue: routerSpy } ] }) + // #enddocregion router-spy .compileComponents().then(() => { fixture = TestBed.createComponent(DashboardComponent); comp = fixture.componentInstance; + + // getHeroes spy returns observable of test heroes + heroServiceSpy.getHeroes.and.returnValue(asyncData(getTestHeroes())); }); // #enddocregion compile-and-create-body })); @@ -104,8 +106,11 @@ function tests(heroClick: Function) { describe('after get dashboard heroes', () => { + let router: Router; + // Trigger component so it gets heroes and binds to them - beforeEach( async(() => { + beforeEach(async(() => { + router = fixture.debugElement.injector.get(Router); fixture.detectChanges(); // runs ngOnInit -> getHeroes fixture.whenStable() // No need for the `lastPromise` hack! .then(() => fixture.detectChanges()); // bind to heroes @@ -119,29 +124,25 @@ function tests(heroClick: Function) { it('should DISPLAY heroes', () => { // Find and examine the displayed heroes // Look for them in the DOM by css class - const heroes = fixture.debugElement.queryAll(By.css('dashboard-hero')); + const heroes = fixture.nativeElement.querySelectorAll('dashboard-hero'); expect(heroes.length).toBe(4, 'should display 4 heroes'); }); - // #docregion navigate-test, inject - it('should tell ROUTER to navigate when hero clicked', - inject([Router], (router: Router) => { // ... - // #enddocregion inject - - const spy = spyOn(router, 'navigateByUrl'); + // #docregion navigate-test + it('should tell ROUTER to navigate when hero clicked', () => { heroClick(); // trigger click on first inner
- // args passed to router.navigateByUrl() + // args passed to router.navigateByUrl() spy + const spy = router.navigateByUrl as jasmine.Spy; const navArgs = spy.calls.first().args[0]; // expecting to navigate to id of the component's first hero const id = comp.heroes[0].id; expect(navArgs).toBe('/heroes/' + id, 'should nav to HeroDetail for first hero'); - // #docregion inject - })); - // #enddocregion navigate-test, inject + }); + // #enddocregion navigate-test }); } diff --git a/aio/content/examples/testing/src/app/dashboard/dashboard.component.ts b/aio/content/examples/testing/src/app/dashboard/dashboard.component.ts index 6521f4b52e..6ab4e6f324 100644 --- a/aio/content/examples/testing/src/app/dashboard/dashboard.component.ts +++ b/aio/content/examples/testing/src/app/dashboard/dashboard.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; -import { Hero } from '../model/hero'; +import { Hero } from '../model/hero'; import { HeroService } from '../model/hero.service'; @Component({ @@ -23,7 +23,7 @@ export class DashboardComponent implements OnInit { ngOnInit() { this.heroService.getHeroes() - .then(heroes => this.heroes = heroes.slice(1, 5)); + .subscribe(heroes => this.heroes = heroes.slice(1, 5)); } // #docregion goto-detail diff --git a/aio/content/examples/testing/src/app/bag/async-helper.spec.ts b/aio/content/examples/testing/src/app/demo/async-helper.spec.ts similarity index 89% rename from aio/content/examples/testing/src/app/bag/async-helper.spec.ts rename to aio/content/examples/testing/src/app/demo/async-helper.spec.ts index 12c8a4fbbd..4ca986badb 100644 --- a/aio/content/examples/testing/src/app/bag/async-helper.spec.ts +++ b/aio/content/examples/testing/src/app/demo/async-helper.spec.ts @@ -1,7 +1,8 @@ // tslint:disable-next-line:no-unused-variable import { async, fakeAsync, tick } from '@angular/core/testing'; -import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { delay } from 'rxjs/operators'; describe('Angular async helper', () => { let actuallyDone = false; @@ -34,8 +35,8 @@ describe('Angular async helper', () => { // Use done. Cannot use setInterval with async or fakeAsync // See https://github.com/angular/angular/issues/10127 - it('should run async test with successful delayed Observable', (done: any) => { - const source = Observable.of(true).delay(10); + it('should run async test with successful delayed Observable', (done: DoneFn) => { + const source = of(true).pipe(delay(10)); source.subscribe( val => actuallyDone = true, err => fail(err), @@ -46,7 +47,7 @@ describe('Angular async helper', () => { // Cannot use setInterval from within an async zone test // See https://github.com/angular/angular/issues/10127 // xit('should run async test with successful delayed Observable', async(() => { - // const source = Observable.of(true).delay(10); + // const source = of(true).pipe(delay(10)); // source.subscribe( // val => actuallyDone = true, // err => fail(err) @@ -56,7 +57,7 @@ describe('Angular async helper', () => { // // Fail message: Error: 1 periodic timer(s) still in the queue // // See https://github.com/angular/angular/issues/10127 // xit('should run async test with successful delayed Observable', fakeAsync(() => { - // const source = Observable.of(true).delay(10); + // const source = of(true).pipe(delay(10)); // source.subscribe( // val => actuallyDone = true, // err => fail(err) diff --git a/aio/content/examples/testing/src/app/bag/bag-external-template.html b/aio/content/examples/testing/src/app/demo/demo-external-template.html similarity index 100% rename from aio/content/examples/testing/src/app/bag/bag-external-template.html rename to aio/content/examples/testing/src/app/demo/demo-external-template.html diff --git a/aio/content/examples/testing/src/app/bag/bag-main.ts b/aio/content/examples/testing/src/app/demo/demo-main.ts similarity index 52% rename from aio/content/examples/testing/src/app/bag/bag-main.ts rename to aio/content/examples/testing/src/app/demo/demo-main.ts index 27b78200ae..9243428b7a 100644 --- a/aio/content/examples/testing/src/app/bag/bag-main.ts +++ b/aio/content/examples/testing/src/app/demo/demo-main.ts @@ -1,5 +1,5 @@ // main app entry point import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; -import { BagModule } from './bag'; +import { DemoModule } from './demo'; -platformBrowserDynamic().bootstrapModule(BagModule); +platformBrowserDynamic().bootstrapModule(DemoModule); diff --git a/aio/content/examples/testing/src/app/demo/demo.spec.ts b/aio/content/examples/testing/src/app/demo/demo.spec.ts new file mode 100644 index 0000000000..d65b9acfb8 --- /dev/null +++ b/aio/content/examples/testing/src/app/demo/demo.spec.ts @@ -0,0 +1,153 @@ +// #docplaster +import { + LightswitchComponent, + MasterService, + ValueService, + ReversePipe +} from './demo'; + +///////// Fakes ///////// +export class FakeValueService extends ValueService { + value = 'faked service value'; +} +//////////////////////// +describe('demo (no TestBed):', () => { + + // #docregion ValueService + // Straight Jasmine testing without Angular's testing support + describe('ValueService', () => { + let service: ValueService; + beforeEach(() => { service = new ValueService(); }); + + it('#getValue should return real value', () => { + expect(service.getValue()).toBe('real value'); + }); + + it('#getObservableValue should return value from observable', + (done: DoneFn) => { + service.getObservableValue().subscribe(value => { + expect(value).toBe('observable value'); + done(); + }); + }); + + it('#getPromiseValue should return value from a promise', + (done: DoneFn) => { + service.getPromiseValue().then(value => { + expect(value).toBe('promise value'); + done(); + }); + }); + }); + // #enddocregion ValueService + + // MasterService requires injection of a ValueService + // #docregion MasterService + describe('MasterService without Angular testing support', () => { + let masterService: MasterService; + + it('#getValue should return real value from the real service', () => { + masterService = new MasterService(new ValueService()); + expect(masterService.getValue()).toBe('real value'); + }); + + it('#getValue should return faked value from a fakeService', () => { + masterService = new MasterService(new FakeValueService()); + expect(masterService.getValue()).toBe('faked service value'); + }); + + it('#getValue should return faked value from a fake object', () => { + const fake = { getValue: () => 'fake value' }; + masterService = new MasterService(fake as ValueService); + expect(masterService.getValue()).toBe('fake value'); + }); + + it('#getValue should return stubbed value from a spy', () => { + // create `getValue` spy on an object representing the ValueService + const valueServiceSpy = + jasmine.createSpyObj('ValueService', ['getValue']); + + // set the value to return when the `getValue` spy is called. + const stubValue = 'stub value'; + valueServiceSpy.getValue.and.returnValue(stubValue); + + masterService = new MasterService(valueServiceSpy); + + expect(masterService.getValue()) + .toBe(stubValue, 'service returned stub value'); + expect(valueServiceSpy.getValue.calls.count()) + .toBe(1, 'spy method was called once'); + expect(valueServiceSpy.getValue.calls.mostRecent().returnValue) + .toBe(stubValue); + }); + }); + // #enddocregion MasterService + + describe('MasterService (no beforeEach)', () => { + // #docregion no-before-each-test + it('#getValue should return stubbed value from a spy', () => { + // #docregion no-before-each-setup-call + const { masterService, stubValue, valueServiceSpy } = setup(); + // #enddocregion no-before-each-setup-call + expect(masterService.getValue()) + .toBe(stubValue, 'service returned stub value'); + expect(valueServiceSpy.getValue.calls.count()) + .toBe(1, 'spy method was called once'); + expect(valueServiceSpy.getValue.calls.mostRecent().returnValue) + .toBe(stubValue); + }); + // #enddocregion no-before-each-test + + // #docregion no-before-each-setup + function setup() { + const valueServiceSpy = + jasmine.createSpyObj('ValueService', ['getValue']); + const stubValue = 'stub value'; + const masterService = new MasterService(valueServiceSpy); + + valueServiceSpy.getValue.and.returnValue(stubValue); + return { masterService, stubValue, valueServiceSpy }; + } + // #enddocregion no-before-each-setup + }); + + // #docregion ReversePipe + + describe('ReversePipe', () => { + let pipe: ReversePipe; + + beforeEach(() => { pipe = new ReversePipe(); }); + + it('transforms "abc" to "cba"', () => { + expect(pipe.transform('abc')).toBe('cba'); + }); + + it('no change to palindrome: "able was I ere I saw elba"', () => { + const palindrome = 'able was I ere I saw elba'; + expect(pipe.transform(palindrome)).toBe(palindrome); + }); + + }); + // #enddocregion ReversePipe + + // #docregion Lightswitch + describe('LightswitchComp', () => { + it('#clicked() should toggle #isOn', () => { + const comp = new LightswitchComponent(); + expect(comp.isOn).toBe(false, 'off at first'); + comp.clicked(); + expect(comp.isOn).toBe(true, 'on after click'); + comp.clicked(); + expect(comp.isOn).toBe(false, 'off after second click'); + }); + + it('#clicked() should set #message to "is on"', () => { + const comp = new LightswitchComponent(); + expect(comp.message).toMatch(/is off/i, 'off at first'); + comp.clicked(); + expect(comp.message).toMatch(/is on/i, 'on after clicked'); + }); + }); + // #enddocregion Lightswitch + +}); diff --git a/aio/content/examples/testing/src/app/demo/demo.testbed.spec.ts b/aio/content/examples/testing/src/app/demo/demo.testbed.spec.ts new file mode 100644 index 0000000000..2c0ba58e99 --- /dev/null +++ b/aio/content/examples/testing/src/app/demo/demo.testbed.spec.ts @@ -0,0 +1,706 @@ +// #docplaster +import { + DemoModule, + BankAccountComponent, BankAccountParentComponent, + LightswitchComponent, + Child1Component, Child2Component, Child3Component, + MasterService, + ValueService, + ExternalTemplateComponent, + InputComponent, + IoComponent, IoParentComponent, + MyIfComponent, MyIfChildComponent, MyIfParentComponent, + NeedsContentComponent, ParentComponent, + TestProvidersComponent, TestViewProvidersComponent, + ReversePipeComponent, ShellComponent +} from './demo'; + +import { By } from '@angular/platform-browser'; +import { Component, + DebugElement, + Injectable } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +// Forms symbols imported only for a specific test below +import { NgModel, NgControl } from '@angular/forms'; + +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick +} from '@angular/core/testing'; + +import { addMatchers, newEvent, click } from '../../testing'; + +export class NotProvided extends ValueService { /* example below */} +beforeEach( addMatchers ); + +describe('demo (with TestBed):', () => { + +//////// Service Tests ///////////// + + // #docregion ValueService + describe('ValueService', () => { + + // #docregion value-service-before-each + let service: ValueService; + + // #docregion value-service-inject-before-each + beforeEach(() => { + TestBed.configureTestingModule({ providers: [ValueService] }); + // #enddocregion value-service-before-each + service = TestBed.get(ValueService); + // #docregion value-service-before-each + }); + // #enddocregion value-service-before-each, value-service-inject-before-each + + // #docregion value-service-inject-it + it('should use ValueService', () => { + service = TestBed.get(ValueService); + expect(service.getValue()).toBe('real value'); + }); + // #enddocregion value-service-inject-it + + it('can inject a default value when service is not provided', () => { + // #docregion testbed-get-w-null + service = TestBed.get(NotProvided, null); // service is null + // #enddocregion testbed-get-w-null + }); + + it('test should wait for ValueService.getPromiseValue', async(() => { + service.getPromiseValue().then( + value => expect(value).toBe('promise value') + ); + })); + + it('test should wait for ValueService.getObservableValue', async(() => { + service.getObservableValue().subscribe( + value => expect(value).toBe('observable value') + ); + })); + + // Must use done. See https://github.com/angular/angular/issues/10127 + it('test should wait for ValueService.getObservableDelayValue', (done: DoneFn) => { + service.getObservableDelayValue().subscribe(value => { + expect(value).toBe('observable delay value'); + done(); + }); + }); + + it('should allow the use of fakeAsync', fakeAsync(() => { + let value: any; + service.getPromiseValue().then((val: any) => value = val); + tick(); // Trigger JS engine cycle until all promises resolve. + expect(value).toBe('promise value'); + })); + }); + // #enddocregion ValueService + + describe('MasterService', () => { + // #docregion master-service-before-each + let masterService: MasterService; + let valueServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + const spy = jasmine.createSpyObj('ValueService', ['getValue']); + + TestBed.configureTestingModule({ + // Provide both the service-to-test and its (spy) dependency + providers: [ + MasterService, + { provide: ValueService, useValue: spy } + ] + }); + // Inject both the service-to-test and its (spy) dependency + masterService = TestBed.get(MasterService); + valueServiceSpy = TestBed.get(ValueService); + }); + // #enddocregion master-service-before-each + + // #docregion master-service-it + it('#getValue should return stubbed value from a spy', () => { + const stubValue = 'stub value'; + valueServiceSpy.getValue.and.returnValue(stubValue); + + expect(masterService.getValue()) + .toBe(stubValue, 'service returned stub value'); + expect(valueServiceSpy.getValue.calls.count()) + .toBe(1, 'spy method was called once'); + expect(valueServiceSpy.getValue.calls.mostRecent().returnValue) + .toBe(stubValue); + }); + // #enddocregion master-service-it + }); + + describe('use inject within `it`', () => { + beforeEach(() => { + TestBed.configureTestingModule({ providers: [ValueService] }); + }); + + it('should use modified providers', + inject([ValueService], (service: ValueService) => { + service.setValue('value modified in beforeEach'); + expect(service.getValue()) + .toBe('value modified in beforeEach'); + }) + ); + }); + + describe('using async(inject) within beforeEach', () => { + let serviceValue: string; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [ValueService] }); + }); + + beforeEach(async(inject([ValueService], (service: ValueService) => { + service.getPromiseValue().then(value => serviceValue = value); + }))); + + it('should use asynchronously modified value ... in synchronous test', () => { + expect(serviceValue).toBe('promise value'); + }); + }); + +/////////// Component Tests ////////////////// + + describe('TestBed component tests', () => { + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + imports: [DemoModule], + }) + // Compile everything in DemoModule + .compileComponents(); + })); + + it('should create a component with inline template', () => { + const fixture = TestBed.createComponent(Child1Component); + fixture.detectChanges(); + + expect(fixture).toHaveText('Child'); + }); + + it('should create a component with external template', () => { + const fixture = TestBed.createComponent(ExternalTemplateComponent); + fixture.detectChanges(); + + expect(fixture).toHaveText('from external template'); + }); + + it('should allow changing members of the component', () => { + const fixture = TestBed.createComponent(MyIfComponent); + + fixture.detectChanges(); + expect(fixture).toHaveText('MyIf()'); + + fixture.componentInstance.showMore = true; + fixture.detectChanges(); + expect(fixture).toHaveText('MyIf(More)'); + }); + + it('should create a nested component bound to inputs/outputs', () => { + const fixture = TestBed.createComponent(IoParentComponent); + + fixture.detectChanges(); + const heroes = fixture.debugElement.queryAll(By.css('.hero')); + expect(heroes.length).toBeGreaterThan(0, 'has heroes'); + + const comp = fixture.componentInstance; + const hero = comp.heroes[0]; + + click(heroes[0]); + fixture.detectChanges(); + + const selected = fixture.debugElement.query(By.css('p')); + expect(selected).toHaveText(hero.name); + }); + + it('can access the instance variable of an `*ngFor` row component', () => { + const fixture = TestBed.createComponent(IoParentComponent); + const comp = fixture.componentInstance; + const heroName = comp.heroes[0].name; // first hero's name + + fixture.detectChanges(); + const ngForRow = fixture.debugElement.query(By.directive(IoComponent)); // first hero ngForRow + + const hero = ngForRow.context['hero']; // the hero object passed into the row + expect(hero.name).toBe(heroName, 'ngRow.context.hero'); + + const rowComp = ngForRow.componentInstance; + // jasmine.any is an "instance-of-type" test. + expect(rowComp).toEqual(jasmine.any(IoComponent), 'component is IoComp'); + expect(rowComp.hero.name).toBe(heroName, 'component.hero'); + }); + + + // #docregion ButtonComp + it('should support clicking a button', () => { + const fixture = TestBed.createComponent(LightswitchComponent); + const btn = fixture.debugElement.query(By.css('button')); + const span = fixture.debugElement.query(By.css('span')).nativeElement; + + fixture.detectChanges(); + expect(span.textContent).toMatch(/is off/i, 'before click'); + + click(btn); + fixture.detectChanges(); + expect(span.textContent).toMatch(/is on/i, 'after click'); + }); + // #enddocregion ButtonComp + + // ngModel is async so we must wait for it with promise-based `whenStable` + it('should support entering text in input box (ngModel)', async(() => { + const expectedOrigName = 'John'; + const expectedNewName = 'Sally'; + + const fixture = TestBed.createComponent(InputComponent); + fixture.detectChanges(); + + const comp = fixture.componentInstance; + const input = fixture.debugElement.query(By.css('input')).nativeElement; + + expect(comp.name).toBe(expectedOrigName, + `At start name should be ${expectedOrigName} `); + + // wait until ngModel binds comp.name to input box + fixture.whenStable().then(() => { + expect(input.value).toBe(expectedOrigName, + `After ngModel updates input box, input.value should be ${expectedOrigName} `); + + // simulate user entering new name in input + input.value = expectedNewName; + + // that change doesn't flow to the component immediately + expect(comp.name).toBe(expectedOrigName, + `comp.name should still be ${expectedOrigName} after value change, before binding happens`); + + // dispatch a DOM event so that Angular learns of input value change. + // then wait while ngModel pushes input.box value to comp.name + input.dispatchEvent(newEvent('input')); + return fixture.whenStable(); + }) + .then(() => { + expect(comp.name).toBe(expectedNewName, + `After ngModel updates the model, comp.name should be ${expectedNewName} `); + }); + })); + + // fakeAsync version of ngModel input test enables sync test style + // synchronous `tick` replaces asynchronous promise-base `whenStable` + it('should support entering text in input box (ngModel) - fakeAsync', fakeAsync(() => { + const expectedOrigName = 'John'; + const expectedNewName = 'Sally'; + + const fixture = TestBed.createComponent(InputComponent); + fixture.detectChanges(); + + const comp = fixture.componentInstance; + const input = fixture.debugElement.query(By.css('input')).nativeElement; + + expect(comp.name).toBe(expectedOrigName, + `At start name should be ${expectedOrigName} `); + + // wait until ngModel binds comp.name to input box + tick(); + expect(input.value).toBe(expectedOrigName, + `After ngModel updates input box, input.value should be ${expectedOrigName} `); + + // simulate user entering new name in input + input.value = expectedNewName; + + // that change doesn't flow to the component immediately + expect(comp.name).toBe(expectedOrigName, + `comp.name should still be ${expectedOrigName} after value change, before binding happens`); + + // dispatch a DOM event so that Angular learns of input value change. + // then wait a tick while ngModel pushes input.box value to comp.name + input.dispatchEvent(newEvent('input')); + tick(); + expect(comp.name).toBe(expectedNewName, + `After ngModel updates the model, comp.name should be ${expectedNewName} `); + })); + + // #docregion ReversePipeComp + it('ReversePipeComp should reverse the input text', fakeAsync(() => { + const inputText = 'the quick brown fox.'; + const expectedText = '.xof nworb kciuq eht'; + + const fixture = TestBed.createComponent(ReversePipeComponent); + fixture.detectChanges(); + + const comp = fixture.componentInstance; + const input = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement; + const span = fixture.debugElement.query(By.css('span')).nativeElement as HTMLElement; + + // simulate user entering new name in input + input.value = inputText; + + // dispatch a DOM event so that Angular learns of input value change. + // then wait a tick while ngModel pushes input.box value to comp.text + // and Angular updates the output span + input.dispatchEvent(newEvent('input')); + tick(); + fixture.detectChanges(); + expect(span.textContent).toBe(expectedText, 'output span'); + expect(comp.text).toBe(inputText, 'component.text'); + })); + // #enddocregion ReversePipeComp + + // Use this technique to find attached directives of any kind + it('can examine attached directives and listeners', () => { + const fixture = TestBed.createComponent(InputComponent); + fixture.detectChanges(); + + const inputEl = fixture.debugElement.query(By.css('input')); + + expect(inputEl.providerTokens).toContain(NgModel, 'NgModel directive'); + + const ngControl = inputEl.injector.get(NgControl); + expect(ngControl).toEqual(jasmine.any(NgControl), 'NgControl directive'); + + expect(inputEl.listeners.length).toBeGreaterThan(2, 'several listeners attached'); + }); + + // #docregion dom-attributes + it('BankAccountComponent should set attributes, styles, classes, and properties', () => { + const fixture = TestBed.createComponent(BankAccountParentComponent); + fixture.detectChanges(); + const comp = fixture.componentInstance; + + // the only child is debugElement of the BankAccount component + const el = fixture.debugElement.children[0]; + const childComp = el.componentInstance as BankAccountComponent; + expect(childComp).toEqual(jasmine.any(BankAccountComponent)); + + expect(el.context).toBe(childComp, 'context is the child component'); + + expect(el.attributes['account']).toBe(childComp.id, 'account attribute'); + expect(el.attributes['bank']).toBe(childComp.bank, 'bank attribute'); + + expect(el.classes['closed']).toBe(true, 'closed class'); + expect(el.classes['open']).toBe(false, 'open class'); + + expect(el.styles['color']).toBe(comp.color, 'color style'); + expect(el.styles['width']).toBe(comp.width + 'px', 'width style'); + // #enddocregion dom-attributes + + // Removed on 12/02/2016 when ceased public discussion of the `Renderer`. Revive in future? + // expect(el.properties['customProperty']).toBe(true, 'customProperty'); + + // #docregion dom-attributes + }); + // #enddocregion dom-attributes + + + }); + + describe('TestBed component overrides:', () => { + + it('should override ChildComp\'s template', () => { + + const fixture = TestBed.configureTestingModule({ + declarations: [Child1Component], + }) + .overrideComponent(Child1Component, { + set: { template: 'Fake' } + }) + .createComponent(Child1Component); + + fixture.detectChanges(); + expect(fixture).toHaveText('Fake'); + }); + + it('should override TestProvidersComp\'s ValueService provider', () => { + const fixture = TestBed.configureTestingModule({ + declarations: [TestProvidersComponent], + }) + .overrideComponent(TestProvidersComponent, { + remove: { providers: [ValueService]}, + add: { providers: [{ provide: ValueService, useClass: FakeValueService }] }, + + // Or replace them all (this component has only one provider) + // set: { providers: [{ provide: ValueService, useClass: FakeValueService }] }, + }) + .createComponent(TestProvidersComponent); + + fixture.detectChanges(); + expect(fixture).toHaveText('injected value: faked value', 'text'); + + // Explore the providerTokens + const tokens = fixture.debugElement.providerTokens; + expect(tokens).toContain(fixture.componentInstance.constructor, 'component ctor'); + expect(tokens).toContain(TestProvidersComponent, 'TestProvidersComp'); + expect(tokens).toContain(ValueService, 'ValueService'); + }); + + it('should override TestViewProvidersComp\'s ValueService viewProvider', () => { + const fixture = TestBed.configureTestingModule({ + declarations: [TestViewProvidersComponent], + }) + .overrideComponent(TestViewProvidersComponent, { + // remove: { viewProviders: [ValueService]}, + // add: { viewProviders: [{ provide: ValueService, useClass: FakeValueService }] }, + + // Or replace them all (this component has only one viewProvider) + set: { viewProviders: [{ provide: ValueService, useClass: FakeValueService }] }, + }) + .createComponent(TestViewProvidersComponent); + + fixture.detectChanges(); + expect(fixture).toHaveText('injected value: faked value'); + }); + + it('injected provider should not be same as component\'s provider', () => { + + // TestComponent is parent of TestProvidersComponent + @Component({ template: '' }) + class TestComponent {} + + // 3 levels of ValueService provider: module, TestCompomponent, TestProvidersComponent + const fixture = TestBed.configureTestingModule({ + declarations: [TestComponent, TestProvidersComponent], + providers: [ValueService] + }) + .overrideComponent(TestComponent, { + set: { providers: [{ provide: ValueService, useValue: {} }] } + }) + .overrideComponent(TestProvidersComponent, { + set: { providers: [{ provide: ValueService, useClass: FakeValueService }] } + }) + .createComponent(TestComponent); + + let testBedProvider: ValueService; + let tcProvider: ValueService; + let tpcProvider: FakeValueService; + + // `inject` uses TestBed's injector + inject([ValueService], (s: ValueService) => testBedProvider = s)(); + tcProvider = fixture.debugElement.injector.get(ValueService) as ValueService; + tpcProvider = fixture.debugElement.children[0].injector.get(ValueService) as FakeValueService; + + expect(testBedProvider).not.toBe(tcProvider, 'testBed/tc not same providers'); + expect(testBedProvider).not.toBe(tpcProvider, 'testBed/tpc not same providers'); + + expect(testBedProvider instanceof ValueService).toBe(true, 'testBedProvider is ValueService'); + expect(tcProvider).toEqual({} as ValueService, 'tcProvider is {}'); + expect(tpcProvider instanceof FakeValueService).toBe(true, 'tpcProvider is FakeValueService'); + }); + + it('can access template local variables as references', () => { + const fixture = TestBed.configureTestingModule({ + declarations: [ShellComponent, NeedsContentComponent, Child1Component, Child2Component, Child3Component], + }) + .overrideComponent(ShellComponent, { + set: { + selector: 'test-shell', + template: ` + + + + + +
!
+
+ ` + } + }) + .createComponent(ShellComponent); + + fixture.detectChanges(); + + // NeedsContentComp is the child of ShellComp + const el = fixture.debugElement.children[0]; + const comp = el.componentInstance; + + expect(comp.children.toArray().length).toBe(4, + 'three different child components and an ElementRef with #content'); + + expect(el.references['nc']).toBe(comp, '#nc reference to component'); + + // #docregion custom-predicate + // Filter for DebugElements with a #content reference + const contentRefs = el.queryAll( de => de.references['content']); + // #enddocregion custom-predicate + expect(contentRefs.length).toBe(4, 'elements w/ a #content reference'); + }); + + }); + + describe('nested (one-deep) component override', () => { + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ParentComponent, FakeChildComponent] + }); + }); + + it('ParentComp should use Fake Child component', () => { + const fixture = TestBed.createComponent(ParentComponent); + fixture.detectChanges(); + expect(fixture).toHaveText('Parent(Fake Child)'); + }); + }); + + describe('nested (two-deep) component override', () => { + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ParentComponent, FakeChildWithGrandchildComponent, FakeGrandchildComponent] + }); + }); + + it('should use Fake Grandchild component', () => { + const fixture = TestBed.createComponent(ParentComponent); + fixture.detectChanges(); + expect(fixture).toHaveText('Parent(Fake Child(Fake Grandchild))'); + }); + }); + + describe('lifecycle hooks w/ MyIfParentComp', () => { + let fixture: ComponentFixture; + let parent: MyIfParentComponent; + let child: MyIfChildComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [FormsModule], + declarations: [MyIfChildComponent, MyIfParentComponent] + }); + + fixture = TestBed.createComponent(MyIfParentComponent); + parent = fixture.componentInstance; + }); + + it('should instantiate parent component', () => { + expect(parent).not.toBeNull('parent component should exist'); + }); + + it('parent component OnInit should NOT be called before first detectChanges()', () => { + expect(parent.ngOnInitCalled).toBe(false); + }); + + it('parent component OnInit should be called after first detectChanges()', () => { + fixture.detectChanges(); + expect(parent.ngOnInitCalled).toBe(true); + }); + + it('child component should exist after OnInit', () => { + fixture.detectChanges(); + getChild(); + expect(child instanceof MyIfChildComponent).toBe(true, 'should create child'); + }); + + it('should have called child component\'s OnInit ', () => { + fixture.detectChanges(); + getChild(); + expect(child.ngOnInitCalled).toBe(true); + }); + + it('child component called OnChanges once', () => { + fixture.detectChanges(); + getChild(); + expect(child.ngOnChangesCounter).toBe(1); + }); + + it('changed parent value flows to child', () => { + fixture.detectChanges(); + getChild(); + + parent.parentValue = 'foo'; + fixture.detectChanges(); + + expect(child.ngOnChangesCounter).toBe(2, + 'expected 2 changes: initial value and changed value'); + expect(child.childValue).toBe('foo', + 'childValue should eq changed parent value'); + }); + + // must be async test to see child flow to parent + it('changed child value flows to parent', async(() => { + fixture.detectChanges(); + getChild(); + + child.childValue = 'bar'; + + return new Promise(resolve => { + // Wait one JS engine turn! + setTimeout(() => resolve(), 0); + }) + .then(() => { + fixture.detectChanges(); + + expect(child.ngOnChangesCounter).toBe(2, + 'expected 2 changes: initial value and changed value'); + expect(parent.parentValue).toBe('bar', + 'parentValue should eq changed parent value'); + }); + + })); + + it('clicking "Close Child" triggers child OnDestroy', () => { + fixture.detectChanges(); + getChild(); + + const btn = fixture.debugElement.query(By.css('button')); + click(btn); + + fixture.detectChanges(); + expect(child.ngOnDestroyCalled).toBe(true); + }); + + ////// helpers /// + /** + * Get the MyIfChildComp from parent; fail w/ good message if cannot. + */ + function getChild() { + + let childDe: DebugElement; // DebugElement that should hold the MyIfChildComp + + // The Hard Way: requires detailed knowledge of the parent template + try { + childDe = fixture.debugElement.children[4].children[0]; + } catch (err) { /* we'll report the error */ } + + // DebugElement.queryAll: if we wanted all of many instances: + childDe = fixture.debugElement + .queryAll(function (de) { return de.componentInstance instanceof MyIfChildComponent; })[0]; + + // WE'LL USE THIS APPROACH ! + // DebugElement.query: find first instance (if any) + childDe = fixture.debugElement + .query(function (de) { return de.componentInstance instanceof MyIfChildComponent; }); + + if (childDe && childDe.componentInstance) { + child = childDe.componentInstance; + } else { + fail('Unable to find MyIfChildComp within MyIfParentComp'); + } + + return child; + } + }); + +}); +////////// Fakes /////////// + +@Component({ + selector: 'child-1', + template: `Fake Child` +}) +class FakeChildComponent { } + +@Component({ + selector: 'child-1', + template: `Fake Child()` +}) +class FakeChildWithGrandchildComponent { } + +@Component({ + selector: 'grandchild-1', + template: `Fake Grandchild` +}) +class FakeGrandchildComponent { } + +@Injectable() +class FakeValueService extends ValueService { + value = 'faked value'; +} diff --git a/aio/content/examples/testing/src/app/bag/bag.ts b/aio/content/examples/testing/src/app/demo/demo.ts similarity index 85% rename from aio/content/examples/testing/src/app/bag/bag.ts rename to aio/content/examples/testing/src/app/demo/demo.ts index 1b624786c7..32649feaa6 100644 --- a/aio/content/examples/testing/src/app/bag/bag.ts +++ b/aio/content/examples/testing/src/app/demo/demo.ts @@ -6,9 +6,8 @@ import { Component, ContentChildren, Directive, EventEmitter, Pipe, PipeTransform, SimpleChange } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of'; -import 'rxjs/add/operator/delay'; +import { of } from 'rxjs/observable/of'; +import { delay } from 'rxjs/operators'; ////////// The App: Services and Components for the tests. ////////////// @@ -17,37 +16,31 @@ export class Hero { } ////////// Services /////////////// -// #docregion FancyService +// #docregion ValueService @Injectable() -export class FancyService { +export class ValueService { protected value = 'real value'; getValue() { return this.value; } setValue(value: string) { this.value = value; } - getAsyncValue() { return Promise.resolve('async value'); } + getObservableValue() { return of('observable value'); } - getObservableValue() { return Observable.of('observable value'); } - - getTimeoutValue() { - return new Promise((resolve) => { - setTimeout(() => { resolve('timeout value'); }, 10); - }); - } + getPromiseValue() { return Promise.resolve('promise value'); } getObservableDelayValue() { - return Observable.of('observable delay value').delay(10); + return of('observable delay value').pipe(delay(10)); } } -// #enddocregion FancyService +// #enddocregion ValueService -// #docregion DependentService +// #docregion MasterService @Injectable() -export class DependentService { - constructor(private dependentService: FancyService) { } - getValue() { return this.dependentService.getValue(); } +export class MasterService { + constructor(private masterService: ValueService) { } + getValue() { return this.masterService.getValue(); } } -// #enddocregion DependentService +// #enddocregion MasterService /////////// Pipe //////////////// /* @@ -102,19 +95,19 @@ export class BankAccountParentComponent { isClosed = true; } -// #docregion ButtonComp +// #docregion LightswitchComp @Component({ - selector: 'button-comp', + selector: 'lightswitch-comp', template: ` {{message}}` }) -export class ButtonComponent { +export class LightswitchComponent { isOn = false; clicked() { this.isOn = !this.isOn; } get message() { return `The light is ${this.isOn ? 'On' : 'Off'}`; } } -// #enddocregion ButtonComp +// #enddocregion LightswitchComp @Component({ selector: 'child-1', @@ -231,31 +224,31 @@ export class MyIfComponent { @Component({ selector: 'my-service-comp', - template: `injected value: {{fancyService.value}}`, - providers: [FancyService] + template: `injected value: {{valueService.value}}`, + providers: [ValueService] }) export class TestProvidersComponent { - constructor(public fancyService: FancyService) {} + constructor(public valueService: ValueService) {} } @Component({ selector: 'my-service-comp', - template: `injected value: {{fancyService.value}}`, - viewProviders: [FancyService] + template: `injected value: {{valueService.value}}`, + viewProviders: [ValueService] }) export class TestViewProvidersComponent { - constructor(public fancyService: FancyService) {} + constructor(public valueService: ValueService) {} } @Component({ selector: 'external-template-comp', - templateUrl: './bag-external-template.html' + templateUrl: './demo-external-template.html' }) export class ExternalTemplateComponent implements OnInit { serviceValue: string; - constructor(@Optional() private service: FancyService) { } + constructor(@Optional() private service: ValueService) { } ngOnInit() { if (this.service) { this.serviceValue = this.service.getValue(); } @@ -376,9 +369,9 @@ export class ReversePipeComponent { export class ShellComponent { } @Component({ - selector: 'bag-comp', + selector: 'demo-comp', template: ` -

Specs Bag

+

Specs Demo


Input/Output Component

@@ -397,7 +390,7 @@ export class ShellComponent { }

Button Component

- +

Needs Content

@@ -409,13 +402,13 @@ export class ShellComponent { } ` }) -export class BagComponent { } +export class DemoComponent { } //////// Aggregations //////////// -export const bagDeclarations = [ - BagComponent, +export const demoDeclarations = [ + DemoComponent, BankAccountComponent, BankAccountParentComponent, - ButtonComponent, + LightswitchComponent, Child1Component, Child2Component, Child3Component, ExternalTemplateComponent, InnerCompWithExternalTemplateComponent, InputComponent, @@ -427,7 +420,7 @@ export const bagDeclarations = [ ReversePipe, ReversePipeComponent, ShellComponent ]; -export const bagProviders = [DependentService, FancyService]; +export const demoProviders = [MasterService, ValueService]; //////////////////// //////////// @@ -437,10 +430,10 @@ import { FormsModule } from '@angular/forms'; @NgModule({ imports: [BrowserModule, FormsModule], - declarations: bagDeclarations, - providers: bagProviders, - entryComponents: [BagComponent], - bootstrap: [BagComponent] + declarations: demoDeclarations, + providers: demoProviders, + entryComponents: [DemoComponent], + bootstrap: [DemoComponent] }) -export class BagModule { } +export class DemoModule { } diff --git a/aio/content/examples/testing/src/app/dummy.module.ts b/aio/content/examples/testing/src/app/dummy.module.ts new file mode 100644 index 0000000000..7ca52420d2 --- /dev/null +++ b/aio/content/examples/testing/src/app/dummy.module.ts @@ -0,0 +1,15 @@ +// These unused NgModules keep the Angular Language Service happy. +// The AppModule registers the final versions of these components +import { NgModule } from '@angular/core'; + +import { AppComponent as app_initial } from './app-initial.component'; +@NgModule({ declarations: [ app_initial ] }) +export class AppModuleInitial {} + +import { BannerComponent as bc_initial } from './banner/banner-initial.component'; +@NgModule({ declarations: [ bc_initial ] }) +export class BannerModuleInitial {} + +import { BannerComponent as bc_external } from './banner/banner-external.component'; +@NgModule({ declarations: [ bc_external ] }) +export class BannerModuleExternal {} diff --git a/aio/content/examples/testing/src/app/hero/hero-detail.component.no-testbed.spec.ts b/aio/content/examples/testing/src/app/hero/hero-detail.component.no-testbed.spec.ts index 9dffe6f9de..4e806da3f9 100644 --- a/aio/content/examples/testing/src/app/hero/hero-detail.component.no-testbed.spec.ts +++ b/aio/content/examples/testing/src/app/hero/hero-detail.component.no-testbed.spec.ts @@ -1,7 +1,7 @@ -import { HeroDetailComponent } from './hero-detail.component'; -import { Hero } from '../model'; +import { asyncData, ActivatedRouteStub } from '../../testing'; -import { ActivatedRouteStub } from '../../testing'; +import { HeroDetailComponent } from './hero-detail.component'; +import { Hero } from '../model/hero'; ////////// Tests //////////////////// @@ -12,22 +12,21 @@ describe('HeroDetailComponent - no TestBed', () => { let hds: any; let router: any; - beforeEach((done: any) => { - expectedHero = new Hero(42, 'Bubba'); - activatedRoute = new ActivatedRouteStub(); - activatedRoute.testParamMap = { id: expectedHero.id }; - + beforeEach((done: DoneFn) => { + expectedHero = {id: 42, name: 'Bubba' }; + const activatedRoute = new ActivatedRouteStub({ id: expectedHero.id }); router = jasmine.createSpyObj('router', ['navigate']); hds = jasmine.createSpyObj('HeroDetailService', ['getHero', 'saveHero']); - hds.getHero.and.returnValue(Promise.resolve(expectedHero)); - hds.saveHero.and.returnValue(Promise.resolve(expectedHero)); + hds.getHero.and.returnValue(asyncData(expectedHero)); + hds.saveHero.and.returnValue(asyncData(expectedHero)); comp = new HeroDetailComponent(hds, activatedRoute, router); comp.ngOnInit(); // OnInit calls HDS.getHero; wait for it to get the fake hero - hds.getHero.calls.first().returnValue.then(done); + hds.getHero.calls.first().returnValue.subscribe(done); + }); it('should expose the hero retrieved from the service', () => { @@ -45,11 +44,11 @@ describe('HeroDetailComponent - no TestBed', () => { expect(router.navigate.calls.any()).toBe(false, 'router.navigate not called yet'); }); - it('should navigate when click save resolves', (done: any) => { + it('should navigate when click save resolves', (done: DoneFn) => { comp.save(); // waits for async save to complete before navigating hds.saveHero.calls.first().returnValue - .then(() => { + .subscribe(() => { expect(router.navigate.calls.any()).toBe(true, 'router.navigate called'); done(); }); diff --git a/aio/content/examples/testing/src/app/hero/hero-detail.component.spec.ts b/aio/content/examples/testing/src/app/hero/hero-detail.component.spec.ts index 9f66e1c841..2326e3dded 100644 --- a/aio/content/examples/testing/src/app/hero/hero-detail.component.spec.ts +++ b/aio/content/examples/testing/src/app/hero/hero-detail.component.spec.ts @@ -3,21 +3,20 @@ import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; +import { Router } from '@angular/router'; import { - ActivatedRoute, ActivatedRouteStub, click, newEvent, Router, RouterStub + ActivatedRoute, ActivatedRouteStub, asyncData, click, newEvent } from '../../testing'; -import { Hero } from '../model'; +import { Hero } from '../model/hero'; import { HeroDetailComponent } from './hero-detail.component'; import { HeroDetailService } from './hero-detail.service'; import { HeroModule } from './hero.module'; ////// Testing Vars ////// let activatedRoute: ActivatedRouteStub; -let comp: HeroDetailComponent; +let component: HeroDetailComponent; let fixture: ComponentFixture; let page: Page; @@ -32,36 +31,38 @@ describe('HeroDetailComponent', () => { describe('with SharedModule setup', sharedModuleSetup); }); -//////////////////// +/////////////////// + function overrideSetup() { // #docregion hds-spy class HeroDetailServiceSpy { - testHero = new Hero(42, 'Test Hero'); + testHero: Hero = {id: 42, name: 'Test Hero' }; + /* emit cloned test hero */ getHero = jasmine.createSpy('getHero').and.callFake( - () => Promise - .resolve(true) - .then(() => Object.assign({}, this.testHero)) + () => asyncData(Object.assign({}, this.testHero)) ); + /* emit clone of test hero, with changes merged in */ saveHero = jasmine.createSpy('saveHero').and.callFake( - (hero: Hero) => Promise - .resolve(true) - .then(() => Object.assign(this.testHero, hero)) + (hero: Hero) => asyncData(Object.assign(this.testHero, hero)) ); } + // #enddocregion hds-spy // the `id` value is irrelevant because ignored by service stub - beforeEach(() => activatedRoute.testParamMap = { id: 99999 } ); + beforeEach(() => activatedRoute.setParamMap({ id: 99999 })); // #docregion setup-override - beforeEach( async(() => { + beforeEach(async(() => { + const routerSpy = createRouterSpy(); + TestBed.configureTestingModule({ imports: [ HeroModule ], providers: [ { provide: ActivatedRoute, useValue: activatedRoute }, - { provide: Router, useClass: RouterStub}, + { provide: Router, useValue: routerSpy}, // #enddocregion setup-override // HeroDetailService at this level is IRRELEVANT! { provide: HeroDetailService, useValue: {} } @@ -87,7 +88,7 @@ function overrideSetup() { // #docregion override-tests let hdsSpy: HeroDetailServiceSpy; - beforeEach( async(() => { + beforeEach(async(() => { createComponent(); // get the component's injected HeroDetailServiceSpy hdsSpy = fixture.debugElement.injector.get(HeroDetailService) as any; @@ -108,7 +109,7 @@ function overrideSetup() { page.nameInput.value = newName; page.nameInput.dispatchEvent(newEvent('input')); // tell Angular - expect(comp.hero.name).toBe(newName, 'component hero has new name'); + expect(component.hero.name).toBe(newName, 'component hero has new name'); expect(hdsSpy.testHero.name).toBe(origName, 'service hero unchanged before save'); click(page.saveBtn); @@ -116,36 +117,40 @@ function overrideSetup() { tick(); // wait for async save to complete expect(hdsSpy.testHero.name).toBe(newName, 'service hero has new name after save'); - expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called'); + expect(page.navigateSpy.calls.any()).toBe(true, 'router.navigate called'); })); // #enddocregion override-tests it('fixture injected service is not the component injected service', - inject([HeroDetailService], (service: HeroDetailService) => { + // inject gets the service from the fixture + inject([HeroDetailService], (fixtureService: HeroDetailService) => { - expect(service).toEqual( {}, 'service injected from fixture'); - expect(hdsSpy).toBeTruthy('service injected into component'); + // use `fixture.debugElement.injector` to get service from component + const componentService = fixture.debugElement.injector.get(HeroDetailService); + + expect(fixtureService).not.toBe(componentService, 'service injected from fixture'); })); } //////////////////// -import { HEROES, FakeHeroService } from '../model/testing'; -import { HeroService } from '../model'; +import { getTestHeroes, TestHeroService, HeroService } from '../model/testing/test-hero.service'; -const firstHero = HEROES[0]; +const firstHero = getTestHeroes()[0]; function heroModuleSetup() { // #docregion setup-hero-module - beforeEach( async(() => { - TestBed.configureTestingModule({ + beforeEach(async(() => { + const routerSpy = createRouterSpy(); + + 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 }, - { provide: Router, useClass: RouterStub}, + { provide: HeroService, useClass: TestHeroService }, + { provide: Router, useValue: routerSpy}, ] }) .compileComponents(); @@ -156,9 +161,9 @@ function heroModuleSetup() { describe('when navigate to existing hero', () => { let expectedHero: Hero; - beforeEach( async(() => { + beforeEach(async(() => { expectedHero = firstHero; - activatedRoute.testParamMap = { id: expectedHero.id }; + activatedRoute.setParamMap({ id: expectedHero.id }); createComponent(); })); @@ -170,7 +175,7 @@ function heroModuleSetup() { it('should navigate when click cancel', () => { click(page.cancelBtn); - expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called'); + expect(page.navigateSpy.calls.any()).toBe(true, 'router.navigate called'); }); it('should save when click save but not navigate immediately', () => { @@ -181,30 +186,31 @@ function heroModuleSetup() { click(page.saveBtn); expect(saveSpy.calls.any()).toBe(true, 'HeroDetailService.save called'); - expect(page.navSpy.calls.any()).toBe(false, 'router.navigate not called'); + expect(page.navigateSpy.calls.any()).toBe(false, 'router.navigate not called'); }); it('should navigate when click save and save resolves', fakeAsync(() => { click(page.saveBtn); tick(); // wait for async save to complete - expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called'); + expect(page.navigateSpy.calls.any()).toBe(true, 'router.navigate called'); })); // #docregion title-case-pipe it('should convert hero name to Title Case', () => { const inputName = 'quick BROWN fox'; const titleCaseName = 'Quick Brown Fox'; + const { nameInput, nameDisplay } = page; // simulate user entering new name into the input box - page.nameInput.value = inputName; + nameInput.value = inputName; // dispatch a DOM event so that Angular learns of input value change. - page.nameInput.dispatchEvent(newEvent('input')); + nameInput.dispatchEvent(newEvent('input')); // Tell Angular to update the output span through the title pipe fixture.detectChanges(); - expect(page.nameDisplay.textContent).toBe(titleCaseName); + expect(nameDisplay.textContent).toBe(titleCaseName); }); // #enddocregion title-case-pipe // #enddocregion selected-tests @@ -214,10 +220,10 @@ function heroModuleSetup() { // #docregion route-no-id describe('when navigate with no hero id', () => { - beforeEach( async( createComponent )); + beforeEach(async( createComponent )); it('should have hero.id === 0', () => { - expect(comp.hero.id).toBe(0); + expect(component.hero.id).toBe(0); }); it('should display empty hero name', () => { @@ -228,14 +234,14 @@ function heroModuleSetup() { // #docregion route-bad-id describe('when navigate to non-existent hero id', () => { - beforeEach( async(() => { - activatedRoute.testParamMap = { id: 99999 }; + beforeEach(async(() => { + activatedRoute.setParamMap({ id: 99999 }); createComponent(); })); it('should try to navigate back to hero list', () => { - expect(page.gotoSpy.calls.any()).toBe(true, 'comp.gotoList called'); - expect(page.navSpy.calls.any()).toBe(true, 'router.navigate called'); + expect(page.gotoListSpy.calls.any()).toBe(true, 'comp.gotoList called'); + expect(page.navigateSpy.calls.any()).toBe(true, 'router.navigate called'); }); }); // #enddocregion route-bad-id @@ -263,23 +269,25 @@ import { TitleCasePipe } from '../shared/title-case.pipe'; function formsModuleSetup() { // #docregion setup-forms-module - beforeEach( async(() => { - TestBed.configureTestingModule({ + beforeEach(async(() => { + const routerSpy = createRouterSpy(); + + TestBed.configureTestingModule({ imports: [ FormsModule ], declarations: [ HeroDetailComponent, TitleCasePipe ], providers: [ { provide: ActivatedRoute, useValue: activatedRoute }, - { provide: HeroService, useClass: FakeHeroService }, - { provide: Router, useClass: RouterStub}, + { provide: HeroService, useClass: TestHeroService }, + { provide: Router, useValue: routerSpy}, ] }) .compileComponents(); })); // #enddocregion setup-forms-module - it('should display 1st hero\'s name', fakeAsync(() => { + it('should display 1st hero\'s name', async(() => { const expectedHero = firstHero; - activatedRoute.testParamMap = { id: expectedHero.id }; + activatedRoute.setParamMap({ id: expectedHero.id }); createComponent().then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); @@ -291,23 +299,25 @@ import { SharedModule } from '../shared/shared.module'; function sharedModuleSetup() { // #docregion setup-shared-module - beforeEach( async(() => { + beforeEach(async(() => { + const routerSpy = createRouterSpy(); + TestBed.configureTestingModule({ imports: [ SharedModule ], declarations: [ HeroDetailComponent ], providers: [ { provide: ActivatedRoute, useValue: activatedRoute }, - { provide: HeroService, useClass: FakeHeroService }, - { provide: Router, useClass: RouterStub}, + { provide: HeroService, useClass: TestHeroService }, + { provide: Router, useValue: routerSpy}, ] }) .compileComponents(); })); // #enddocregion setup-shared-module - it('should display 1st hero\'s name', fakeAsync(() => { + it('should display 1st hero\'s name', async(() => { const expectedHero = firstHero; - activatedRoute.testParamMap = { id: expectedHero.id }; + activatedRoute.setParamMap({ id: expectedHero.id }); createComponent().then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); @@ -320,45 +330,51 @@ function sharedModuleSetup() { /** Create the HeroDetailComponent, initialize it, set test variables */ function createComponent() { fixture = TestBed.createComponent(HeroDetailComponent); - comp = fixture.componentInstance; - page = new Page(); + component = fixture.componentInstance; + page = new Page(fixture); // 1st change detection triggers ngOnInit which gets a hero fixture.detectChanges(); return fixture.whenStable().then(() => { // 2nd change detection displays the async-fetched hero fixture.detectChanges(); - page.addPageElements(); }); } // #enddocregion create-component // #docregion page class Page { - gotoSpy: jasmine.Spy; - navSpy: jasmine.Spy; + // getter properties wait to query the DOM until called. + get buttons() { return this.queryAll('button'); } + get saveBtn() { return this.buttons[0]; } + get cancelBtn() { return this.buttons[1]; } + get nameDisplay() { return this.query('span'); } + get nameInput() { return this.query('input'); } - saveBtn: DebugElement; - cancelBtn: DebugElement; - nameDisplay: HTMLElement; - nameInput: HTMLInputElement; + gotoListSpy: jasmine.Spy; + navigateSpy: jasmine.Spy; - constructor() { - const router = TestBed.get(Router); // get router from root injector - this.gotoSpy = spyOn(comp, 'gotoList').and.callThrough(); - this.navSpy = spyOn(router, 'navigate'); + constructor(fixture: ComponentFixture) { + // get the navigate spy from the injected router spy object + const routerSpy = fixture.debugElement.injector.get(Router); + this.navigateSpy = routerSpy.navigate; + + // spy on component's `gotoList()` method + const component = fixture.componentInstance; + this.gotoListSpy = spyOn(component, 'gotoList').and.callThrough(); } - /** Add page elements after hero arrives */ - addPageElements() { - if (comp.hero) { - // have a hero so these elements are now in the DOM - const buttons = fixture.debugElement.queryAll(By.css('button')); - this.saveBtn = buttons[0]; - this.cancelBtn = buttons[1]; - this.nameDisplay = fixture.debugElement.query(By.css('span')).nativeElement; - this.nameInput = fixture.debugElement.query(By.css('input')).nativeElement; - } + //// query helpers //// + private query(selector: string): T { + return fixture.nativeElement.querySelector(selector); + } + + private queryAll(selector: string): T[] { + return fixture.nativeElement.querySelectorAll(selector); } } // #enddocregion page + +function createRouterSpy() { + return jasmine.createSpyObj('Router', ['navigate']); +} diff --git a/aio/content/examples/testing/src/app/hero/hero-detail.component.ts b/aio/content/examples/testing/src/app/hero/hero-detail.component.ts index b595a51371..fdfea6c4fa 100644 --- a/aio/content/examples/testing/src/app/hero/hero-detail.component.ts +++ b/aio/content/examples/testing/src/app/hero/hero-detail.component.ts @@ -2,7 +2,6 @@ // #docplaster import { Component, Input, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import 'rxjs/add/operator/map'; import { Hero } from '../model/hero'; import { HeroDetailService } from './hero-detail.service'; @@ -29,18 +28,18 @@ export class HeroDetailComponent implements OnInit { // #docregion ng-on-init ngOnInit(): void { // get hero when `id` param changes - this.route.paramMap.subscribe(p => this.getHero(p.has('id') && p.get('id'))); + this.route.paramMap.subscribe(pmap => this.getHero(pmap.get('id'))); } // #enddocregion ng-on-init private getHero(id: string): void { - // when no id or id===0, create new hero + // when no id or id===0, create new blank hero if (!id) { - this.hero = new Hero(); + this.hero = { id: 0, name: '' } as Hero; return; } - this.heroDetailService.getHero(id).then(hero => { + this.heroDetailService.getHero(id).subscribe(hero => { if (hero) { this.hero = hero; } else { @@ -50,7 +49,7 @@ export class HeroDetailComponent implements OnInit { } save(): void { - this.heroDetailService.saveHero(this.hero).then(() => this.gotoList()); + this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList()); } cancel() { this.gotoList(); } diff --git a/aio/content/examples/testing/src/app/hero/hero-detail.service.ts b/aio/content/examples/testing/src/app/hero/hero-detail.service.ts index f7dc887827..fc875f2df7 100644 --- a/aio/content/examples/testing/src/app/hero/hero-detail.service.ts +++ b/aio/content/examples/testing/src/app/hero/hero-detail.service.ts @@ -1,5 +1,8 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { map } from 'rxjs/operators'; + import { Hero } from '../model/hero'; import { HeroService } from '../model/hero.service'; @@ -10,13 +13,15 @@ export class HeroDetailService { // #enddocregion prototype // Returns a clone which caller may modify safely - getHero(id: number | string): Promise { + getHero(id: number | string): Observable { if (typeof id === 'string') { id = parseInt(id as string, 10); } - return this.heroService.getHero(id).then(hero => { - return hero ? Object.assign({}, hero) : null; // clone or null - }); + return this.heroService.getHero(id).pipe( + map(hero => { + return hero ? Object.assign({}, hero) : null; // clone or null + }) + ); } saveHero(hero: Hero) { diff --git a/aio/content/examples/testing/src/app/hero/hero-list.component.css b/aio/content/examples/testing/src/app/hero/hero-list.component.css index d939ab565d..35e45af98d 100644 --- a/aio/content/examples/testing/src/app/hero/hero-list.component.css +++ b/aio/content/examples/testing/src/app/hero/hero-list.component.css @@ -6,7 +6,7 @@ margin: 0 0 2em 0; list-style-type: none; padding: 0; - width: 10em; + width: 15em; } .heroes li { cursor: pointer; diff --git a/aio/content/examples/testing/src/app/hero/hero-list.component.spec.ts b/aio/content/examples/testing/src/app/hero/hero-list.component.spec.ts index b46492f443..d178fa6098 100644 --- a/aio/content/examples/testing/src/app/hero/hero-list.component.spec.ts +++ b/aio/content/examples/testing/src/app/hero/hero-list.component.spec.ts @@ -4,15 +4,18 @@ import { async, ComponentFixture, fakeAsync, TestBed, tick import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; -import { addMatchers, newEvent, Router, RouterStub -} from '../../testing'; +import { Router } from '@angular/router'; -import { HEROES, FakeHeroService } from '../model/testing'; +import { addMatchers, newEvent } from '../../testing'; + +import { getTestHeroes, TestHeroService } from '../model/testing/test-hero.service'; import { HeroModule } from './hero.module'; import { HeroListComponent } from './hero-list.component'; import { HighlightDirective } from '../shared/highlight.directive'; -import { HeroService } from '../model'; +import { HeroService } from '../model/hero.service'; + +const HEROES = getTestHeroes(); let comp: HeroListComponent; let fixture: ComponentFixture; @@ -22,13 +25,15 @@ let page: Page; describe('HeroListComponent', () => { - beforeEach( async(() => { + beforeEach(async(() => { addMatchers(); + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + TestBed.configureTestingModule({ imports: [HeroModule], providers: [ - { provide: HeroService, useClass: FakeHeroService }, - { provide: Router, useClass: RouterStub} + { provide: HeroService, useClass: TestHeroService }, + { provide: Router, useValue: routerSpy} ] }) .compileComponents() @@ -125,15 +130,14 @@ class Page { navSpy: jasmine.Spy; constructor() { - this.heroRows = fixture.debugElement.queryAll(By.css('li')).map(de => de.nativeElement); + const heroRowNodes = fixture.nativeElement.querySelectorAll('li'); + this.heroRows = Array.from(heroRowNodes); // Find the first element with an attached HighlightDirective this.highlightDe = fixture.debugElement.query(By.directive(HighlightDirective)); - // Get the component's injected router and spy on it - const router = fixture.debugElement.injector.get(Router); - this.navSpy = spyOn(router, 'navigate'); + // Get the component's injected router navigation spy + const routerSpy = fixture.debugElement.injector.get(Router); + this.navSpy = routerSpy.navigate as jasmine.Spy; }; } - - diff --git a/aio/content/examples/testing/src/app/hero/hero-list.component.ts b/aio/content/examples/testing/src/app/hero/hero-list.component.ts index 614976319b..e28d4b5c48 100644 --- a/aio/content/examples/testing/src/app/hero/hero-list.component.ts +++ b/aio/content/examples/testing/src/app/hero/hero-list.component.ts @@ -1,6 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; +import { Observable } from 'rxjs/Observable'; + import { Hero } from '../model/hero'; import { HeroService } from '../model/hero.service'; @@ -10,7 +12,7 @@ import { HeroService } from '../model/hero.service'; styleUrls: [ './hero-list.component.css' ] }) export class HeroListComponent implements OnInit { - heroes: Promise; + heroes: Observable; selectedHero: Hero; constructor( diff --git a/aio/content/examples/testing/src/app/in-memory-data.service.ts b/aio/content/examples/testing/src/app/in-memory-data.service.ts new file mode 100644 index 0000000000..81f26b674a --- /dev/null +++ b/aio/content/examples/testing/src/app/in-memory-data.service.ts @@ -0,0 +1,26 @@ +// #docregion , init +import { InMemoryDbService } from 'angular-in-memory-web-api'; +import { QUOTES } from './twain/twain.data'; + +// Adjust to reduce number of quotes +const maxQuotes = Infinity; // 0; + +/** Create in-memory database of heroes and quotes */ +export class InMemoryDataService implements InMemoryDbService { + createDb() { + const heroes = [ + { id: 11, name: 'Mr. Nice' }, + { id: 12, name: 'Narco' }, + { id: 13, name: 'Bombasto' }, + { id: 14, name: 'Celeritas' }, + { id: 15, name: 'Magneta' }, + { id: 16, name: 'RubberMan' }, + { id: 17, name: 'Dynama' }, + { id: 18, name: 'Dr IQ' }, + { id: 19, name: 'Magma' }, + { id: 20, name: 'Tornado' } + ]; + + return { heroes, quotes: QUOTES.slice(0, maxQuotes) }; + } +} diff --git a/aio/content/examples/testing/src/app/model/hero.service.spec.ts b/aio/content/examples/testing/src/app/model/hero.service.spec.ts new file mode 100644 index 0000000000..b228692154 --- /dev/null +++ b/aio/content/examples/testing/src/app/model/hero.service.spec.ts @@ -0,0 +1,215 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +// Other imports +import { TestBed } from '@angular/core/testing'; +import { HttpClient, HttpResponse, HttpErrorResponse } from '@angular/common/http'; + +import { asyncData, asyncError } from '../../testing/async-observable-helpers'; + +import { Hero } from './hero'; +import { HeroService } from './hero.service'; + +describe ('HeroesService (with spies)', () => { + // #docregion test-with-spies + let httpClientSpy: { get: jasmine.Spy }; + let heroService: HeroService; + + beforeEach(() => { + // Todo: spy on other methods too + httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']); + heroService = new HeroService( httpClientSpy); + }); + + it('should return expected heroes (HttpClient called once)', () => { + const expectedHeroes: Hero[] = + [{ id: 1, name: 'A' }, { id: 2, name: 'B' }]; + + httpClientSpy.get.and.returnValue(asyncData(expectedHeroes)); + + heroService.getHeroes().subscribe( + heroes => expect(heroes).toEqual(expectedHeroes, 'expected heroes'), + fail + ); + expect(httpClientSpy.get.calls.count()).toBe(1, 'one call'); + }); + + it('should return an error when the server returns a 404', () => { + const errorResponse = new HttpErrorResponse({ + error: 'test 404 error', + status: 404, statusText: 'Not Found' + }); + + httpClientSpy.get.and.returnValue(asyncError(errorResponse)); + + heroService.getHeroes().subscribe( + heroes => fail('expected an error, not heroes'), + error => expect(error.message).toContain('test 404 error') + ); + }); + // #enddocregion test-with-spies + +}); + +describe('HeroesService (with mocks)', () => { + let httpClient: HttpClient; + let httpTestingController: HttpTestingController; + let heroService: HeroService; + + beforeEach(() => { + TestBed.configureTestingModule({ + // Import the HttpClient mocking services + imports: [ HttpClientTestingModule ], + // Provide the service-under-test + providers: [ HeroService ] + }); + + // Inject the http, test controller, and service-under-test + // as they will be referenced by each test. + httpClient = TestBed.get(HttpClient); + httpTestingController = TestBed.get(HttpTestingController); + heroService = TestBed.get(HeroService); + }); + + afterEach(() => { + // After every test, assert that there are no more pending requests. + httpTestingController.verify(); + }); + + /// HeroService method tests begin /// + describe('#getHeroes', () => { + let expectedHeroes: Hero[]; + + beforeEach(() => { + heroService = TestBed.get(HeroService); + expectedHeroes = [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ] as Hero[]; + }); + + it('should return expected heroes (called once)', () => { + heroService.getHeroes().subscribe( + heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'), + fail + ); + + // HeroService should have made one request to GET heroes from expected URL + const req = httpTestingController.expectOne(heroService.heroesUrl); + expect(req.request.method).toEqual('GET'); + + // Respond with the mock heroes + req.flush(expectedHeroes); + }); + + it('should be OK returning no heroes', () => { + heroService.getHeroes().subscribe( + heroes => expect(heroes.length).toEqual(0, 'should have empty heroes array'), + fail + ); + + const req = httpTestingController.expectOne(heroService.heroesUrl); + req.flush([]); // Respond with no heroes + }); + + it('should turn 404 into a user-friendly error', () => { + const msg = 'Deliberate 404'; + heroService.getHeroes().subscribe( + heroes => fail('expected to fail'), + error => expect(error.message).toContain(msg) + ); + + const req = httpTestingController.expectOne(heroService.heroesUrl); + + // respond with a 404 and the error message in the body + req.flush(msg, {status: 404, statusText: 'Not Found'}); + }); + + it('should return expected heroes (called multiple times)', () => { + heroService.getHeroes().subscribe(); + heroService.getHeroes().subscribe(); + heroService.getHeroes().subscribe( + heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'), + fail + ); + + const requests = httpTestingController.match(heroService.heroesUrl); + expect(requests.length).toEqual(3, 'calls to getHeroes()'); + + // Respond to each request with different mock hero results + requests[0].flush([]); + requests[1].flush([{id: 1, name: 'bob'}]); + requests[2].flush(expectedHeroes); + }); + }); + + describe('#updateHero', () => { + // Expecting the query form of URL so should not 404 when id not found + const makeUrl = (id: number) => `${heroService.heroesUrl}/?id=${id}`; + + it('should update a hero and return it', () => { + + const updateHero: Hero = { id: 1, name: 'A' }; + + heroService.updateHero(updateHero).subscribe( + data => expect(data).toEqual(updateHero, 'should return the hero'), + fail + ); + + // HeroService should have made one request to PUT hero + const req = httpTestingController.expectOne(heroService.heroesUrl); + expect(req.request.method).toEqual('PUT'); + expect(req.request.body).toEqual(updateHero); + + // Expect server to return the hero after PUT + const expectedResponse = new HttpResponse( + { status: 200, statusText: 'OK', body: updateHero }); + req.event(expectedResponse); + }); + + it('should turn 404 error into user-facing error', () => { + const msg = 'Deliberate 404'; + const updateHero: Hero = { id: 1, name: 'A' }; + heroService.updateHero(updateHero).subscribe( + heroes => fail('expected to fail'), + error => expect(error.message).toContain(msg) + ); + + const req = httpTestingController.expectOne(heroService.heroesUrl); + + // respond with a 404 and the error message in the body + req.flush(msg, {status: 404, statusText: 'Not Found'}); + }); + + // #docregion network-error + it('should turn network error into user-facing error', () => { + const emsg = 'simulated network error'; + + const updateHero: Hero = { id: 1, name: 'A' }; + heroService.updateHero(updateHero).subscribe( + heroes => fail('expected to fail'), + error => expect(error.message).toContain(emsg) + ); + + const req = httpTestingController.expectOne(heroService.heroesUrl); + + // Create mock ErrorEvent, raised when something goes wrong at the network level. + // Connection timeout, DNS error, offline, etc + const errorEvent = new ErrorEvent('so sad', { + message: emsg, + // #enddocregion network-error + // The rest of this is optional and not used. + // Just showing that you could provide this too. + filename: 'HeroService.ts', + lineno: 42, + colno: 21 + // #docregion network-error + }); + + // Respond with mock error + req.error(errorEvent); + }); + // #enddocregion network-error + }); + + // TODO: test other HeroService methods +}); diff --git a/aio/content/examples/testing/src/app/model/hero.service.ts b/aio/content/examples/testing/src/app/model/hero.service.ts index 667d47312b..ac55cde5fc 100644 --- a/aio/content/examples/testing/src/app/model/hero.service.ts +++ b/aio/content/examples/testing/src/app/model/hero.service.ts @@ -1,30 +1,98 @@ import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http'; -import { Hero } from './hero'; -import { HEROES } from './test-heroes'; +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { catchError, map, tap } from 'rxjs/operators'; + +import { Hero } from './hero'; + +const httpOptions = { + headers: new HttpHeaders({ 'Content-Type': 'application/json' }) +}; @Injectable() -/** Dummy HeroService. Pretend it makes real http requests */ export class HeroService { - getHeroes() { - return Promise.resolve(HEROES); + + readonly heroesUrl = 'api/heroes'; // URL to web api + + constructor(private http: HttpClient) { } + + /** GET heroes from the server */ + getHeroes (): Observable { + return this.http.get(this.heroesUrl) + .pipe( + tap(heroes => this.log(`fetched heroes`)), + catchError(this.handleError('getHeroes')) + ) as Observable; } - getHero(id: number | string): Promise { + /** GET hero by id. Return `undefined` when id not found */ + getHero(id: number | string): Observable { if (typeof id === 'string') { id = parseInt(id as string, 10); } - return this.getHeroes().then( - heroes => heroes.find(hero => hero.id === id) + const url = `${this.heroesUrl}/?id=${id}`; + return this.http.get(url) + .pipe( + map(heroes => heroes[0]), // returns a {0|1} element array + tap(h => { + const outcome = h ? `fetched` : `did not find`; + this.log(`${outcome} hero id=${id}`); + }), + catchError(this.handleError(`getHero id=${id}`)) + ); + } + + //////// Save methods ////////// + + /** POST: add a new hero to the server */ + addHero (hero: Hero): Observable { + return this.http.post(this.heroesUrl, hero, httpOptions).pipe( + tap((hero: Hero) => this.log(`added hero w/ id=${hero.id}`)), + catchError(this.handleError('addHero')) + ); + } + /** DELETE: delete the hero from the server */ + deleteHero (hero: Hero | number): Observable { + const id = typeof hero === 'number' ? hero : hero.id; + const url = `${this.heroesUrl}/${id}`; + + return this.http.delete(url, httpOptions).pipe( + tap(_ => this.log(`deleted hero id=${id}`)), + catchError(this.handleError('deleteHero')) ); } - updateHero(hero: Hero): Promise { - return this.getHero(hero.id).then(h => { - if (!h) { - throw new Error(`Hero ${hero.id} not found`); - } - return Object.assign(h, hero); - }); + /** PUT: update the hero on the server */ + updateHero (hero: Hero): Observable { + return this.http.put(this.heroesUrl, hero, httpOptions).pipe( + tap(_ => this.log(`updated hero id=${hero.id}`)), + catchError(this.handleError('updateHero')) + ); + } + /** + * Returns a function that handles Http operation failures. + * This error handler lets the app continue to run as if no error occurred. + * @param operation - name of the operation that failed + */ + private handleError (operation = 'operation') { + return (error: HttpErrorResponse): Observable => { + + // TODO: send the error to remote logging infrastructure + console.error(error); // log to console instead + + const message = (error.error instanceof ErrorEvent) ? + error.error.message : + `server returned code ${error.status} with body "${error.error}"`; + + // TODO: better job of transforming error for user consumption + throw new Error(`${operation} failed: ${message}`); + }; + + } + + private log(message: string) { + console.log('HeroService: ' + message); } } diff --git a/aio/content/examples/testing/src/app/model/hero.spec.ts b/aio/content/examples/testing/src/app/model/hero.spec.ts deleted file mode 100644 index e8acf913f2..0000000000 --- a/aio/content/examples/testing/src/app/model/hero.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -// #docregion -import { Hero } from './hero'; - -describe('Hero', () => { - it('has name', () => { - const hero = new Hero(1, 'Super Cat'); - expect(hero.name).toBe('Super Cat'); - }); - - it('has id', () => { - const hero = new Hero(1, 'Super Cat'); - expect(hero.id).toBe(1); - }); - - it('can clone itself', () => { - const hero = new Hero(1, 'Super Cat'); - const clone = hero.clone(); - expect(hero).toEqual(clone); - }); -}); diff --git a/aio/content/examples/testing/src/app/model/hero.ts b/aio/content/examples/testing/src/app/model/hero.ts index 6a98f0dfdc..93e87f4770 100644 --- a/aio/content/examples/testing/src/app/model/hero.ts +++ b/aio/content/examples/testing/src/app/model/hero.ts @@ -1,4 +1,8 @@ -export class Hero { - constructor(public id = 0, public name = '') { } - clone() { return new Hero(this.id, this.name); } +export interface Hero { + id: number; + name: string; } + +// SystemJS bug: +// TS file must export something real in JS, not just interfaces +export const _dummy = undefined; diff --git a/aio/content/examples/testing/src/app/model/http-hero.service.spec.ts b/aio/content/examples/testing/src/app/model/http-hero.service.spec.ts index c16b421274..6973d01c15 100644 --- a/aio/content/examples/testing/src/app/model/http-hero.service.spec.ts +++ b/aio/content/examples/testing/src/app/model/http-hero.service.spec.ts @@ -1,3 +1,6 @@ +/** + * Test the HeroService when implemented with the OLD HttpModule + */ import { async, inject, TestBed } from '@angular/core/testing'; @@ -12,14 +15,11 @@ import { } from '@angular/http'; import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of'; - -import 'rxjs/add/operator/catch'; -import 'rxjs/add/operator/do'; -import 'rxjs/add/operator/toPromise'; +import { of } from 'rxjs/observable/of'; +import { catchError, tap } from 'rxjs/operators'; import { Hero } from './hero'; -import { HttpHeroService as HeroService } from './http-hero.service'; +import { HttpHeroService } from './http-hero.service'; const makeHeroData = () => [ { id: 1, name: 'Windstorm' }, @@ -29,99 +29,100 @@ const makeHeroData = () => [ ] as Hero[]; //////// Tests ///////////// -describe('Http-HeroService (mockBackend)', () => { +describe('HttpHeroService (using old HttpModule)', () => { + let backend: MockBackend; + let service: HttpHeroService; - beforeEach( async(() => { + beforeEach( () => { TestBed.configureTestingModule({ imports: [ HttpModule ], providers: [ - HeroService, + HttpHeroService, { provide: XHRBackend, useClass: MockBackend } ] - }) - .compileComponents(); - })); + }); + }); - it('can instantiate service when inject service', - inject([HeroService], (service: HeroService) => { - expect(service instanceof HeroService).toBe(true); - })); + it('can instantiate service via DI', () => { + service = TestBed.get(HttpHeroService); + expect(service instanceof HttpHeroService).toBe(true); + }); - - - it('can instantiate service with "new"', inject([Http], (http: Http) => { + it('can instantiate service with "new"', () => { + const http = TestBed.get(Http); expect(http).not.toBeNull('http should be provided'); - let service = new HeroService(http); - expect(service instanceof HeroService).toBe(true, 'new service should be ok'); - })); + let service = new HttpHeroService(http); + expect(service instanceof HttpHeroService).toBe(true, 'new service should be ok'); + }); - - it('can provide the mockBackend as XHRBackend', - inject([XHRBackend], (backend: MockBackend) => { - expect(backend).not.toBeNull('backend should be provided'); - })); + it('can provide the mockBackend as XHRBackend', () => { + const backend = TestBed.get(XHRBackend); + expect(backend).not.toBeNull('backend should be provided'); + }); describe('when getHeroes', () => { - let backend: MockBackend; - let service: HeroService; - let fakeHeroes: Hero[]; - let response: Response; + let fakeHeroes: Hero[]; + let http: Http; + let response: Response; - beforeEach(inject([Http, XHRBackend], (http: Http, be: MockBackend) => { - backend = be; - service = new HeroService(http); - fakeHeroes = makeHeroData(); - let options = new ResponseOptions({status: 200, body: {data: fakeHeroes}}); - response = new Response(options); - })); + beforeEach(() => { - it('should have expected fake heroes (then)', async(inject([], () => { - backend.connections.subscribe((c: MockConnection) => c.mockRespond(response)); + backend = TestBed.get(XHRBackend); + http = TestBed.get(Http); - service.getHeroes().toPromise() - // .then(() => Promise.reject('deliberate')) - .then(heroes => { - expect(heroes.length).toBe(fakeHeroes.length, - 'should have expected no. of heroes'); - }); - }))); + service = new HttpHeroService(http); + fakeHeroes = makeHeroData(); + let options = new ResponseOptions({status: 200, body: {data: fakeHeroes}}); + response = new Response(options); + }); - it('should have expected fake heroes (Observable.do)', async(inject([], () => { - backend.connections.subscribe((c: MockConnection) => c.mockRespond(response)); + it('should have expected fake heroes (then)', () => { + backend.connections.subscribe((c: MockConnection) => c.mockRespond(response)); - service.getHeroes() - .do(heroes => { - expect(heroes.length).toBe(fakeHeroes.length, - 'should have expected no. of heroes'); - }) - .toPromise(); - }))); + service.getHeroes().toPromise() + // .then(() => Promise.reject('deliberate')) + .then(heroes => { + expect(heroes.length).toBe(fakeHeroes.length, + 'should have expected no. of heroes'); + }) + .catch(fail); + }); + + it('should have expected fake heroes (Observable tap)', () => { + backend.connections.subscribe((c: MockConnection) => c.mockRespond(response)); + + service.getHeroes().subscribe( + heroes => { + expect(heroes.length).toBe(fakeHeroes.length, + 'should have expected no. of heroes'); + }, + fail + ); + }); - it('should be OK returning no heroes', async(inject([], () => { - let resp = new Response(new ResponseOptions({status: 200, body: {data: []}})); - backend.connections.subscribe((c: MockConnection) => c.mockRespond(resp)); + it('should be OK returning no heroes', () => { + let resp = new Response(new ResponseOptions({status: 200, body: {data: []}})); + backend.connections.subscribe((c: MockConnection) => c.mockRespond(resp)); - service.getHeroes() - .do(heroes => { - expect(heroes.length).toBe(0, 'should have no heroes'); - }) - .toPromise(); - }))); + service.getHeroes().subscribe( + heroes => { + expect(heroes.length).toBe(0, 'should have no heroes'); + }, + fail + ); + }); - it('should treat 404 as an Observable error', async(inject([], () => { - let resp = new Response(new ResponseOptions({status: 404})); - backend.connections.subscribe((c: MockConnection) => c.mockRespond(resp)); + it('should treat 404 as an Observable error', () => { + let resp = new Response(new ResponseOptions({status: 404})); + backend.connections.subscribe((c: MockConnection) => c.mockRespond(resp)); - service.getHeroes() - .do(heroes => { - fail('should not respond with heroes'); - }) - .catch(err => { - expect(err).toMatch(/Bad response status/, 'should catch bad response status code'); - return Observable.of(null); // failure is the expected test result - }) - .toPromise(); - }))); + service.getHeroes().subscribe( + heroes => fail('should not respond with heroes'), + err => { + expect(err).toMatch(/Bad response status/, 'should catch bad response status code'); + return of(null); // failure is the expected test result + }); + }); }); }); diff --git a/aio/content/examples/testing/src/app/model/http-hero.service.ts b/aio/content/examples/testing/src/app/model/http-hero.service.ts index a5fe46b801..11a08f3de3 100644 --- a/aio/content/examples/testing/src/app/model/http-hero.service.ts +++ b/aio/content/examples/testing/src/app/model/http-hero.service.ts @@ -1,3 +1,4 @@ +// The OLD Http module. See HeroService for use of the current HttpClient // #docplaster // #docregion import { Injectable } from '@angular/core'; @@ -5,12 +6,9 @@ import { Http, Response } from '@angular/http'; import { Headers, RequestOptions } from '@angular/http'; import { Hero } from './hero'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/throw'; - -import 'rxjs/add/operator/do'; -import 'rxjs/add/operator/catch'; -import 'rxjs/add/operator/map'; +import { Observable } from 'rxjs/Observable'; +import { ErrorObservable } from 'rxjs/observable/ErrorObservable'; +import { catchError, map, tap } from 'rxjs/operators'; @Injectable() export class HttpHeroService { @@ -19,16 +17,17 @@ export class HttpHeroService { constructor (private http: Http) {} getHeroes (): Observable { - return this.http.get(this._heroesUrl) - .map(this.extractData) - // .do(data => console.log(data)) // eyeball results in the console - .catch(this.handleError); + return this.http.get(this._heroesUrl).pipe( + map(this.extractData), + // tap(data => console.log(data)), // eyeball results in the console + catchError(this.handleError) + ); } getHero(id: number | string) { - return this.http - .get('app/heroes/?id=${id}') - .map((r: Response) => r.json().data as Hero[]); + return this.http.get('app/heroes/?id=${id}').pipe( + map((r: Response) => r.json().data as Hero[]) + ); } addHero (name: string): Observable { @@ -36,9 +35,10 @@ export class HttpHeroService { let headers = new Headers({ 'Content-Type': 'application/json' }); let options = new RequestOptions({ headers: headers }); - return this.http.post(this._heroesUrl, body, options) - .map(this.extractData) - .catch(this.handleError); + return this.http.post(this._heroesUrl, body, options).pipe( + map(this.extractData), + catchError(this.handleError) + ); } updateHero (hero: Hero): Observable { @@ -46,9 +46,10 @@ export class HttpHeroService { let headers = new Headers({ 'Content-Type': 'application/json' }); let options = new RequestOptions({ headers: headers }); - return this.http.put(this._heroesUrl, body, options) - .map(this.extractData) - .catch(this.handleError); + return this.http.put(this._heroesUrl, body, options).pipe( + map(this.extractData), + catchError(this.handleError) + ); } private extractData(res: Response) { @@ -63,6 +64,6 @@ export class HttpHeroService { // In a real world app, we might send the error to remote logging infrastructure let errMsg = error.message || 'Server error'; console.error(errMsg); // log to console instead - return Observable.throw(errMsg); + return new ErrorObservable(errMsg); } } diff --git a/aio/content/examples/testing/src/app/model/test-heroes.ts b/aio/content/examples/testing/src/app/model/test-heroes.ts deleted file mode 100644 index 2dc0f92502..0000000000 --- a/aio/content/examples/testing/src/app/model/test-heroes.ts +++ /dev/null @@ -1,11 +0,0 @@ -// #docregion -import { Hero } from './hero'; - -export const HEROES: Hero[] = [ - new Hero(11, 'Mr. Nice'), - new Hero(12, 'Narco'), - new Hero(13, 'Bombasto'), - new Hero(14, 'Celeritas'), - new Hero(15, 'Magneta'), - new Hero(16, 'RubberMan') -]; diff --git a/aio/content/examples/testing/src/app/model/testing/fake-hero.service.ts b/aio/content/examples/testing/src/app/model/testing/fake-hero.service.ts deleted file mode 100644 index 02e1484df4..0000000000 --- a/aio/content/examples/testing/src/app/model/testing/fake-hero.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -// re-export for tester convenience -export { Hero } from '../hero'; -export { HeroService } from '../hero.service'; - -import { Hero } from '../hero'; -import { HeroService } from '../hero.service'; - -export const HEROES: Hero[] = [ - new Hero(41, 'Bob'), - new Hero(42, 'Carol'), - new Hero(43, 'Ted'), - new Hero(44, 'Alice'), - new Hero(45, 'Speedy'), - new Hero(46, 'Stealthy') -]; - -export class FakeHeroService implements HeroService { - - heroes = HEROES.map(h => h.clone()); - lastPromise: Promise; // remember so we can spy on promise calls - - getHero(id: number | string) { - if (typeof id === 'string') { - id = parseInt(id as string, 10); - } - let hero = this.heroes.find(h => h.id === id); - return this.lastPromise = Promise.resolve(hero); - } - - getHeroes() { - return this.lastPromise = Promise.resolve(this.heroes); - } - - updateHero(hero: Hero): Promise { - return this.lastPromise = this.getHero(hero.id).then(h => { - return h ? - Object.assign(h, hero) : - Promise.reject(`Hero ${hero.id} not found`) as any as Promise; - }); - } -} diff --git a/aio/content/examples/testing/src/app/model/testing/http-client.spec.ts b/aio/content/examples/testing/src/app/model/testing/http-client.spec.ts new file mode 100644 index 0000000000..2c5b5ffd46 --- /dev/null +++ b/aio/content/examples/testing/src/app/model/testing/http-client.spec.ts @@ -0,0 +1,192 @@ +// #docplaster +// #docregion imports +// Http testing module and mocking controller +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +// Other imports +import { TestBed } from '@angular/core/testing'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; + +// #enddocregion imports +import { HttpHeaders } from '@angular/common/http'; + +interface Data { + name: string; +} + +const testUrl = '/data'; + +// #docregion setup +describe('HttpClient testing', () => { + let httpClient: HttpClient; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ HttpClientTestingModule ] + }); + + // Inject the http service and test controller for each test + httpClient = TestBed.get(HttpClient); + httpTestingController = TestBed.get(HttpTestingController); + }); + // #enddocregion setup + // #docregion afterEach + afterEach(() => { + // After every test, assert that there are no more pending requests. + httpTestingController.verify(); + }); + // #enddocregion afterEach + // #docregion setup + /// Tests begin /// + // #enddocregion setup + // #docregion get-test + it('can test HttpClient.get', () => { + const testData: Data = {name: 'Test Data'}; + + // Make an HTTP GET request + httpClient.get(testUrl) + .subscribe(data => + // When observable resolves, result should match test data + expect(data).toEqual(testData) + ); + + // The following `expectOne()` will match the request's URL. + // If no requests or multiple requests matched that URL + // `expectOne()` would throw. + const req = httpTestingController.expectOne('/data'); + + // Assert that the request is a GET. + expect(req.request.method).toEqual('GET'); + + // Respond with mock data, causing Observable to resolve. + // Subscribe callback asserts that correct data was returned. + req.flush(testData); + + // Finally, assert that there are no outstanding requests. + httpTestingController.verify(); + }); + // #enddocregion get-test + it('can test HttpClient.get with matching header', () => { + const testData: Data = {name: 'Test Data'}; + + // Make an HTTP GET request with specific header + httpClient.get(testUrl, { + headers: new HttpHeaders({'Authorization': 'my-auth-token'}) + }) + .subscribe(data => + expect(data).toEqual(testData) + ); + + // Find request with a predicate function. + // #docregion predicate + // Expect one request with an authorization header + const req = httpTestingController.expectOne( + req => req.headers.has('Authorization') + ); + // #enddocregion predicate + req.flush(testData); + }); + + it('can test multiple requests', () => { + let testData: Data[] = [ + { name: 'bob' }, { name: 'carol' }, + { name: 'ted' }, { name: 'alice' } + ]; + + // Make three requests in a row + httpClient.get(testUrl) + .subscribe(d => expect(d.length).toEqual(0, 'should have no data')); + + httpClient.get(testUrl) + .subscribe(d => expect(d).toEqual([testData[0]], 'should be one element array')); + + httpClient.get(testUrl) + .subscribe(d => expect(d).toEqual(testData, 'should be expected data')); + + // #docregion multi-request + // get all pending requests that match the given URL + const requests = httpTestingController.match(testUrl); + expect(requests.length).toEqual(3); + + // Respond to each request with different results + requests[0].flush([]); + requests[1].flush([testData[0]]); + requests[2].flush(testData); + // #enddocregion multi-request + }); + + // #docregion 404 + it('can test for 404 error', () => { + const emsg = 'deliberate 404 error'; + + httpClient.get(testUrl).subscribe( + data => fail('should have failed with the 404 error'), + (error: HttpErrorResponse) => { + expect(error.status).toEqual(404, 'status'); + expect(error.error).toEqual(emsg, 'message'); + } + ); + + const req = httpTestingController.expectOne(testUrl); + + // Respond with mock error + req.flush(emsg, { status: 404, statusText: 'Not Found' }); + }); + // #enddocregion 404 + + // #docregion network-error + it('can test for network error', () => { + const emsg = 'simulated network error'; + + httpClient.get(testUrl).subscribe( + data => fail('should have failed with the network error'), + (error: HttpErrorResponse) => { + expect(error.error.message).toEqual(emsg, 'message'); + } + ); + + const req = httpTestingController.expectOne(testUrl); + + // Create mock ErrorEvent, raised when something goes wrong at the network level. + // Connection timeout, DNS error, offline, etc + const errorEvent = new ErrorEvent('so sad', { + message: emsg, + // #enddocregion network-error + // The rest of this is optional and not used. + // Just showing that you could provide this too. + filename: 'HeroService.ts', + lineno: 42, + colno: 21 + // #docregion network-error + }); + + // Respond with mock error + req.error(errorEvent); + }); + // #enddocregion network-error + + it('httpTestingController.verify should fail if HTTP response not simulated', () => { + // Sends request + httpClient.get('some/api').subscribe(); + + // verify() should fail because haven't handled the pending request. + expect(() => httpTestingController.verify()).toThrow(); + + // Now get and flush the request so that afterEach() doesn't fail + const req = httpTestingController.expectOne('some/api'); + req.flush(null); + }); + + // Proves that verify in afterEach() really would catch error + // if test doesn't simulate the HTTP response. + // + // Must disable this test because can't catch an error in an afterEach(). + // Uncomment if you want to confirm that afterEach() does the job. + // it('afterEach() should fail when HTTP response not simulated',() => { + // // Sends request which is never handled by this test + // httpClient.get('some/api').subscribe(); + // }); +// #docregion setup +}); +// #enddocregion setup diff --git a/aio/content/examples/testing/src/app/model/testing/test-hero.service.ts b/aio/content/examples/testing/src/app/model/testing/test-hero.service.ts new file mode 100644 index 0000000000..14ad8453d6 --- /dev/null +++ b/aio/content/examples/testing/src/app/model/testing/test-hero.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs/Observable'; +import { asyncData } from '../../../testing'; + +import { map } from 'rxjs/operators'; + +// re-export for tester convenience +export { Hero } from '../hero'; +export { HeroService } from '../hero.service'; +export { getTestHeroes } from './test-heroes'; + +import { Hero } from '../hero'; +import { HeroService } from '../hero.service'; +import { getTestHeroes } from './test-heroes'; + +@Injectable() +/** + * FakeHeroService pretends to make real http requests. + * implements only as much of HeroService as is actually consumed by the app +*/ +export class TestHeroService extends HeroService { + + constructor() { + super(null); + } + + heroes = getTestHeroes(); + lastResult: Observable; // result from last method call + + addHero(hero: Hero): Observable { + throw new Error('Method not implemented.'); + } + + deleteHero(hero: number | Hero): Observable { + throw new Error('Method not implemented.'); + } + + getHeroes(): Observable { + return this.lastResult = asyncData(this.heroes); + } + + getHero(id: number | string): Observable { + if (typeof id === 'string') { + id = parseInt(id as string, 10); + } + let hero = this.heroes.find(h => h.id === id); + return this.lastResult = asyncData(hero); + } + + updateHero(hero: Hero): Observable { + return this.lastResult = this.getHero(hero.id).pipe( + map(h => { + if (h) { + return Object.assign(h, hero); + } + throw new Error(`Hero ${hero.id} not found`); + }) + ); + } +} diff --git a/aio/content/examples/testing/src/app/model/testing/test-heroes.ts b/aio/content/examples/testing/src/app/model/testing/test-heroes.ts new file mode 100644 index 0000000000..2af87f0e0d --- /dev/null +++ b/aio/content/examples/testing/src/app/model/testing/test-heroes.ts @@ -0,0 +1,13 @@ +import { Hero } from '../hero'; + +/** return fresh array of test heroes */ +export function getTestHeroes(): Hero[] { + return [ + {id: 41, name: 'Bob' }, + {id: 42, name: 'Carol' }, + {id: 43, name: 'Ted' }, + {id: 44, name: 'Alice' }, + {id: 45, name: 'Speedy' }, + {id: 46, name: 'Stealthy' } + ]; +} diff --git a/aio/content/examples/testing/src/app/shared/shared.module.ts b/aio/content/examples/testing/src/app/shared/shared.module.ts index 17c41c0410..cb2e6e7f90 100644 --- a/aio/content/examples/testing/src/app/shared/shared.module.ts +++ b/aio/content/examples/testing/src/app/shared/shared.module.ts @@ -4,12 +4,16 @@ import { FormsModule } from '@angular/forms'; import { HighlightDirective } from './highlight.directive'; import { TitleCasePipe } from './title-case.pipe'; -import { TwainComponent } from './twain.component'; @NgModule({ - imports: [ CommonModule ], - exports: [ CommonModule, FormsModule, - HighlightDirective, TitleCasePipe, TwainComponent ], - declarations: [ HighlightDirective, TitleCasePipe, TwainComponent ] + imports: [ CommonModule ], + exports: [ + CommonModule, + // SharedModule importers won't have to import FormsModule too + FormsModule, + HighlightDirective, + TitleCasePipe + ], + declarations: [ HighlightDirective, TitleCasePipe ] }) export class SharedModule { } diff --git a/aio/content/examples/testing/src/app/shared/twain.component.spec.ts b/aio/content/examples/testing/src/app/shared/twain.component.spec.ts deleted file mode 100644 index b177c0bfc3..0000000000 --- a/aio/content/examples/testing/src/app/shared/twain.component.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -// #docplaster -import { async, fakeAsync, ComponentFixture, TestBed, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; - -import { TwainService } from './twain.service'; -import { TwainComponent } from './twain.component'; - -describe('TwainComponent', () => { - - let comp: TwainComponent; - let fixture: ComponentFixture; - - let spy: jasmine.Spy; - let de: DebugElement; - let el: HTMLElement; - let twainService: TwainService; // the actually injected service - - const testQuote = 'Test Quote'; - - // #docregion setup - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ TwainComponent ], - providers: [ TwainService ], - }); - - fixture = TestBed.createComponent(TwainComponent); - comp = fixture.componentInstance; - - // TwainService actually injected into the component - twainService = fixture.debugElement.injector.get(TwainService); - - // Setup spy on the `getQuote` method - // #docregion spy - spy = spyOn(twainService, 'getQuote') - .and.returnValue(Promise.resolve(testQuote)); - // #enddocregion spy - - // Get the Twain quote element by CSS selector (e.g., by class name) - de = fixture.debugElement.query(By.css('.twain')); - el = de.nativeElement; - }); - // #enddocregion setup - - // #docregion tests - it('should not show quote before OnInit', () => { - expect(el.textContent).toBe('', 'nothing displayed'); - expect(spy.calls.any()).toBe(false, 'getQuote not yet called'); - }); - - it('should still not show quote after component initialized', () => { - fixture.detectChanges(); - // getQuote service is async => still has not returned with quote - expect(el.textContent).toBe('...', 'no quote yet'); - expect(spy.calls.any()).toBe(true, 'getQuote called'); - }); - - // #docregion async-test - it('should show quote after getQuote promise (async)', async(() => { - fixture.detectChanges(); - - fixture.whenStable().then(() => { // wait for async getQuote - fixture.detectChanges(); // update view with quote - expect(el.textContent).toBe(testQuote); - }); - })); - // #enddocregion async-test - - // #docregion fake-async-test - it('should show quote after getQuote promise (fakeAsync)', fakeAsync(() => { - fixture.detectChanges(); - tick(); // wait for async getQuote - fixture.detectChanges(); // update view with quote - expect(el.textContent).toBe(testQuote); - })); - // #enddocregion fake-async-test - // #enddocregion tests - - // #docregion done-test - it('should show quote after getQuote promise (done)', (done: any) => { - fixture.detectChanges(); - - // get the spy promise and wait for it to resolve - spy.calls.mostRecent().returnValue.then(() => { - fixture.detectChanges(); // update view with quote - expect(el.textContent).toBe(testQuote); - done(); - }); - }); - // #enddocregion done-test -}); diff --git a/aio/content/examples/testing/src/app/shared/twain.component.timer.spec.ts.no-work b/aio/content/examples/testing/src/app/shared/twain.component.timer.spec.ts.no-work deleted file mode 100644 index 74dec3e766..0000000000 --- a/aio/content/examples/testing/src/app/shared/twain.component.timer.spec.ts.no-work +++ /dev/null @@ -1,116 +0,0 @@ -// #docplaster -// When AppComponent learns to present quote with intervalTimer -import { async, discardPeriodicTasks, fakeAsync, ComponentFixture, TestBed, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; - -import { TwainService } from './model'; -import { TwainComponent } from './twain.component'; - -xdescribe('TwainComponent', () => { - - let comp: TwainComponent; - let fixture: ComponentFixture; - - const quotes = [ - 'Test Quote 1', - 'Test Quote 2', - 'Test Quote 3' - ]; - - let spy: jasmine.Spy; - let twainEl: DebugElement; // the element with the Twain quote - let twainService: TwainService; // the actually injected service - - function getQuote() { return twainEl.nativeElement.textContent; } - - // #docregion setup - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ TwainComponent ], - providers: [ TwainService ], - }); - - fixture = TestBed.createComponent(TwainComponent); - comp = fixture.componentInstance; - - // TwainService actually injected into the component - twainService = fixture.debugElement.injector.get(TwainService); - - // Setup spy on the `getQuote` method - spy = spyOn(twainService, 'getQuote') - .and.returnValues(...quotes.map(q => Promise.resolve(q))); - - // Get the Twain quote element by CSS selector (e.g., by class name) - twainEl = fixture.debugElement.query(By.css('.twain')); - }); - - afterEach(() => { - // destroy component to stop the component timer - fixture.destroy(); - }); - // #enddocregion setup - - // #docregion tests - it('should not show quote before OnInit', () => { - expect(getQuote()).toBe(''); - }); - - it('should still not show quote after component initialized', () => { - // because the getQuote service is async - fixture.detectChanges(); // trigger data binding - expect(getQuote()).toContain('not initialized'); - }); - - // WIP - // If go this way, add jasmine.clock().uninstall(); to afterEach - // it('should show quote after Angular "settles"', async(() => { - // //jasmine.clock().install(); - // fixture.detectChanges(); // trigger data binding - // fixture.whenStable().then(() => { - // fixture.detectChanges(); // update view with the quote - // expect(getQuote()).toBe(quotes[0]); - // }); - // // jasmine.clock().tick(5000); - // // fixture.whenStable().then(() => { - // // fixture.detectChanges(); // update view with the quote - // // expect(getQuote()).toBe(quotes[1]); - // // }); - // })); - - it('should show quote after getQuote promise returns', fakeAsync(() => { - fixture.detectChanges(); // trigger data binding - tick(); // wait for first async getQuote to return - fixture.detectChanges(); // update view with the quote - expect(getQuote()).toBe(quotes[0]); - - // destroy component to stop the component timer before test ends - // else test errors because still have timer in the queue - fixture.destroy(); - })); - - it('should show 2nd quote after 5 seconds pass', fakeAsync(() => { - fixture.detectChanges(); // trigger data binding - tick(5000); // wait for second async getQuote to return - fixture.detectChanges(); // update view with the quote - expect(getQuote()).toBe(quotes[1]); - - // still have intervalTimer queuing requres - // discardPeriodicTasks() else test errors - discardPeriodicTasks(); - })); - - fit('should show 3rd quote after 10 seconds pass', fakeAsync(() => { - fixture.detectChanges(); // trigger data binding - tick(5000); // wait for second async getQuote to return - fixture.detectChanges(); // update view with the 2nd quote - tick(5000); // wait for third async getQuote to return - fixture.detectChanges(); // update view with the 3rd quote - expect(getQuote()).toBe(quotes[2]); - - // still have intervalTimer queuing requres - // discardPeriodicTasks() else test errors - discardPeriodicTasks(); - })); - // #enddocregion tests -}); diff --git a/aio/content/examples/testing/src/app/shared/twain.component.timer.ts.no-work b/aio/content/examples/testing/src/app/shared/twain.component.timer.ts.no-work deleted file mode 100644 index d3dc1f205d..0000000000 --- a/aio/content/examples/testing/src/app/shared/twain.component.timer.ts.no-work +++ /dev/null @@ -1,27 +0,0 @@ -// #docregion -import { Component, OnInit, OnDestroy } from '@angular/core'; - -import { TwainService } from './twain.service'; - -@Component({ - selector: 'twain-quote', - template: '

{{quote}}

' -}) -export class TwainComponent implements OnInit, OnDestroy { - intervalId: number; - quote = '-- not initialized yet --'; - constructor(private twainService: TwainService) { } - - getQuote() { - this.twainService.getQuote().then(quote => this.quote = quote); - } - - ngOnInit(): void { - this.getQuote(); - this.intervalId = window.setInterval(() => this.getQuote(), 5000); - } - - ngOnDestroy(): void { - clearInterval(this.intervalId); - } -} diff --git a/aio/content/examples/testing/src/app/shared/twain.component.ts b/aio/content/examples/testing/src/app/shared/twain.component.ts deleted file mode 100644 index 29f24459ab..0000000000 --- a/aio/content/examples/testing/src/app/shared/twain.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -// #docregion -import { Component, OnInit } from '@angular/core'; - -import { TwainService } from './twain.service'; - -// #docregion component -@Component({ - selector: 'twain-quote', - template: '

{{quote}}

' -}) -export class TwainComponent implements OnInit { - intervalId: number; - quote = '...'; - constructor(private twainService: TwainService) { } - - ngOnInit(): void { - this.twainService.getQuote().then(quote => this.quote = quote); - } -} -// #enddocregion component diff --git a/aio/content/examples/testing/src/app/shared/twain.service.ts b/aio/content/examples/testing/src/app/shared/twain.service.ts deleted file mode 100644 index 9e394df1ee..0000000000 --- a/aio/content/examples/testing/src/app/shared/twain.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from '@angular/core'; - -const quotes = [ -'Always do right. This will gratify some people and astonish the rest.', -'I have never let my schooling interfere with my education.', -'Don\'t go around saying the world owes you a living. The world owes you nothing. It was here first.', -'Whenever you find yourself on the side of the majority, it is time to pause and reflect.', -'If you tell the truth, you don\'t have to remember anything.', -'Clothes make the man. Naked people have little or no influence on society.', -'It\'s not the size of the dog in the fight, it\'s the size of the fight in the dog.', -'Truth is stranger than fiction, but it is because Fiction is obliged to stick to possibilities; Truth isn\'t.', -'The man who does not read good books has no advantage over the man who cannot read them.', -'Get your facts first, and then you can distort them as much as you please.', -]; - -@Injectable() -export class TwainService { - private next = 0; - - // Imaginary todo: get quotes from a remote quote service - // returns quote after delay simulating server latency - getQuote(): Promise { - return new Promise(resolve => { - setTimeout( () => resolve(this.nextQuote()), 500 ); - }); - } - - private nextQuote() { - if (this.next === quotes.length) { this.next = 0; } - return quotes[ this.next++ ]; - } -} diff --git a/aio/content/examples/testing/src/app/twain/quote.ts b/aio/content/examples/testing/src/app/twain/quote.ts new file mode 100644 index 0000000000..803cae0f60 --- /dev/null +++ b/aio/content/examples/testing/src/app/twain/quote.ts @@ -0,0 +1,4 @@ +export class Quote { + id: number; + quote: string; +} diff --git a/aio/content/examples/testing/src/app/twain/twain.component.marbles.spec.ts b/aio/content/examples/testing/src/app/twain/twain.component.marbles.spec.ts new file mode 100644 index 0000000000..7e2926001d --- /dev/null +++ b/aio/content/examples/testing/src/app/twain/twain.component.marbles.spec.ts @@ -0,0 +1,93 @@ +// #docplaster +import { async, fakeAsync, ComponentFixture, TestBed, tick } from '@angular/core/testing'; + +// #docregion import-marbles +import { cold, getTestScheduler } from 'jasmine-marbles'; +// #enddocregion import-marbles + +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; + +import { ErrorObservable } from 'rxjs/observable/ErrorObservable'; +import { last } from 'rxjs/operators'; + +import { TwainService } from './twain.service'; +import { TwainComponent } from './twain.component'; + + +describe('TwainComponent (marbles)', () => { + let component: TwainComponent; + let fixture: ComponentFixture; + 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(() => { + // Create a fake TwainService object with a `getQuote()` spy + const twainService = jasmine.createSpyObj('TwainService', ['getQuote']); + getQuoteSpy = twainService.getQuote; + + TestBed.configureTestingModule({ + declarations: [ TwainComponent ], + providers: [ + { provide: TwainService, useValue: twainService } + ] + }); + + fixture = TestBed.createComponent(TwainComponent); + component = fixture.componentInstance; + quoteEl = fixture.nativeElement.querySelector('.twain'); + testQuote = 'Test Quote'; + }); + + // A synchronous test that simulates async behavior + // #docregion get-quote-test + it('should show quote after getQuote (marbles)', () => { + // observable test quote value and complete(), after delay + // #docregion test-quote-marbles + const q$ = cold('---x|', { x: testQuote }); + // #enddocregion test-quote-marbles + getQuoteSpy.and.returnValue( q$ ); + + fixture.detectChanges(); // ngOnInit() + expect(quoteEl.textContent).toBe('...', 'should show placeholder'); + + // #docregion test-scheduler-flush + getTestScheduler().flush(); // flush the observables + // #enddocregion test-scheduler-flush + + fixture.detectChanges(); // update view + + expect(quoteEl.textContent).toBe(testQuote, 'should show quote'); + expect(errorMessage()).toBeNull('should not show error'); + }); + // #enddocregion get-quote-test + + // Still need fakeAsync() because of component's setTimeout() + // #docregion error-test + it('should display error when TwainService fails', fakeAsync(() => { + // observable error after delay + // #docregion error-marbles + const q$ = cold('---#|', null, new Error('TwainService test failure')); + // #enddocregion error-marbles + getQuoteSpy.and.returnValue( q$ ); + + fixture.detectChanges(); // ngOnInit() + expect(quoteEl.textContent).toBe('...', 'should show placeholder'); + + getTestScheduler().flush(); // flush the observables + tick(); // component shows error after a setTimeout() + fixture.detectChanges(); // update error message + + expect(errorMessage()).toMatch(/test failure/, 'should display error'); + expect(quoteEl.textContent).toBe('...', 'should show placeholder'); + })); + // #enddocregion error-test +}); diff --git a/aio/content/examples/testing/src/app/twain/twain.component.spec.ts b/aio/content/examples/testing/src/app/twain/twain.component.spec.ts new file mode 100644 index 0000000000..ae71da68f5 --- /dev/null +++ b/aio/content/examples/testing/src/app/twain/twain.component.spec.ts @@ -0,0 +1,184 @@ +// #docplaster +import { async, fakeAsync, ComponentFixture, TestBed, tick } from '@angular/core/testing'; + +import { asyncData, asyncError } from '../../testing'; + +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; + +import { ErrorObservable } from 'rxjs/observable/ErrorObservable'; +import { last } from 'rxjs/operators'; + +import { TwainService } from './twain.service'; +import { TwainComponent } from './twain.component'; + +describe('TwainComponent', () => { + let component: TwainComponent; + let fixture: ComponentFixture; + 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; + }; + + // #docregion setup + beforeEach(() => { + testQuote = 'Test Quote'; + + // #docregion spy + // Create a fake TwainService object with a `getQuote()` spy + const twainService = jasmine.createSpyObj('TwainService', ['getQuote']); + // Make the spy return a synchronous Observable with the test data + getQuoteSpy = twainService.getQuote.and.returnValue( of(testQuote) ); + // #enddocregion spy + + TestBed.configureTestingModule({ + declarations: [ TwainComponent ], + providers: [ + { provide: TwainService, useValue: twainService } + ] + }); + + fixture = TestBed.createComponent(TwainComponent); + component = fixture.componentInstance; + quoteEl = fixture.nativeElement.querySelector('.twain'); + }); + // #enddocregion setup + + describe('when test with synchronous observable', () => { + it('should not show quote before OnInit', () => { + expect(quoteEl.textContent).toBe('', 'nothing displayed'); + expect(errorMessage()).toBeNull('should not show error element'); + expect(getQuoteSpy.calls.any()).toBe(false, 'getQuote not yet called'); + }); + + // The quote would not be immediately available if the service were truly async. + // #docregion sync-test + it('should show quote after component initialized', () => { + fixture.detectChanges(); // onInit() + + // sync spy result shows testQuote immediately after init + expect(quoteEl.textContent).toBe(testQuote); + expect(getQuoteSpy.calls.any()).toBe(true, 'getQuote called'); + }); + // #enddocregion sync-test + + + // The error would not be immediately available if the service were truly async. + // Use `fakeAsync` because the component error calls `setTimeout` + // #docregion error-test + it('should display error when TwainService fails', fakeAsync(() => { + // tell spy to return an error observable + getQuoteSpy.and.returnValue( + new ErrorObservable('TwainService test failure')); + + fixture.detectChanges(); // onInit() + // sync spy errors immediately after init + + tick(); // flush the component's setTimeout() + + fixture.detectChanges(); // update errorMessage within setTimeout() + + expect(errorMessage()).toMatch(/test failure/, 'should display error'); + expect(quoteEl.textContent).toBe('...', 'should show placeholder'); + })); + // #enddocregion error-test + }); + + describe('when test with asynchronous observable', () => { + beforeEach(() => { + // #docregion async-setup + // Simulate delayed observable values with the `asyncData()` helper + getQuoteSpy.and.returnValue(asyncData(testQuote)); + // #enddocregion async-setup + }); + + it('should not show quote before OnInit', () => { + expect(quoteEl.textContent).toBe('', 'nothing displayed'); + expect(errorMessage()).toBeNull('should not show error element'); + expect(getQuoteSpy.calls.any()).toBe(false, 'getQuote not yet called'); + }); + + 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).toBe('...', 'should show placeholder'); + expect(errorMessage()).toBeNull('should not show error'); + expect(getQuoteSpy.calls.any()).toBe(true, 'getQuote called'); + }); + + // #docregion fake-async-test + it('should show quote after getQuote (fakeAsync)', fakeAsync(() => { + fixture.detectChanges(); // ngOnInit() + expect(quoteEl.textContent).toBe('...', 'should show placeholder'); + + tick(); // flush the observable to get the quote + fixture.detectChanges(); // update view + + expect(quoteEl.textContent).toBe(testQuote, 'should show quote'); + expect(errorMessage()).toBeNull('should not show error'); + })); + // #enddocregion fake-async-test + + // #docregion async-test + it('should show quote after getQuote (async)', async(() => { + fixture.detectChanges(); // ngOnInit() + expect(quoteEl.textContent).toBe('...', 'should show placeholder'); + + fixture.whenStable().then(() => { // wait for async getQuote + fixture.detectChanges(); // update view with quote + expect(quoteEl.textContent).toBe(testQuote); + expect(errorMessage()).toBeNull('should not show error'); + }); + })); + // #enddocregion async-test + + + // #docregion quote-done-test + it('should show last quote (quote done)', (done: DoneFn) => { + fixture.detectChanges(); + + component.quote.pipe( last() ).subscribe(() => { + fixture.detectChanges(); // update view with quote + expect(quoteEl.textContent).toBe(testQuote); + expect(errorMessage()).toBeNull('should not show error'); + done(); + }); + }); + // #enddocregion quote-done-test + + // #docregion spy-done-test + it('should show quote after getQuote (spy done)', (done: DoneFn) => { + fixture.detectChanges(); + + // the spy's most recent call returns the observable with the test quote + getQuoteSpy.calls.mostRecent().returnValue.subscribe(() => { + fixture.detectChanges(); // update view with quote + expect(quoteEl.textContent).toBe(testQuote); + expect(errorMessage()).toBeNull('should not show error'); + done(); + }); + }); + // #enddocregion spy-done-test + + // #docregion async-error-test + it('should display error when TwainService fails', fakeAsync(() => { + // tell spy to return an async error observable + getQuoteSpy.and.returnValue(asyncError('TwainService test failure')); + + fixture.detectChanges(); + tick(); // component shows error after a setTimeout() + fixture.detectChanges(); // update error message + + expect(errorMessage()).toMatch(/test failure/, 'should display error'); + expect(quoteEl.textContent).toBe('...', 'should show placeholder'); + })); + // #enddocregion async-error-test + }); +}); diff --git a/aio/content/examples/testing/src/app/twain/twain.component.ts b/aio/content/examples/testing/src/app/twain/twain.component.ts new file mode 100644 index 0000000000..6e40b6dd54 --- /dev/null +++ b/aio/content/examples/testing/src/app/twain/twain.component.ts @@ -0,0 +1,49 @@ +// #docregion +import { Component, OnInit } from '@angular/core'; + +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { catchError, startWith } from 'rxjs/operators'; + +import { TwainService } from './twain.service'; + +// #docregion component +@Component({ + selector: 'twain-quote', + // #docregion template + template: ` +

{{quote | async}}

+ +

{{ errorMessage }}

`, + // #enddocregion template + styles: [ + `.twain { font-style: italic; } .error { color: red; }` + ] + +}) +export class TwainComponent implements OnInit { + errorMessage: string; + quote: Observable; + + constructor(private twainService: TwainService) {} + + ngOnInit(): void { + this.getQuote(); + } + + // #docregion get-quote + getQuote() { + this.errorMessage = ''; + this.quote = this.twainService.getQuote().pipe( + startWith('...'), + catchError( (err: any) => { + // Wait a turn because errorMessage already set once this turn + setTimeout(() => this.errorMessage = err.message || err.toString()); + return of('...'); // reset message to placeholder + }) + ); + // #enddocregion get-quote + } + +} +// #enddocregion component diff --git a/aio/content/examples/testing/src/app/twain/twain.data.ts b/aio/content/examples/testing/src/app/twain/twain.data.ts new file mode 100644 index 0000000000..a228ef5bbc --- /dev/null +++ b/aio/content/examples/testing/src/app/twain/twain.data.ts @@ -0,0 +1,15 @@ +import { Quote } from './quote'; + +export const QUOTES: Quote[] = [ + 'Always do right. This will gratify some people and astonish the rest.', + 'I have never let my schooling interfere with my education.', + 'Don\'t go around saying the world owes you a living. The world owes you nothing. It was here first.', + 'Whenever you find yourself on the side of the majority, it is time to pause and reflect.', + 'If you tell the truth, you don\'t have to remember anything.', + 'Clothes make the man. Naked people have little or no influence on society.', + 'It\'s not the size of the dog in the fight, it\'s the size of the fight in the dog.', + 'Truth is stranger than fiction, but it is because Fiction is obliged to stick to possibilities; Truth isn\'t.', + 'The man who does not read good books has no advantage over the man who cannot read them.', + 'Get your facts first, and then you can distort them as much as you please.', +] +.map((q, i) => ({ id: i + 1, quote: q })); diff --git a/aio/content/examples/testing/src/app/twain/twain.service.ts b/aio/content/examples/testing/src/app/twain/twain.service.ts new file mode 100644 index 0000000000..da4386f5b1 --- /dev/null +++ b/aio/content/examples/testing/src/app/twain/twain.service.ts @@ -0,0 +1,47 @@ +// Mark Twain Quote service gets quotes from server +import { Injectable } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; + +import { Observable } from 'rxjs/Observable'; +import { ErrorObservable } from 'rxjs/observable/ErrorObservable'; +import { of } from 'rxjs/observable/of'; +import { concat, map, retryWhen, switchMap, take, tap } from 'rxjs/operators'; + +import { Quote } from './quote'; + +@Injectable() +export class TwainService { + constructor(private http: HttpClient) { } + + private nextId = 1; + + getQuote(): Observable { + return Observable.create(observer => observer.next(this.nextId++)).pipe( + + // tap((id: number) => console.log(id)), + // tap((id: number) => { throw new Error('Simulated server error'); }), + + switchMap((id: number) => this.http.get(`api/quotes/${id}`)), + // tap((q : Quote) => console.log(q)), + map((q: Quote) => q.quote), + + // `errors` is observable of http.get errors + retryWhen(errors => errors.pipe( + switchMap((error: HttpErrorResponse) => { + if (error.status === 404) { + // Queried for quote that doesn't exist. + this.nextId = 1; // retry with quote id:1 + return of(null); // signal OK to retry + } + // Some other HTTP error. + console.error(error); + return new ErrorObservable('Cannot get Twain quotes from the server'); + }), + take(2), + // If a second retry value, then didn't find id:1 and triggers the following error + concat(new ErrorObservable('There are no Twain quotes')) // didn't find id:1 + )) + ); + } +} + diff --git a/aio/content/examples/testing/src/app/welcome.component.spec.ts b/aio/content/examples/testing/src/app/welcome/welcome.component.spec.ts similarity index 67% rename from aio/content/examples/testing/src/app/welcome.component.spec.ts rename to aio/content/examples/testing/src/app/welcome/welcome.component.spec.ts index e506dda396..25f3b28c77 100644 --- a/aio/content/examples/testing/src/app/welcome.component.spec.ts +++ b/aio/content/examples/testing/src/app/welcome/welcome.component.spec.ts @@ -1,26 +1,66 @@ // #docplaster import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; -import { UserService } from './model'; +import { UserService } from '../model/user.service'; import { WelcomeComponent } from './welcome.component'; +// #docregion mock-user-service +class MockUserService { + isLoggedIn = true; + user = { name: 'Test User'}; +}; +// #enddocregion mock-user-service + +describe('WelcomeComponent (class only)', () => { + let comp: WelcomeComponent; + let userService: UserService; + + // #docregion class-only-before-each + beforeEach(() => { + TestBed.configureTestingModule({ + // provide the component-under-test and dependent service + providers: [ + WelcomeComponent, + { provide: UserService, useClass: MockUserService } + ] + }); + // inject both the component and the dependent service. + comp = TestBed.get(WelcomeComponent); + userService = TestBed.get(UserService); + }); + // #enddocregion class-only-before-each + + // #docregion class-only-tests + it('should not have welcome message after construction', () => { + expect(comp.welcome).toBeUndefined(); + }); + + it('should welcome logged in user after Angular calls ngOnInit', () => { + comp.ngOnInit(); + expect(comp.welcome).toContain(userService.user.name); + }); + + it('should ask user to log in if not logged in after ngOnInit', () => { + userService.isLoggedIn = false; + comp.ngOnInit(); + expect(comp.welcome).not.toContain(userService.user.name); + expect(comp.welcome).toContain('log in'); + }); + // #enddocregion class-only-tests +}); + describe('WelcomeComponent', () => { let comp: WelcomeComponent; let fixture: ComponentFixture; let componentUserService: UserService; // the actually injected service let userService: UserService; // the TestBed injected service - let de: DebugElement; // the DebugElement with the welcome message let el: HTMLElement; // the DOM element with the welcome message - let userServiceStub: { - isLoggedIn: boolean; - user: { name: string} - }; + // #docregion setup, user-service-stub + let userServiceStub: Partial; - // #docregion setup + // #enddocregion user-service-stub beforeEach(() => { // stub UserService for test purposes // #docregion user-service-stub @@ -57,8 +97,7 @@ describe('WelcomeComponent', () => { // #enddocregion inject-from-testbed // get the "welcome" element by CSS selector (e.g., by class name) - de = fixture.debugElement.query(By.css('.welcome')); - el = de.nativeElement; + el = fixture.nativeElement.querySelector('.welcome'); }); // #enddocregion setup @@ -85,12 +124,10 @@ describe('WelcomeComponent', () => { }); // #enddocregion tests - // #docregion inject-it it('should inject the component\'s UserService instance', inject([UserService], (service: UserService) => { expect(service).toBe(componentUserService); })); - // #enddocregion inject-it it('TestBed and Component UserService should be the same', () => { expect(userService === componentUserService).toBe(true); diff --git a/aio/content/examples/testing/src/app/welcome.component.ts b/aio/content/examples/testing/src/app/welcome/welcome.component.ts similarity index 50% rename from aio/content/examples/testing/src/app/welcome.component.ts rename to aio/content/examples/testing/src/app/welcome/welcome.component.ts index 9bc5ca33c4..802f30cc4a 100644 --- a/aio/content/examples/testing/src/app/welcome.component.ts +++ b/aio/content/examples/testing/src/app/welcome/welcome.component.ts @@ -1,19 +1,20 @@ // #docregion import { Component, OnInit } from '@angular/core'; +import { UserService } from '../model/user.service'; -import { UserService } from './model/user.service'; - +// #docregion component @Component({ selector: 'app-welcome', - template: '

{{welcome}}

' + template: '

{{welcome}}

' }) +// #docregion class export class WelcomeComponent implements OnInit { - welcome = '-- not initialized yet --'; + welcome: string; constructor(private userService: UserService) { } ngOnInit(): void { this.welcome = this.userService.isLoggedIn ? - 'Welcome, ' + this.userService.user.name : - 'Please log in.'; + 'Welcome, ' + this.userService.user.name : 'Please log in.'; } } +// #enddocregion component, class diff --git a/aio/content/examples/testing/src/bag.html b/aio/content/examples/testing/src/bag.html deleted file mode 100644 index 3e0fcb9025..0000000000 --- a/aio/content/examples/testing/src/bag.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - Specs Bag - - - - - - - - - - - - - - - - Loading ... - - diff --git a/aio/content/examples/testing/src/testing/activated-route-stub.ts b/aio/content/examples/testing/src/testing/activated-route-stub.ts new file mode 100644 index 0000000000..04fb7cb92b --- /dev/null +++ b/aio/content/examples/testing/src/testing/activated-route-stub.ts @@ -0,0 +1,29 @@ +// export for convenience. +export { ActivatedRoute } from '@angular/router'; + +// #docregion activated-route-stub +import { ReplaySubject } from 'rxjs/ReplaySubject'; +import { convertToParamMap, ParamMap, Params } from '@angular/router'; + +/** + * An ActivateRoute test double with a `paramMap` observable. + * Use the `setParamMap()` method to add the next `paramMap` value. + */ +export class ActivatedRouteStub { + // Use a ReplaySubject to share previous values with subscribers + // and pump new values into the `paramMap` observable + private subject = new ReplaySubject(); + + constructor(initialParams?: Params) { + this.setParamMap(initialParams); + } + + /** The mock paramMap observable */ + readonly paramMap = this.subject.asObservable(); + + /** Set the paramMap observables's next value */ + setParamMap(params?: Params) { + this.subject.next(convertToParamMap(params)); + }; +} +// #enddocregion activated-route-stub diff --git a/aio/content/examples/testing/src/testing/async-observable-helpers.ts b/aio/content/examples/testing/src/testing/async-observable-helpers.ts new file mode 100644 index 0000000000..3282bcbe37 --- /dev/null +++ b/aio/content/examples/testing/src/testing/async-observable-helpers.ts @@ -0,0 +1,30 @@ +/* +* 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 { Observable } from 'rxjs/Observable'; +import { defer } from 'rxjs/observable/defer'; + +// #docregion async-data +/** Create async observable that emits-once and completes + * after a JS engine turn */ +export function asyncData(data: T) { + return defer(() => Promise.resolve(data)); +} +// #enddocregion async-data + +// #docregion async-error +/** Create async observable error that errors + * after a JS engine turn */ +export function asyncError(errorObject: any) { + return defer(() => Promise.reject(errorObject)); +} +// #enddocregion async-error diff --git a/aio/content/examples/testing/src/testing/index.ts b/aio/content/examples/testing/src/testing/index.ts index e3de5164ca..1fddaf8c4d 100644 --- a/aio/content/examples/testing/src/testing/index.ts +++ b/aio/content/examples/testing/src/testing/index.ts @@ -1,8 +1,10 @@ import { DebugElement } from '@angular/core'; import { tick, ComponentFixture } from '@angular/core/testing'; +export * from './async-observable-helpers'; +export * from './activated-route-stub'; export * from './jasmine-matchers'; -export * from './router-stubs'; +export * from './router-link-directive-stub'; ///// Short utilities ///// diff --git a/aio/content/examples/testing/src/testing/router-link-directive-stub.ts b/aio/content/examples/testing/src/testing/router-link-directive-stub.ts new file mode 100644 index 0000000000..761529d726 --- /dev/null +++ b/aio/content/examples/testing/src/testing/router-link-directive-stub.ts @@ -0,0 +1,30 @@ +import { Directive, Input } from '@angular/core'; + +// export for convenience. +export { RouterLink} from '@angular/router'; + +/* tslint:disable:directive-class-suffix */ +// #docregion router-link +@Directive({ + selector: '[routerLink]', + host: { '(click)': 'onClick()' } +}) +export class RouterLinkDirectiveStub { + @Input('routerLink') linkParams: any; + navigatedTo: any = null; + + onClick() { + this.navigatedTo = this.linkParams; + } +} +// #enddocregion router-link + +/// Dummy module to satisfy Angular Language service. Never used. +import { NgModule } from '@angular/core'; + +@NgModule({ + declarations: [ + RouterLinkDirectiveStub + ] +}) +export class RouterStubsModule {} diff --git a/aio/content/examples/testing/src/testing/router-stubs.ts b/aio/content/examples/testing/src/testing/router-stubs.ts deleted file mode 100644 index 880302c7be..0000000000 --- a/aio/content/examples/testing/src/testing/router-stubs.ts +++ /dev/null @@ -1,58 +0,0 @@ - // export for convenience. -export { ActivatedRoute, Router, RouterLink, RouterOutlet} from '@angular/router'; - -import { Component, Directive, Injectable, Input } from '@angular/core'; -import { NavigationExtras } from '@angular/router'; - -// #docregion router-link -@Directive({ - selector: '[routerLink]', - host: { - '(click)': 'onClick()' - } -}) -export class RouterLinkStubDirective { - @Input('routerLink') linkParams: any; - navigatedTo: any = null; - - onClick() { - this.navigatedTo = this.linkParams; - } -} -// #enddocregion router-link - -@Component({selector: 'router-outlet', template: ''}) -export class RouterOutletStubComponent { } - -@Injectable() -export class RouterStub { - navigate(commands: any[], extras?: NavigationExtras) { } -} - - -// Only implements params and part of snapshot.paramMap -// #docregion activated-route-stub -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; -import { convertToParamMap, ParamMap } from '@angular/router'; - -@Injectable() -export class ActivatedRouteStub { - - // ActivatedRoute.paramMap is Observable - private subject = new BehaviorSubject(convertToParamMap(this.testParamMap)); - paramMap = this.subject.asObservable(); - - // Test parameters - private _testParamMap: ParamMap; - get testParamMap() { return this._testParamMap; } - set testParamMap(params: {}) { - this._testParamMap = convertToParamMap(params); - this.subject.next(this._testParamMap); - } - - // ActivatedRoute.snapshot.paramMap - get snapshot() { - return { paramMap: this.testParamMap }; - } -} -// #enddocregion activated-route-stub diff --git a/aio/content/examples/testing/src/tests.html b/aio/content/examples/testing/src/tests.html new file mode 100644 index 0000000000..c988c457cb --- /dev/null +++ b/aio/content/examples/testing/src/tests.html @@ -0,0 +1,64 @@ + + + + + + + Sample App Specs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/aio/content/examples/testing/src/tests.sb.ts b/aio/content/examples/testing/src/tests.sb.ts index e424b91d4f..3f1c8fe61a 100644 --- a/aio/content/examples/testing/src/tests.sb.ts +++ b/aio/content/examples/testing/src/tests.sb.ts @@ -1,18 +1,26 @@ // Import spec files individually for Stackblitz -import './app/about.component.spec.ts'; -import './app/app.component.spec.ts'; -import './app/app.component.router.spec.ts'; -import './app/banner.component.spec.ts'; -import './app/banner.component.detect-changes.spec.ts'; -import './app/banner-inline.component.spec.ts'; -import './app/dashboard/dashboard.component.spec.ts'; -import './app/dashboard/dashboard.component.no-testbed.spec.ts'; -import './app/dashboard/dashboard-hero.component.spec.ts'; -import './app/hero/hero-list.component.spec.ts'; -import './app/hero/hero-detail.component.spec.ts'; -import './app/hero/hero-detail.component.no-testbed.spec.ts'; -import './app/model/hero.spec.ts'; -import './app/model/http-hero.service.spec.ts'; -import './app/shared/title-case.pipe.spec.ts'; -import './app/shared/twain.component.spec.ts'; -import './app/welcome.component.spec.ts'; +import 'app/about/about.component.spec.ts'; +import 'app/app-initial.component.spec.ts'; +import 'app/app.component.router.spec.ts'; +import 'app/app.component.spec.ts'; +import 'app/banner/banner-initial.component.spec.ts'; +import 'app/banner/banner.component.spec.ts'; +import 'app/banner/banner.component.detect-changes.spec.ts'; +import 'app/banner/banner-external.component.spec.ts'; +import 'app/dashboard/dashboard-hero.component.spec.ts'; +import 'app/dashboard/dashboard.component.no-testbed.spec.ts'; +import 'app/dashboard/dashboard.component.spec.ts'; +import 'app/demo/async-helper.spec.ts'; +import 'app/demo/demo.spec.ts'; +import 'app/demo/demo.testbed.spec.ts'; +import 'app/hero/hero-detail.component.no-testbed.spec.ts'; +import 'app/hero/hero-detail.component.spec.ts'; +import 'app/hero/hero-list.component.spec.ts'; +import 'app/model/hero.service.spec.ts'; +import 'app/model/http-hero.service.spec.ts'; +import 'app/model/testing/http-client.spec.ts'; +import 'app/shared/highlight.directive.spec.ts'; +import 'app/shared/title-case.pipe.spec.ts'; +import 'app/twain/twain.component.spec.ts'; +import 'app/twain/twain.component.marbles.spec.ts'; +import 'app/welcome/welcome.component.spec.ts'; diff --git a/aio/content/examples/upgrade-phonecat-2-hybrid/karma-test-shim.1.js b/aio/content/examples/upgrade-phonecat-2-hybrid/karma-test-shim.1.js index 19fcc89fe9..b92085c014 100644 --- a/aio/content/examples/upgrade-phonecat-2-hybrid/karma-test-shim.1.js +++ b/aio/content/examples/upgrade-phonecat-2-hybrid/karma-test-shim.1.js @@ -37,6 +37,7 @@ System.config({ map: { '@angular/core/testing': 'npm:@angular/core/bundles/core-testing.umd.js', '@angular/common/testing': 'npm:@angular/common/bundles/common-testing.umd.js', + '@angular/common/http/testing': 'npm:@angular/common/bundles/common-http-testing.umd.js', '@angular/compiler/testing': 'npm:@angular/compiler/bundles/compiler-testing.umd.js', '@angular/platform-browser/testing': 'npm:@angular/platform-browser/bundles/platform-browser-testing.umd.js', '@angular/platform-browser-dynamic/testing': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic-testing.umd.js', diff --git a/aio/content/guide/change-log.md b/aio/content/guide/change-log.md index 854f93010e..2818161737 100644 --- a/aio/content/guide/change-log.md +++ b/aio/content/guide/change-log.md @@ -83,8 +83,8 @@ HTTP guide. ## Testing: added component test plunkers (2016-12-02) -Added two plunkers that each test _one simple component_ so you can write a component test plunker of your own: one for the QuickStart seed's `AppComponent` and another for the Testing guide's `BannerComponent`. -Linked to these plunkers in [Testing](guide/testing#live-examples) and [Setup anatomy](guide/setup-systemjs-anatomy) guides. +Added two plunkers that each test _one simple component_ so you can write a component test plunker of your own: one for the QuickStart seed's `AppComponent` and another for the Testing guide's `BannerComponent`. +Linked to these plunkers in "Testing" and "Setup anatomy" guides. ## Internationalization: pluralization and _select_ (2016-11-30) diff --git a/aio/content/guide/testing.md b/aio/content/guide/testing.md index 22a31252f6..22894476e2 100644 --- a/aio/content/guide/testing.md +++ b/aio/content/guide/testing.md @@ -1,549 +1,755 @@ +{@a top} # Testing -This guide offers tips and techniques for testing Angular applications. -Though this page includes some general testing principles and techniques, -the focus is on testing applications written with Angular. +This guide offers tips and techniques for unit and integration testing Angular applications. +The guide presents tests of a sample CLI application that is much like the [_Tour of Heroes_ tutorial](tutorial). +The sample application and all tests in this guide are available for inspection and experimentation: -{@a top} +* Sample app +* Tests -## Live examples +
-This guide presents tests of a sample application that is much like the [_Tour of Heroes_ tutorial](tutorial). -The sample application and all tests in this guide are available as live examples for inspection, experiment, and download: +## Setup -* The sample application to be tested. -* All specs that test the sample application. +The Angular CLI downloads and install everything you need to test an Angular application with the [Jasmine test framework](http://jasmine.github.io/2.4/introduction.html). -
+The project you create with the CLI is immediately ready to test. +Just run this one CLI command: + + ng test + +The `ng test` command builds the app in _watch mode_, +and launches the [karma test runner](https://karma-runner.github.io/1.0/index.html). -{@a testing-intro} +The console output looks a bit like this: + +10% building modules 1/1 modules 0 active +...INFO [karma]: Karma v1.7.1 server started at http://0.0.0.0:9876/ +...INFO [launcher]: Launching browser Chrome ... +...INFO [launcher]: Starting browser Chrome +...INFO [Chrome ...]: Connected on socket ... +Chrome ...: Executed 3 of 3 SUCCESS (0.135 secs / 0.205 secs) + -## Introduction to Angular Testing +The last line of the log is the most important. +It shows that Karma ran three tests that all passed. -This page guides you through writing tests to explore -and confirm the behavior of the application. Testing -does the following: +A chrome browser also opens and displays the test output in the "Jasmine HTML Reporter" like this. -1. Guards against changes that break existing code (“regressions”). +
+ Jasmine HTML Reporter in the browser +
-1. Clarifies what the code does both when used as intended and when faced with deviant conditions. +Most people find this browser output easier to read than the console log. +You can click on a test row to re-run just that test or click on a description to re-run the tests in the selected test group ("test suite"). -1. Reveals mistakes in design and implementation. -Tests shine a harsh light on the code from many angles. -When a part of the application seems hard to test, the root cause is often a design flaw, -something to cure now rather than later when it becomes expensive to fix. +Meanwhile, the `ng test` command is watching for changes. - +To see this in action, make a small change to `app.component.ts` and save. +The tests run again, the browser refreshes, and the new test results appear. +#### Configuration -{@a tools-and-tech} +The CLI takes care of Jasmine and karma configuration for you. +You can fine-tune many options by editing the `karma.conf.js` file in the project root folder and +the `test.ts` file in the `src/` folder. -### Tools and technologies +The `karma.conf.js` file is a partial karma configuration file. +The CLI constructs the full runtime configuration in memory,based on application structure specified in the `.angular-cli.json` file, supplemented by `karma.conf.js`. -You can write and run Angular tests with a variety of tools and technologies. -This guide describes specific choices that are known to work well. +Search the web for more details about Jasmine and karma configuration. +#### Other test frameworks - +You can also unit test an Angular app with other testing libraries and test runners. +Each library and runner has its own distinctive installation procedures, configuration, and syntax. - +Search the web to learn more. - +#### Test file name and location - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Technology - - Purpose -
- Jasmine - - - - The [Jasmine test framework](http://jasmine.github.io/2.4/introduction.html) - provides everything needed to write basic tests. - It ships with an HTML test runner that executes tests in the browser. -
- Angular testing utilities - - - - Angular testing utilities create a test environment - for the Angular application code under test. - Use them to condition and control parts of the application as they - interact _within_ the Angular environment. -
- Karma - - - - The [karma test runner](https://karma-runner.github.io/1.0/index.html) - is ideal for writing and running unit tests while developing the application. - It can be an integral part of the project's development and continuous integration processes. - This guide describes how to set up and run tests with karma. -
- Protractor - - - - Use protractor to write and run _end-to-end_ (e2e) tests. - End-to-end tests explore the application _as users experience it_. - In e2e testing, one process runs the real application - and a second process runs protractor tests that simulate user behavior - and assert that the application respond in the browser as expected. - -
- - - -{@a setup} - - -### Setup - -There are two fast paths to getting started with unit testing. - -1. Start a new project following the instructions in [Setup](guide/setup "Setup"). - -1. Start a new project with the -Angular CLI. - -Both approaches install npm packages, files, and scripts pre-configured for applications -built in their respective modalities. -Their artifacts and procedures differ slightly but their essentials are the same -and there are no differences in the test code. - -In this guide, the application and its tests are based on the [setup instructions](guide/setup "Setup"). -For a discussion of the unit testing setup files, [see below](guide/testing#setup-files). - - -{@a isolated-v-testing-utilities} - - -### Isolated unit tests vs. the Angular testing utilities - -[Isolated unit tests](guide/testing#isolated-unit-tests "Unit testing without the Angular testing utilities") -examine an instance of a class all by itself without any dependence on Angular or any injected values. -The tester creates a test instance of the class with `new`, supplying test doubles for the constructor parameters as needed, and -then probes the test instance API surface. - -*You should write isolated unit tests for pipes and services.* - -You can test components in isolation as well. -However, isolated unit tests don't reveal how components interact with Angular. -In particular, they can't reveal how a component class interacts with its own template or with other components. - -Such tests require the **Angular testing utilities**. -The Angular testing utilities include the `TestBed` class and several helper functions from `@angular/core/testing`. -They are the main focus of this guide and you'll learn about them -when you write your [first component test](guide/testing#simple-component-test). -A comprehensive review of the Angular testing utilities appears [later in this guide](guide/testing#atu-apis). - -But first you should write a dummy test to verify that your test environment is set up properly -and to lock in a few basic testing skills. - -
- - - -{@a 1st-karma-test} - - -## The first karma test - -Start with a simple test to make sure that the setup works properly. - -Create a new file called `1st.spec.ts` in the application root folder, `src/app/` +Look inside the `src/app` folder. +The CLI generated a test file for the `AppComponent` named `app.component.spec.ts`.
- - -Tests written in Jasmine are called _specs_ . -**The filename extension must be `.spec.ts`**, -the convention adhered to by `karma.conf.js` and other tooling. - +The test file extension **must be `.spec.ts`** so that tooling can identify it as a file with tests (AKA, a _spec_ file).
+The `app.component.ts` and `app.component.spec.ts` files are siblings in the same folder. +The root file names (`app.component`) are the same for both files. +Adopt these two conventions in your own projects for _every kind_ of test file. -**Put spec files somewhere within the `src/app/` folder.** -The `karma.conf.js` tells karma to look for spec files there, -for reasons explained [below](guide/testing#q-spec-file-location). +## Service Tests -Add the following code to `src/app/1st.spec.ts`. +Services are often the easiest files to unit test. +Here are some synchronous and asynchronous unit tests of the `ValueService` +written without assistance from Angular testing utilities. - + -{@a run-karma} +{@a services-with-dependencies} +#### Services with dependencies -### Run with karma -Compile and run it in karma from the command line using the following command: +Services often depend on other services that Angular injects into the constructor. +In many cases, it easy to create and _inject_ these dependencies by hand while +calling the service's constructor. - - npm test - +The `MasterService` is a simple example: + +`MasterService` delegates its only method, `getValue`, to the injected `ValueService`. -The command compiles the application and test code and starts karma. -Both processes watch pertinent files, write messages to the console, and re-run when they detect changes. +Here are several ways to test it. -
+ +The first test creates a `ValueService` with `new` and passes it to the `MasterService` constructor. +However, injecting the real service rarely works well as most dependent services are difficult to create and control. -The documentation setup defines the `test` command in the `scripts` section of npm's `package.json`. -The Angular CLI has different commands to do the same thing. Adjust accordingly. - -
- - - -After a few moments, karma opens a browser and starts writing to the console. - -
- Karma browser -
- - - -Hide (don't close!) the browser and focus on the console output, which -should look something like this: - - - - > npm test - ... - [0] 1:37:03 PM - Compilation complete. Watching for file changes. - ... - [1] Chrome 51.0.2704: Executed 0 of 0 SUCCESS - Chrome 51.0.2704: Executed 1 of 1 SUCCESS - SUCCESS (0.005 secs / 0.005 secs) - - - - - -Both the compiler and karma continue to run. The compiler output is preceded by `[0]`; -the karma output by `[1]`. - -Change the expectation from `true` to `false`. - -The _compiler_ watcher detects the change and recompiles. - - - - [0] 1:49:21 PM - File change detected. Starting incremental compilation... - [0] 1:49:25 PM - Compilation complete. Watching for file changes. - - - - - -The _karma_ watcher detects the change to the compilation output and re-runs the test. - - - [1] Chrome 51.0.2704 1st tests true is true FAILED - [1] Expected false to equal true. - [1] Chrome 51.0.2704: Executed 1 of 1 (1 FAILED) (0.005 secs / 0.005 secs) - - - - - -It fails of course. - -Restore the expectation from `false` back to `true`. -Both processes detect the change, re-run, and karma reports complete success. - +Instead you can mock the dependency, use a dummy value, or create a +[spy](https://jasmine.github.io/2.0/introduction.html#section-Spies) +on the pertinent service method.
- - -The console log can be quite long. Keep your eye on the last line. -When all is well, it reads `SUCCESS`. - +Prefer spies as they are usually the easiest way to mock services.
+These standard testing techniques are great for unit testing services in isolation. +However, you almost always inject service into application classes using Angular +dependency injection and you should have tests that reflect that usage pattern. +Angular testing utilities make it easy to investigate how injected services behave. -{@a test-debugging} +#### Testing services with the _TestBed_ +Your app relies on Angular [dependency injection (DI)](guide/dependency-injection) +to create services. +When a service has a dependent service, DI finds or creates that dependent service. +And if that dependent service has its own dependencies, DI finds-or-creates them as well. -### Test debugging - -Debug specs in the browser in the same way that you debug an application. - - 1. Reveal the karma browser window (hidden earlier). - 1. Click the **DEBUG** button; it opens a new browser tab and re-runs the tests. - 1. Open the browser's “Developer Tools” (`Ctrl-Shift-I` on windows; `Command-Option-I` in OSX). - 1. Pick the "sources" section. - 1. Open the `1st.spec.ts` test file (Control/Command-P, then start typing the name of the file). - 1. Set a breakpoint in the test. - 1. Refresh the browser, and it stops at the breakpoint. - - -
- Karma debugging -
- - -
- - - -{@a simple-component-test} - - -## Test a component - -An Angular component is the first thing most developers want to test. -The `BannerComponent` in `src/app/banner-inline.component.ts` is the simplest component in this application and -a good place to start. -It presents the application title at the top of the screen within an `

` tag. - - - - - -This version of the `BannerComponent` has an inline template and an interpolation binding. -The component is probably too simple to be worth testing in real life but -it's perfect for a first encounter with the Angular testing utilities. - -The corresponding `src/app/banner-inline.component.spec.ts` sits in the same folder as the component, -for reasons explained in the [FAQ](guide/testing#faq) answer to -["Why put specs next to the things they test?"](guide/testing#q-spec-file-location). - -Start with ES6 import statements to get access to symbols referenced in the spec. - - - - - -{@a configure-testing-module} - - -Here's the `describe` and the `beforeEach` that precedes the tests: - - - +As service _consumer_, you don't worry about any of this. +You don't worry about the order of constructor arguments or how they're created. +As a service _tester_, you must at least think about the first level of service dependencies +but you _can_ let Angular DI do the service creation and deal with constructor argument order +when you use the `TestBed` testing utility to provide and create services. {@a testbed} +#### Angular _TestBed_ -### _TestBed_ +The `TestBed` is the most important of the Angular testing utilities. +The `TestBed` creates a dynamically-constructed Angular _test_ module that emulates +an Angular [@NgModule](guide/ngmodules). -`TestBed` is the first and most important of the Angular testing utilities. -It creates an Angular testing module—an `@NgModule` class—that -you configure with the `configureTestingModule` method to produce the module environment for the class you want to test. -In effect, you detach the tested component from its own application module -and re-attach it to a dynamically-constructed Angular test module -tailored specifically for this battery of tests. +The `TestBed.configureTestingModule()` method takes a metadata object that can have most of the properties of an [@NgModule](guide/ngmodules). -The `configureTestingModule` method takes an `@NgModule`-like metadata object. -The metadata object can have most of the properties of a normal [NgModule](guide/ngmodules). +To test a service, you set the `providers` metadata property with an +array of the services that you'll test or mock. -_This metadata object_ simply declares the component to test, `BannerComponent`. -The metadata lack `imports` because (a) the default testing module configuration already has what `BannerComponent` needs -and (b) `BannerComponent` doesn't interact with any other components. + + +Then inject it inside a test by calling `TestBed.get()` with the service class as the argument. -Call `configureTestingModule` within a `beforeEach` so that -`TestBed` can reset itself to a base state before each test runs. + + -The base state includes a default testing module configuration consisting of the -declarables (components, directives, and pipes) and providers (some of them mocked) -that almost everyone needs. +Or inside the `beforeEach()` if you prefer to inject the service as part of your setup. -
+ + +When testing a service with a dependency, provide the mock in the `providers` array. +In the following example, the mock is a spy object. -The testing shims mentioned [later](guide/testing#testbed-methods) initialize the testing module configuration -to something like the `BrowserModule` from `@angular/platform-browser`. + + -
+The test consumes that spy in the same way it did earlier. + + +{@a no-before-each} +#### Testing without _beforeEach()_ -This default configuration is merely a _foundation_ for testing an app. -Later you'll call `TestBed.configureTestingModule` with more metadata that define additional -imports, declarations, providers, and schemas to fit your application tests. -Optional `override` methods can fine-tune aspects of the configuration. +Most test suites in this guide call `beforeEach()` to set the preconditions for each `it()` test +and rely on the `TestBed` to create classes and inject services. +There's another school of testing that never calls `beforeEach()` and +and prefers to create classes explicitly rather than use the `TestBed`. -{@a create-component} +Here's how you might rewrite one of the `MasterService` tests in that style. +Begin by putting re-usable, preparatory code in a _setup_ function instead of `beforeEach()`. -### _createComponent_ + + -After configuring `TestBed`, you tell it to create an instance of the _component-under-test_. -In this example, `TestBed.createComponent` creates an instance of `BannerComponent` and -returns a [_component test fixture_](guide/testing#component-fixture). +The `setup()` function returns an object literal +with the variables, such as `masterService`, that a test might reference. +You don't define _semi-global_ variables (e.g., `let masterService: MasterService`) +in the body of the `describe()`. +Then each test invokes `setup()` in its first line, before continuing +with steps that manipulate the test subject and assert expectations. + + + + +Notice how the test uses +[_destructuring assignment_](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) +to extract the setup variables that it needs. + + + + +Many developers feel this approach is cleaner and more explicit than the +traditional `beforeEach()` style. + +Although this testing guide follows the tradition style and +the default [CLI schematics](https://github.com/angular/devkit) +generate test files with `beforeEach()` and `TestBed`, +feel free to adopt _this alternative approach_ in your own projects. + +#### Testing HTTP services + +Data services that make HTTP calls to remote servers typically inject and delegate +to the Angular [`HttpClient`](guide/http) service for XHR calls. + +You can test a data service with an injected `HttpClient` spy as you would +test any service with a dependency. + +
+The `HeroService` methods return _Observables_. +_Subscribe_ to the method observable to (a) cause it to execute and (b) +assert that the method succeeds or fails. +The `subscribe()` method takes a success and fail callback. +Make sure you provide _both_ callbacks so that you capture errors. -Do not re-configure `TestBed` after calling `createComponent`. +Neglecting to do so produces an asynchronous uncaught observable error that +the test runner will likely attribute to a completely different test. +
+ +#### _HttpClientTestingModule_ + +Extended interactions between a data service and the `HttpClient` can be complex +and difficult to mock with spies. + +The `HttpClientTestingModule` can make these testing scenarios more manageable. + +While the _code sample_ accompanying this guide demonstrates `HttpClientTestingModule`, +this page defers to the [Http guide](guide/http#testing-http-requests), +which covers testing with the `HttpClientTestingModule` in detail. + +
+ +This guide's sample code also demonstrates testing of the _legacy_ `HttpModule` +in `app/model/http-hero.service.spec.ts`.
+## Component Test Basics -The `createComponent` method closes the current `TestBed` instance to further configuration. -You cannot call any more `TestBed` configuration methods, not `configureTestingModule` -nor any of the `override...` methods. If you try, `TestBed` throws an error. +A component, unlike all other parts of an Angular application, +combines an HTML template and a TypeScript class. +The component truly is the template and the class _working together_. +and to adequately test a component, you should test that they work together +as intended. +Such tests require creating the component's host element in the browser DOM, +as Angular does, and investigating the component class's interaction with +the DOM as described by its template. -{@a component-fixture} +The Angular `TestBed` facilitates this kind of testing as you'll see in the sections below. +But in many cases, _testing the component class alone_, without DOM involvement, +can validate much of the component's behavior in an easier, more obvious way. +### Component class testing -### _ComponentFixture_, _DebugElement_, and _query(By.css)_ +Test a component class on its own as you would test a service class. -The `createComponent` method returns a **`ComponentFixture`**, a handle on the test environment surrounding the created component. -The fixture provides access to the component instance itself and -to the **`DebugElement`**, which is a handle on the component's DOM element. +Consider this `LightswitchComponent` which toggles a light on and off +(represented by an on-screen message) when the user clicks the button. -The `title` property value is interpolated into the DOM within `

` tags. -Use the fixture's `DebugElement` to `query` for the `

` element by CSS selector. + + -The **`query`** method takes a predicate function and searches the fixture's entire DOM tree for the -_first_ element that satisfies the predicate. -The result is a _different_ `DebugElement`, one associated with the matching DOM element. +You might decide only to test that the `clicked()` method +toggles the light's _on/off_ state and sets the message appropriately. + +This component class has no dependencies. +To test a service with no dependencies, you create it with `new`, poke at its API, +and assert expectations on its public state. +Do the same with the component class. + + + + +Here is the `DashboardHeroComponent` from the _Tour of Heroes_ tutorial. + + + + +It appears within the template of a parent component, +which binds a _hero_ to the `@Input` property and +listens for an event raised through the _selected_ `@Output` property. + +You can test that the class code works without creating the the `DashboardHeroComponent` +or its parent component. + + + + +When a component has dependencies, you may wish to use the `TestBed` to both +create the component and its dependencies. + +The following `WelcomeComponent` depends on the `UserService` to know the name of the user to greet. + + + + +You might start by creating a mock of the `UserService` that meets the minimum needs of this component. + + + + +Then provide and inject _both the_ **component** _and the service_ in the `TestBed` configuration. + + + + +Then exercise the component class, remembering to call the [lifecycle hook methods](guide/lifecycle-hooks) as Angular does when running the app. + + + + +### Component DOM testing + +Testing the component _class_ is as easy as testing a service. + +But a component is more than just its class. +A component interacts with the DOM and with other components. +The _class-only_ tests can tell you about class behavior. +They cannot tell you if the component is going to render properly, +respond to user input and gestures, or integrate with its parent and and child components. + +None of the _class-only_ tests above can answer key questions about how the +components actually behave on screen. + +* Is `Lightswitch.clicked()` bound to anything such that the user can invoke it? +* Is the `Lightswitch.message` displayed? +* Can the user actually select the hero displayed by `DashboardHeroComponent`? +* Is the hero name displayed as expected (i.e, in uppercase)? +* Is the welcome message displayed by the template of `WelcomeComponent`? + +These may not be troubling questions for the simple components illustrated above. +But many components have complex interactions with the DOM elements +described in their templates, causing HTML to appear and disappear as +the component state changes. + +To answer these kinds of questions, you have to create the DOM elements associated +with the components, you must examine the DOM to confirm that component state +displays properly at the appropriate times, and you must simulate user interaction +with the screen to determine whether those interactions cause the component to +behave as expected. + +To write these kinds of test, you'll use additional features of the `TestBed` +as well as other testing helpers. + +#### CLI-generated tests + +The CLI creates an initial test file for you by default when you ask it to +generate a new component. + +For example, the following CLI command generates a `BannerComponent` in the `app/banner` folder (with inline template and styles): + + +ng generate component banner --inline-template --inline-style --module app + + +It also generates an initial test file for the component, `banner-external.component.spec.ts`, that looks like this: + + + + +#### Reduce the setup + +Only the last three lines of this file actually test the component +and all they do is assert that Angular can create the component. + +The rest of the file is boilerplate setup code anticipating more advanced tests that _might_ become necessary if the component evolves into something substantial. + +You'll learn about these advanced test features below. +For now, you can radically reduce this test file to a more manageable size: + + + + +In this example, the metadata object passed to `TestBed.configureTestingModule` +simply declares `BannerComponent`, the component to test. + + +
+There's no need to declare or import anything else. +The default test module is pre-configured with +something like the `BrowserModule` from `@angular/platform-browser`. - -The `queryAll` method returns an array of _all_ `DebugElements` that satisfy the predicate. - -A _predicate_ is a function that returns a boolean. -A query predicate receives a `DebugElement` and returns `true` if the element meets the selection criteria. - +Later you'll call `TestBed.configureTestingModule()` with +imports, providers, and more declarations to suit your testing needs. +Optional `override` methods can further fine-tune aspects of the configuration.
+{@a create-component} +#### _createComponent()_ -The **`By`** class is an Angular testing utility that produces useful predicates. -Its `By.css` static method produces a -standard CSS selector -predicate that filters the same way as a jQuery selector. +After configuring `TestBed`, you call its `createComponent()` method. -Finally, the setup assigns the DOM element from the `DebugElement` **`nativeElement`** property to `el`. -The tests assert that `el` contains the expected title text. + + +`TestBed.createComponent()` creates an instance of the `BannerComponent`, +adds a corresponding element to the test-runner DOM, +and returns a [`ComponentFixture`](#component-fixture). -{@a the-tests} +
+Do not re-configure `TestBed` after calling `createComponent`. -### The tests +The `createComponent` method freezes the current `TestBed`definition, +closing it to further configuration. -Jasmine runs the `beforeEach` function before each of these tests +You cannot call any more `TestBed` configuration methods, not `configureTestingModule()`, +nor `get()`, nor any of the `override...` methods. +If you try, `TestBed` throws an error. - +
+{@a component-fixture} +#### _ComponentFixture_ -These tests ask the `DebugElement` for the native HTML element to satisfy their expectations. +The [ComponentFixture](api/core/testing/ComponentFixture) is a test harness for interacting with the created component and its corresponding element. +Access the component instance through the fixture and confirm it exists with a Jasmine expectation: + + + + +#### _beforeEach()_ + +You will add more tests as this component evolves. +Rather than duplicate the `TestBed` configuration for each test, +you refactor to pull the setup into a Jasmine `beforeEach()` and some supporting variables: + + + + +Now add a test that gets the component's element from `fixture.nativeElement` and +looks for the expected text. + + + + +{@a native-element} + +#### _nativeElement_ + +The value of `ComponentFixture.nativeElement` has the `any` type. +Later you'll encounter the `DebugElement.nativeElement` and it too has the `any` type. + +Angular can't know at compile time what kind of HTML element the `nativeElement` is or +if it even is an HTML element. +The app might be running on a _non-browser platform_, such as the server or a +[Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API), +where the element may have a diminished API or not exist at all. + +The tests in this guide are designed to run in a browser so a +`nativeElement` value will always be an `HTMLElement` or +one of its derived classes. + +Knowing that it is an `HTMLElement` of some sort, you can use +the standard HTML `querySelector` to dive deeper into the element tree. + +Here's another test that calls `HTMLElement.querySelector` to get the paragraph element and look for the banner text: + + + + +{@a debug-element} + +#### _DebugElement_ + +The Angular _fixture_ provides the component's element directly through the `fixture.nativeElement`. + + + + +This is actually a convenience method, implemented as `fixture.debugElement.nativeElement`. + + + + +There's a good reason for this circuitous path to the element. + +The properties of the `nativeElement` depend upon the runtime environment. +You could be running these tests on a _non-browser_ platform that doesn't have a DOM or +whose DOM-emulation doesn't support the full `HTMLElement` API. + +Angular relies on the `DebugElement` abstraction to work safely across _all supported platforms_. +Instead of creating an HTML element tree, Angular creates a `DebugElement` tree that wraps the _native elements_ for the runtime platform. +The `nativeElement` property unwraps the `DebugElement` and returns the platform-specific element object. + +Because the sample tests for this guide are designed to run only in a browser, +a `nativeElement` in these tests is always an `HTMLElement` +whose familiar methods and properties you can explore within a test. + +Here's the previous test, re-implemented with `fixture.debugElement.nativeElement`: + + + + +The `DebugElement` has other methods and properties that +are useful in tests, as you'll see elsewhere in this guide. + +You import the `DebugElement` symbol from the Angular core library. + + + + +{@a by-css} +#### _By.css()_ + +Although the tests in this guide all run in the browser, +some apps might run on a different platform at least some of the time. + +For example, the component might render first on the server as part of a strategy to make the application launch faster on poorly connected devices. The server-side renderer might not support the full HTML element API. +If it doesn't support `querySelector`, the previous test could fail. + +The `DebugElement` offers query methods that work for all supported platforms. +These query methods take a _predicate_ function that returns `true` when a node in the `DebugElement` tree matches the selection criteria. + +You create a _predicate_ with the help of a `By` class imported from a +library for the runtime platform. Here's the `By` import for the browser platform: + + + + +The following example re-implements the previous test with +`DebugElement.query()` and the browser's `By.css` method. + + + + +Some noteworthy observations: + +* The `By.css()` static method selects `DebugElement` nodes +with a [standard CSS selector](https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_started/Selectors "CSS selectors"). +* The query returns a `DebugElement` for the paragraph. +* You must unwrap that result to get the paragraph element. + +When you're filtering by CSS selector and only testing properties of a browser's _native element_, the `By.css` approach may be overkill. + +It's often easier and more clear to filter with a standard `HTMLElement` method +such as `querySelector()` or `querySelectorAll()`, +as you'll see in the next set of tests. + +
+ +## Component Test Scenarios + +The following sections, comprising most of this guide, explore common +component testing scenarios + +### Component binding + +The current `BannerComponent` presents static title text in the HTML template. + +After a few changes, the `BannerComponent` presents a dynamic title by binding to +the component's `title` property like this. + + + + +Simple as this is, you decide to add a test to confirm that component +actually displays the right content where you think it should. + +#### Query for the _<h1>_ + +You'll write a sequence of tests that inspect the value of the `

` element +that wraps the _title_ property interpolation binding. + +You update the `beforeEach` to find that element with a standard HTML `querySelector` +and assign it to the `h1` variable. + + + {@a detect-changes} +#### _createComponent()_ does not bind data -### _detectChanges_: Angular change detection within a test +For your first test you'd like to see that the screen displays the default `title`. +Your instinct is to write a test that immediately inspects the `

` like this: -Each test tells Angular when to perform change detection by calling `fixture.detectChanges()`. -The first test does so immediately, triggering data binding and propagation of the `title` property -to the DOM element. + + -The second test changes the component's `title` property _and only then_ calls `fixture.detectChanges()`; -the new value appears in the DOM element. +_That test fails_ with the message: +```javascript +expected '' to contain 'Test Tour of Heroes'. +``` + +Binding happens when Angular performs **change detection**. In production, change detection kicks in automatically when Angular creates a component or the user enters a keystroke or an asynchronous activity (e.g., AJAX) completes. The `TestBed.createComponent` does _not_ trigger change detection. -The fixture does not automatically push the component's `title` property value into the data bound element, -a fact demonstrated in the following test: +a fact confirmed in the revised test: - + + + +#### _detectChanges()_ + +You must tell the `TestBed` to perform data binding by calling `fixture.detectChanges()`. +Only then does the `

` have the expected title. + + + + +Delayed change detection is intentional and useful. +It gives the tester an opportunity to inspect and change the state of +the component _before Angular initiates data binding and calls [lifecycle hooks](guide/lifecycle-hooks)_. + +Here's another test that changes the component's `title` property _before_ calling `fixture.detectChanges()`. - -This behavior (or lack of it) is intentional. -It gives the tester an opportunity to inspect or change the state of -the component _before Angular initiates data binding or calls lifecycle hooks_. - + + {@a auto-detect-changes} - -### Automatic change detection +#### Automatic change detection The `BannerComponent` tests frequently call `detectChanges`. Some testers prefer that the Angular test environment run change detection automatically. @@ -551,21 +757,15 @@ Some testers prefer that the Angular test environment run change detection autom That's possible by configuring the `TestBed` with the `ComponentFixtureAutoDetect` provider. First import it from the testing utility library: - - - + Then add it to the `providers` array of the testing module configuration: - - - + Here are three tests that illustrate how automatic change detection works. - - - + The first test shows the benefit of automatic change detection. @@ -575,203 +775,73 @@ The `ComponentFixtureAutoDetect` service responds to _asynchronous activities_ s But a direct, synchronous update of the component property is invisible. The test must call `fixture.detectChanges()` manually to trigger another cycle of change detection. -
- - Rather than wonder when the test fixture will or won't perform change detection, the samples in this guide _always call_ `detectChanges()` _explicitly_. There is no harm in calling `detectChanges()` more often than is strictly necessary. -
+
+### Component with external files -
+The `BannerComponent` above is defined with an _inline template_ and _inline css_, specified in the `@Component.template` and `@Component.styles` properties respectively. +Many components specify _external templates_ and _external css_ with the +`@Component.templateUrl` and `@Component.styleUrls` properties respectively, +as the following variant of `BannerComponent` does. + + -{@a component-with-external-template} +This syntax tells the Angular compiler to read the external files during component compilation. +That's not a problem when you run the CLI `ng test` command because it +_compiles the app before running the tests_. -## Test a component with an external template -The application's actual `BannerComponent` behaves the same as the version above but is implemented differently. -It has _external_ template and css files, specified in `templateUrl` and `styleUrls` properties. +However, if you run the tests in a **non-CLI environment**, +tests of this component may fail. +For example, if you run the `BannerComponent` tests in a web coding environment such as [plunker](http://plnkr.co/), you'll see a message like this one: - - - - -That's a problem for the tests. -The `TestBed.createComponent` method is synchronous. -But the Angular template compiler must read the external files from the file system before it can create a component instance. -That's an asynchronous activity. -The previous setup for testing the inline component won't work for a component with an external template. - - - -
- -
- - - -### The first asynchronous _beforeEach_ - -The test setup for `BannerComponent` must give the Angular template compiler time to read the files. -The logic in the `beforeEach` of the previous spec is split into two `beforeEach` calls. -The first `beforeEach` handles asynchronous compilation. - - - - - - -Notice the `async` function called as the argument to `beforeEach`. -The `async` function is one of the Angular testing utilities and -has to be imported. - - - - - -It takes a parameterless function and _returns a function_ -which becomes the true argument to the `beforeEach`. - -The body of the `async` argument looks much like the body of a synchronous `beforeEach`. -There is nothing obviously asynchronous about it. -For example, it doesn't return a promise and -there is no `done` function to call as there would be in standard Jasmine asynchronous tests. -Internally, `async` arranges for the body of the `beforeEach` to run in a special _async test zone_ -that hides the mechanics of asynchronous execution. - -All this is necessary in order to call the asynchronous `TestBed.compileComponents` method. - - -{@a compile-components} - - -### _compileComponents_ -The `TestBed.configureTestingModule` method returns the `TestBed` class so you can chain -calls to other `TestBed` static methods such as `compileComponents`. - -The `TestBed.compileComponents` method asynchronously compiles all the components configured in the testing module. -In this example, the `BannerComponent` is the only component to compile. -When `compileComponents` completes, the external templates and css files have been "inlined" -and `TestBed.createComponent` can create new instances of `BannerComponent` synchronously. - -
- - - -WebPack developers need not call `compileComponents` because it inlines templates and css -as part of the automated build process that precedes running the test. - -
- - - -In this example, `TestBed.compileComponents` only compiles the `BannerComponent`. -Tests later in the guide declare multiple components and -a few specs import entire application modules that hold yet more components. -Any of these components might have external templates and css files. -`TestBed.compileComponents` compiles all of the declared components asynchronously at one time. - - -
- - - -Do not configure the `TestBed` after calling `compileComponents`. -Make `compileComponents` the last step -before calling `TestBed.createComponent` to instantiate the _component-under-test_. - -
- - - -Calling `compileComponents` closes the current `TestBed` instance to further configuration. -You cannot call any more `TestBed` configuration methods, not `configureTestingModule` -nor any of the `override...` methods. The `TestBed` throws an error if you try. - - -{@a second-before-each} - - -### The second synchronous _beforeEach_ -A _synchronous_ `beforeEach` containing the remaining setup steps follows the asynchronous `beforeEach`. - - - - - - -These are the same steps as in the original `beforeEach`. -They include creating an instance of the `BannerComponent` and querying for the elements to inspect. - -You can count on the test runner to wait for the first asynchronous `beforeEach` to finish before calling the second. - - -{@a waiting-compile-components} - - -### Waiting for _compileComponents_ - -The `compileComponents` method returns a promise so you can perform additional tasks _immediately after_ it finishes. -For example, you could move the synchronous code in the second `beforeEach` -into a `compileComponents().then(...)` callback and write only one `beforeEach`. - -Most developers find that hard to read. -The two `beforeEach` calls are widely preferred. - - -
- - -There's no harm in it and you might call `compileComponents` anyway -in case you decide later to re-factor the template into a separate file. -The tests in this guide only call `compileComponents` when necessary. - - -
- - - -
+ +Error: This test module uses the component BannerComponent +which is using a "templateUrl" or "styleUrls", but they were never compiled. +Please call "TestBed.compileComponents" before your test. + +You get this test failure message when the runtime environment +compiles the source code _during the tests themselves_. +To correct the problem, call `compileComponents()` as explained [below](#compile-components). {@a component-with-dependency} +### Component with a dependency -## Test a component with a dependency Components often have service dependencies. The `WelcomeComponent` displays a welcome message to the logged in user. It knows who the user is based on a property of the injected `UserService`: - - - + The `WelcomeComponent` has decision logic that interacts with the service, logic that makes this component worth testing. -Here's the testing module configuration for the spec file, `src/app/welcome.component.spec.ts`: - - - +Here's the testing module configuration for the spec file, `app/welcome/welcome.component.spec.ts`: + This time, in addition to declaring the _component-under-test_, the configuration adds a `UserService` provider to the `providers` list. But not the real `UserService`. - {@a service-test-doubles} - -### Provide service test doubles +#### Provide service test doubles A _component-under-test_ doesn't have to be injected with real services. In fact, it is usually better if they are test doubles (stubs, fakes, spies, or mocks). @@ -784,17 +854,19 @@ attempt to reach an authentication server. These behaviors can be hard to intercept. It is far easier and safer to create and register a test double in place of the real `UserService`. -This particular test suite supplies a minimal `UserService` stub that satisfies the needs of the `WelcomeComponent` +This particular test suite supplies a minimal mock of the `UserService` that satisfies the needs of the `WelcomeComponent` and its tests: - - - + + {@a get-injected-service} +#### Get injected services -### Get injected services The tests need access to the (stub) `UserService` injected into the `WelcomeComponent`. Angular has a hierarchical injection system. @@ -805,319 +877,468 @@ The safest way to get the injected service, the way that **_always works_**, is to **get it from the injector of the _component-under-test_**. The component injector is a property of the fixture's `DebugElement`. - - - + + {@a testbed-get} +#### _TestBed.get()_ -### _TestBed.get_ - -You _may_ also be able to get the service from the root injector via `TestBed.get`. +You _may_ also be able to get the service from the root injector via `TestBed.get()`. This is easier to remember and less verbose. But it only works when Angular injects the component with the service instance in the test's root injector. -Fortunately, in this test suite, the _only_ provider of `UserService` is the root testing module, -so it is safe to call `TestBed.get` as follows: - - +In this test suite, the _only_ provider of `UserService` is the root testing module, +so it is safe to call `TestBed.get()` as follows: + +
- - -The [`inject`](guide/testing#inject) utility function is another way to get one or more services from the test root injector. - -For a use case in which `inject` and `TestBed.get` do not work, -see the section [_Override a component's providers_](guide/testing#component-override), which -explains why you must get the service from the component's injector instead. - +For a use case in which `TestBed.get()` does not work, +see the section [_Override a component's providers_](#component-override), which +explains when and why you must get the service from the component's injector instead.
- - {@a service-from-injector} +#### Always get the service from an injector -### Always get the service from an injector Do _not_ reference the `userServiceStub` object that's provided to the testing module in the body of your test. **It does not work!** The `userService` instance injected into the component is a completely _different_ object, a clone of the provided `userServiceStub`. - - - + {@a welcome-spec-setup} +#### Final setup and tests -### Final setup and tests -Here's the complete `beforeEach` using `TestBed.get`: - - - +Here's the complete `beforeEach()`, using `TestBed.get()`: + And here are some tests: - - - + The first is a sanity test; it confirms that the stubbed `UserService` is called and working.
- - -The second parameter to the Jasmine matcher (e.g., `'expected name'`) is an optional addendum. -If the expectation fails, Jasmine displays this addendum after the expectation failure message. +The second parameter to the Jasmine matcher (e.g., `'expected name'`) is an optional failure label. +If the expectation fails, Jasmine displays appends this label to the expectation failure message. In a spec with multiple expectations, it can help clarify what went wrong and which expectation failed. -
- - The remaining tests confirm the logic of the component when the service returns different values. The second test validates the effect of changing the user name. The third test checks that the component displays the proper message when there is no logged-in user. -
- - +
{@a component-with-async-service} +### Component with async service -## Test a component with an async service -Many services return values asynchronously. -Most data services make an HTTP request to a remote server and the response is necessarily asynchronous. +In this sample, the `AboutComponent` template hosts a `TwainComponent`. +The `TwainComponent` displays Mark Twain quotes. -The "About" view in this sample displays Mark Twain quotes. -The `TwainComponent` handles the display, delegating the server request to the `TwainService`. + + -Both are in the `src/app/shared` folder because the author intends to display Twain quotes on other pages someday. -Here is the `TwainComponent`. +Note that value of the component's `quote` property passes through an `AsyncPipe`. +That means the property returns either a `Promise` or an `Observable`. - +In this example, the `TwainComponent.getQuote()` method tells you that +the `quote` property returns an `Observable`. + + +The `TwainComponent` gets quotes from an injected `TwainService`. +The component starts the returned `Observable` with a placeholder value (`'...'`), +before the service can returns its first quote. -The `TwainService` implementation is irrelevant for this particular test. -It is sufficient to see within `ngOnInit` that `twainService.getQuote` returns a promise, which means it is asynchronous. +The `catchError` intercepts service errors, prepares an error message, +and returns the placeholder value on the success channel. +It must wait a tick to set the `errorMessage` +in order to avoid updating that message twice in the same change detection cycle. -In general, tests should not make calls to remote servers. -They should emulate such calls. The setup in this `src/app/shared/twain.component.spec.ts` shows one way to do that: +These are all features you'll want to test. - +#### Testing with a spy +When testing a component, only the service's public API should matter. +In general, tests themselves should not make calls to remote servers. +They should emulate such calls. The setup in this `app/twain/twain.component.spec.ts` shows one way to do that: + + {@a service-spy} +Focus on the spy. -### Spying on the real service - -This setup is similar to the [`welcome.component.spec` setup](guide/testing#welcome-spec-setup). -But instead of creating a stubbed service object, it injects the _real_ service (see the testing module `providers`) and -replaces the critical `getQuote` method with a Jasmine spy. - - - - - -The spy is designed such that any call to `getQuote` receives an immediately resolved promise with a test quote. -The spy bypasses the actual `getQuote` method and therefore does not contact the server. - - -
- - - -Faking a service instance and spying on the real service are _both_ great options. -Pick the one that seems easiest for the current test suite. -Don't be afraid to change your mind. - -Spying on the real service isn't always easy, especially when the real service has injected dependencies. -You can _stub and spy_ at the same time, as shown in [an example below](guide/testing#spy-stub). - - -
- - - -Here are the tests with commentary to follow: - - - + + +The spy is designed such that any call to `getQuote` receives an Observable with a test quote. +Unlike the real `getQuote()` method, this spy bypasses the server +and returns a synchronous Observable whose value is available immediately. +You can write many useful tests with this spy, even though its `Observable` is synchronous. {@a sync-tests} +#### Synchronous tests -### Synchronous tests -The first two tests are synchronous. -Thanks to the spy, they verify that `getQuote` is called _after_ +A key advantage of a synchronous `Observable` is that +you can often turn asynchronous processes into synchronous tests. + + + + +Because the spy result returns synchronously, the `getQuote()` method updates +the message on screen immediately _after_ the first change detection cycle during which Angular calls `ngOnInit`. -Neither test can prove that a value from the service is displayed. -The quote itself has not arrived, despite the fact that the spy returns a resolved promise. - -This test must wait at least one full turn of the JavaScript engine before the +You're not so lucky when testing the error path. +Although the service spy will return an error synchronously, +the component method calls `setTimeout()`. +The test must wait at least one full turn of the JavaScript engine before the value becomes available. The test must become _asynchronous_. - -{@a async} - - -### The _async_ function in _it_ - -Notice the `async` in the third test. - - - - - -The `async` function is one of the Angular testing utilities. -It simplifies coding of asynchronous tests by arranging for the tester's code to run in a special _async test zone_ -as [discussed earlier](guide/testing#async-in-before-each) when it was called in a `beforeEach`. - -Although `async` does a great job of hiding asynchronous boilerplate, -some functions called within a test (such as `fixture.whenStable`) continue to reveal their asynchronous behavior. - -
- - - -The `fakeAsync` alternative, [covered below](guide/testing#fake-async), removes this artifact and affords a more linear coding experience. - - -
- - - -{@a when-stable} - - -### _whenStable_ -The test must wait for the `getQuote` promise to resolve in the next turn of the JavaScript engine. - -This test has no direct access to the promise returned by the call to `twainService.getQuote` -because it is buried inside `TwainComponent.ngOnInit` and therefore inaccessible to a test that -probes only the component API surface. - -Fortunately, the `getQuote` promise is accessible to the _async test zone_, -which intercepts all promises issued within the _async_ method call _no matter where they occur_. - -The `ComponentFixture.whenStable` method returns its own promise, which -resolves when the `getQuote` promise finishes. -In fact, the _whenStable_ promise resolves when _all pending -asynchronous activities within this test_ complete—the definition of "stable." - -Then the test resumes and kicks off another round of change detection (`fixture.detectChanges`), -which tells Angular to update the DOM with the quote. -The `getQuote` helper method extracts the display element text and the expectation confirms that the text matches the test quote. - - -{@a fakeAsync} - - {@a fake-async} +#### Async test with _fakeAsync()_ +The following test confirms the expected behavior when the service returns an `ErrorObservable`. -### The _fakeAsync_ function + + -The fourth test verifies the same component behavior in a different way. - - - - - -Notice that `fakeAsync` replaces `async` as the `it` argument. -The `fakeAsync` function is another of the Angular testing utilities. - -Like [async](guide/testing#async), it _takes_ a parameterless function and _returns_ a function -that becomes the argument to the Jasmine `it` call. +Note that the `it()` function receives an argument of the following form. +```javascript +fakeAsync(() => { /* test body */ })` +``` The `fakeAsync` function enables a linear coding style by running the test body in a special _fakeAsync test zone_. - -The principle advantage of `fakeAsync` over `async` is that the test appears to be synchronous. -There is no `then(...)` to disrupt the visible flow of control. -The promise-returning `fixture.whenStable` is gone, replaced by `tick()`. - - -
- - - -There _are_ limitations. For example, you cannot make an XHR call from within a `fakeAsync`. - - -
- - +The test body appears to be synchronous. +There is no nested syntax (like a `Promise.then()`) to disrupt the flow of control. {@a tick} +#### The _tick()_ function -### The _tick_ function -The `tick` function is one of the Angular testing utilities and a companion to `fakeAsync`. -You can only call it within a `fakeAsync` body. +You do have to call `tick()` to advance the (virtual) clock. -Calling `tick()` simulates the passage of time until all pending asynchronous activities finish, -including the resolution of the `getQuote` promise in this test case. +Calling `tick()` simulates the passage of time until all pending asynchronous activities finish. +In this case, it waits for the error handler's `setTimeout()`; -It returns nothing. There is no promise to wait for. -Proceed with the same test code that appeared in the `whenStable.then()` callback. +The `tick` function is one of the Angular testing utilities that you import with `TestBed`. +It's a companion to `fakeAsync` and you can only call it within a `fakeAsync` body. -Even this simple example is easier to read than the third test. -To more fully appreciate the improvement, imagine a succession of asynchronous operations, -chained in a long sequence of promise callbacks. +#### Async observables +You might be satisfied with the test coverage of these tests. + +But you might be troubled by the fact that the real service doesn't quite behave this way. +The real service sends requests to a remote server. +A server takes time to respond and the response certainly won't be available immediately +as in the previous two tests. + +Your tests will reflect the real world more faithfully if you return an _asynchronous_ observable +from the `getQuote()` spy like this. + + + + +#### Async observable helpers + +The async observable was produced by an `asyncData` helper +The `asyncData` helper is a utility function that you'll have to write yourself. +Or you can copy this one from the sample code. + + + + +This helper's observable emits the `data` value in the next turn of the JavaScript engine. + +[RxJS `defer()`](http://reactivex.io/documentation/operators/defer.html) returns an observable. +It takes a factory function that returns either a promise or an observable. +When something subscribes to _defer_'s observable, +it adds the subscriber to a new observable created with that factory. + +RxJS `defer()` transform the `Promise.resolve()` into a new observable that, +like `HttpClient`, emits once and completes. +Subscribers will be unsubscribed after they receive the data value. + +There's a similar helper for producing an async error. + + + + +#### More async tests + +Now that the `getQuote()` spy is returning async observables, +most of your tests will have to be async as well. + +Here's a `fakeAsync()` test that demonstrates the data flow you'd expect +in the real world. + + + + +Notice that the quote element displays the placeholder value (`'...'`) after `ngOnInit()`. +The first quote hasn't arrived yet. + +To flush the first quote from the observable, you call `tick()`. +Then call `detectChanges()` to tell Angular to update the screen. + +Then you can assert that the quote element displays the expected text. + +{@a async} + +#### Async test with _async()_ + +The `fakeAsync()` utility function has a few limitations. +In particular, it won't work if the test body makes an `XHR` call. + +`XHR` calls within a test are rare so you can generally stick with `fakeAsync()`. +But if you ever do need to call `XHR`, you'll want to know about `async()`. + +
+ +The `TestBed.compileComponents()` method (see [below](#compile-components)) calls `XHR` +to read external template and css files during "just-in-time" compilation. +Write tests that call `compileComponents()` with the `async()` utility. + +
+ +Here's the previous `fakeAsync()` test, re-written with the `async()` utility. + + + + +The `async()` utility hides some asynchronous boilerplate by arranging for the tester's code +to run in a special _async test zone_. +You don't have to pass Jasmine's `done()` into the test and call `done()` +in promise or observable callbacks. + +But the test's asynchronous nature is revealed by the call to `fixture.whenStable()`, +which breaks the linear flow of control. + +{@a when-stable} + +#### _whenStable_ + +The test must wait for the `getQuote()` observable to emit the next quote. +Instead of calling `tick()`, it calls `fixture.whenStable()`. + +The `fixture.whenStable()` returns a promise that resolves when the JavaScript engine's +task queue becomes empty. +In this example, the task queue becomes empty when the observable emits the first quote. + +The test resumes within the promise callback, which calls `detectChanges()` to +update the quote element with the expected text. {@a jasmine-done} +#### Jasmine _done()_ -### _jasmine.done_ While the `async` and `fakeAsync` functions greatly simplify Angular asynchronous testing, -you can still fall back to the traditional Jasmine asynchronous testing technique. - -You can still pass `it` a function that takes a +you can still fall back to the traditional technique +and pass `it` a function that takes a [`done` callback](http://jasmine.github.io/2.0/introduction.html#section-Asynchronous_Support). -Now you are responsible for chaining promises, handling errors, and calling `done` at the appropriate moment. -Here is a `done` version of the previous two tests: +Now you are responsible for chaining promises, handling errors, and calling `done()` at the appropriate moments. - - - - -Although there is no direct access to the `getQuote` promise inside `TwainComponent`, -the spy has direct access, which makes it possible to wait for `getQuote` to finish. - -Writing test functions with `done`, while more cumbersome than `async` -and `fakeAsync`, is a viable and occasionally necessary technique. +Writing test functions with `done()`, is more cumbersome than `async`and `fakeAsync`. +But it is occasionally necessary. For example, you can't call `async` or `fakeAsync` when testing -code that involves the `intervalTimer`, as is common when -testing async `Observable` methods. +code that involves the `intervalTimer()` or the RxJS `delay()` operator. -
+Here are two mover versions of the previous test, written with `done()`. +The first one subscribes to the `Observable` exposed to the template by the component's `quote` property. + + + + +The RxJS `last()` operator emits the observable's last value before completing, which will be the test quote. +The `subscribe` callback calls `detectChanges()` to +update the quote element with the test quote, in the same manner as the earlier tests. + +In some tests, you're more interested in how an injected service method was called and what values it returned, +than what appears on screen. + +A service spy, such as the `qetQuote()` spy of the fake `TwainService`, +can give you that information and make assertions about the state of the view. + + + + +
+ +{@a marble-testing} +### Component marble tests + +The previous `TwainComponent` tests simulated an asynchronous observable response +from the `TwainService` with the `asyncData` and `asyncError` utilities. + +These are short, simple functions that you can write yourself. +Unfortunately, they're too simple for many common scenarios. +An observable often emits multiple times, perhaps after a significant delay. +A component may coordinate multiple observables +with overlapping sequences of values and errors. + +**RxJS marble testing** is a great way to test observable scenarios, +both simple and complex. +You've likely seen the [marble diagrams](http://rxmarbles.com/) +that illustrate how observables work. +Marble testing uses a similar marble language to +specify the observable streams and expectations in your tests. + +The following examples revisit two of the `TwainComponent` tests +with marble testing. + +Start by installing the `jasmine-marbles` npm package. +Then import the symbols you need. + + + + +Here's the complete test for getting a quote: + + + + +Notice that the Jasmine test is synchronous. There's no `fakeAsync()`. +Marble testing uses a test scheduler to simulate the passage of time +in a synchronous test. + +The beauty of marble testing is in the visual definition of the observable streams. +This test defines a [_cold_ observable](#cold-observable) that waits +three [frames](#marble-frame) (`---`), +emits a value (`x`), and completes (`|`). +In the second argument you map the value marker (`x`) to the emitted value (`testQuote`). + + + + +The marble library constructs the corresponding observable, which the +test sets as the `getQuote` spy's return value. + +When you're ready to activate the marble observables, +you tell the `TestScheduler` to _flush_ its queue of prepared tasks like this. + + + + +This step serves a purpose analogous to `tick()` and `whenStable()` in the +earlier `fakeAsync()` and `async()` examples. +The balance of the test is the same as those examples. + +#### Marble error testing + +Here's the marble testing version of the `getQuote()` error test. + + + + +It's still an async test, calling `fakeAsync()` and `tick()`, because the component itself +calls `setTimeout()` when processing errors. + +Look at the marble observable definition. + + + + +This is a _cold_ observable that waits three frames and then emits an error, +The hash (`#`) indicates the timing of the error that is specified in the third argument. +The second argument is null because the observable never emits a value. + +#### Learn about marble testing + +{@a marble-frame} +A _marble frame_ is a virtual unit of testing time. +Each symbol (`-`, `x`, `|`, `#`) marks the passing of one frame. + +{@a cold-observable} +A _cold_ observable doesn't produce values until you subscribe to it. +Most of your application observables are cold. +All [_HttpClient_](guide/http) methods return cold observables. + +A _hot_ observable is already producing values _before_ you subscribe to it. +The [_Router.events_](api/router/Router#events) observable, +which reports router activity, is a _hot_ observable. +RxJS marble testing is a rich subject, beyond the scope of this guide. +Learn about it on the web, starting with the +[official documentation](https://github.com/ReactiveX/rxjs/blob/master/doc/writing-marble-tests.md). + +
{@a component-with-input-output} +### Component with inputs and outputs -## Test a component with inputs and outputs A component with inputs and outputs typically appears inside the view template of a host component. The host uses a property binding to set the input property and an event binding to listen to events raised by the output property. @@ -1131,18 +1352,24 @@ Clicking that hero tells the `DashboardComponent` that the user has selected the The `DashboardHeroComponent` is embedded in the `DashboardComponent` template like this: - - - + + The `DashboardHeroComponent` appears in an `*ngFor` repeater, which sets each component's `hero` input property to the looping value and listens for the component's `selected` event. -Here's the component's definition: - - +Here's the component's full definition: +{@a dashboard-hero-component} + + While testing a component this simple has little intrinsic value, it's worth knowing how. You can use one of these approaches: @@ -1153,9 +1380,11 @@ You can use one of these approaches: A quick look at the `DashboardComponent` constructor discourages the first approach: - - - + + The `DashboardComponent` depends on the Angular router and the `HeroService`. You'd probably have to replace them both with test doubles, which is a lot of work. @@ -1163,135 +1392,129 @@ The router seems particularly challenging.
- - -The [discussion below](guide/testing#routed-component) covers testing components that require the router. +The [discussion below](#routing-component) covers testing components that require the router.
- - The immediate goal is to test the `DashboardHeroComponent`, not the `DashboardComponent`, so, try the second and third options. - {@a dashboard-standalone} +#### Test _DashboardHeroComponent_ stand-alone -### Test _DashboardHeroComponent_ stand-alone +Here's the meat of the spec file setup. + + -Here's the spec file setup. - - - - - -The async `beforeEach` was discussed [above](guide/testing#component-with-external-template). -Having compiled the components asynchronously with `compileComponents`, the rest of the setup -proceeds _synchronously_ in a _second_ `beforeEach`, using the basic techniques described [earlier](guide/testing#simple-component-test). - -Note how the setup code assigns a test hero (`expectedHero`) to the component's `hero` property, emulating -the way the `DashboardComponent` would set it via the property binding in its repeater. - -The first test follows: - - - - - -It verifies that the hero name is propagated to template with a binding. -Because the template passes the hero name through the Angular `UpperCasePipe`, -the test must match the element value with the uppercased name: - - - +Note how the setup code assigns a test hero (`expectedHero`) to the component's `hero` property, +emulating the way the `DashboardComponent` would set it +via the property binding in its repeater. +The following test verifies that the hero name is propagated to the template via a binding. + + +Because the [template](#dashboard-hero-component) passes the hero name through the Angular `UpperCasePipe`, +the test must match the element value with the upper-cased name.
- - This small test demonstrates how Angular tests can verify a component's visual representation—something not possible with -[isolated unit tests](guide/testing#isolated-component-tests)—at +[component class tests](#component-class-testing)—at low cost and without resorting to much slower and more complicated end-to-end tests. -
+#### Clicking +Clicking the hero should raise a `selected` event that +the host component (`DashboardComponent` presumably) can hear: -The second test verifies click behavior. Clicking the hero should raise a `selected` event that the -host component (`DashboardComponent` presumably) can hear: + + - +The component's `selected` property returns an `EventEmitter`, +which looks like an RxJS synchronous `Observable` to consumers. +The test subscribes to it _explicitly_ just as the host component does _implicitly_. +If the component behaves as expected, clicking the hero's element +should tell the component's `selected` property to emit the `hero` object. - -The component exposes an `EventEmitter` property. The test subscribes to it just as the host component would do. - -The `heroEl` is a `DebugElement` that represents the hero `
`. -The test calls `triggerEventHandler` with the "click" event name. -The "click" event binding responds by calling `DashboardHeroComponent.click()`. - -If the component behaves as expected, `click()` tells the component's `selected` property to emit the `hero` object, -the test detects that value through its subscription to `selected`, and the test should pass. - +The test detects that event through its subscription to `selected`. {@a trigger-event-handler} +#### _triggerEventHandler_ -### _triggerEventHandler_ +The `heroDe` in the previous test is a `DebugElement` that represents the hero `
`. + +It has Angular properties and methods that abstract interaction with the native element. +This test calls the `DebugElement.triggerEventHandler` with the "click" event name. +The "click" event binding responds by calling `DashboardHeroComponent.click()`. The Angular `DebugElement.triggerEventHandler` can raise _any data-bound event_ by its _event name_. The second parameter is the event object passed to the handler. -In this example, the test triggers a "click" event with a null event object. - - - - +The test triggered a "click" event with a `null` event object. + + The test assumes (correctly in this case) that the runtime event handler—the component's `click()` method—doesn't care about the event object. +
+ Other handlers are less forgiving. For example, the `RouterLink` directive expects an object with a `button` property -that identifies which mouse button was pressed. -This directive throws an error if the event object doesn't do this correctly. +that identifies which mouse button (if any) was pressed during the click. +The `RouterLink` directive throws an error if the event object is missing. +
+ +#### Click the element + +The following test alternative calls the native element's own `click()` method, +which is perfectly fine for _this component_. + + + {@a click-helper} - +#### _click()_ helper Clicking a button, an anchor, or an arbitrary HTML element is a common test task. -Make that easy by encapsulating the _click-triggering_ process in a helper such as the `click` function below: - - - +Make that consistent and easy by encapsulating the _click-triggering_ process +in a helper such as the `click()` function below: + + The first parameter is the _element-to-click_. If you wish, you can pass a custom event object as the second parameter. The default is a (partial) left-button mouse event object accepted by many handlers including the `RouterLink` directive. - -
- - - -
- click() is not an Angular testing utility -
- - +
The `click()` helper function is **not** one of the Angular testing utilities. It's a function defined in _this guide's sample code_. @@ -1300,52 +1523,59 @@ If you like it, add it to your own collection of helpers.
+Here's the previous test, rewritten using the click helper. + + -Here's the previous test, rewritten using this click helper. - - - - - -
- - +
{@a component-inside-test-host} +### Component inside a test host -## Test a component inside a test host component - -In the previous approach, the tests themselves played the role of the host `DashboardComponent`. +The previous tests played the role of the host `DashboardComponent` themselves. But does the `DashboardHeroComponent` work correctly when properly data-bound to a host component? -Testing with the actual `DashboardComponent` host is doable but seems more trouble than its worth. -It's easier to emulate the `DashboardComponent` host with a _test host_ like this one: +You could test with the actual `DashboardComponent`. +But doing so could require a lot of setup, +especially when its template features an `*ngFor` repeater, +other components, layout HTML, additional bindings, +a constructor that injects multiple services, +and it starts interacting with those services right away. - +Imagine the effort to disable these distractions, just to prove a point +that can be made satisfactorily with a _test host_ like this one: + + - -The test host binds to `DashboardHeroComponent` as the `DashboardComponent` would but without -the distraction of the `Router`, the `HeroService`, or even the `*ngFor` repeater. +This test host binds to `DashboardHeroComponent` as the `DashboardComponent` would +but without the noise of the `Router`, the `HeroService`, or the `*ngFor` repeater. The test host sets the component's `hero` input property with its test hero. It binds the component's `selected` event with its `onSelected` handler, -which records the emitted hero -in its `selectedHero` property. Later, the tests check that property to verify that the -`DashboardHeroComponent.selected` event emitted the right hero. +which records the emitted hero in its `selectedHero` property. -The setup for the test-host tests is similar to the setup for the stand-alone tests: +Later, the tests will be able to easily check `selectedHero` to verify that the +`DashboardHeroComponent.selected` event emitted the expected hero. - +The setup for the _test-host_ tests is similar to the setup for the stand-alone tests: + - -This testing module configuration shows two important differences: +This testing module configuration shows three important differences: 1. It _declares_ both the `DashboardHeroComponent` and the `TestHostComponent`. 1. It _creates_ the `TestHostComponent` instead of the `DashboardHeroComponent`. +1. The `TestHostComponent` sets the `DashboardHeroComponent.hero` with a binding. The `createComponent` returns a `fixture` that holds an instance of `TestHostComponent` instead of an instance of `DashboardHeroComponent`. @@ -1356,264 +1586,161 @@ albeit at greater depth in the element tree than before. The tests themselves are almost identical to the stand-alone version: - - - + + Only the selected event test differs. It confirms that the selected `DashboardHeroComponent` hero really does find its way up through the event binding to the host component. +
-
+{@a routing-component} +### Routing component +A _routing component_ is a component that tells the `Router` to navigate to another component. +The `DashboardComponent` is a _routing component_ because the user can +navigate to the `HeroDetailComponent` by clicking on one of the _hero buttons_ on the dashboard. -{@a routed-component} +Routing is pretty complicated. +Testing the `DashboardComponent` seemed daunting in part because it involves the `Router`, +which it injects together with the `HeroService`. + + -## Test a routed component +Mocking the `HeroService` with a spy is a [familiar story](#component-with-async-service). +But the `Router` has a complicated API and is entwined with other services and application preconditions. Might it be difficult to mock? -Testing the actual `DashboardComponent` seemed daunting because it injects the `Router`. +Fortunately, not in this case because the `DashboardComponent` isn't doing much with the `Router` - + + - - -It also injects the `HeroService`, but faking that is a [familiar story](guide/testing#component-with-async-service). -The `Router` has a complicated API and is entwined with other services and application preconditions. - -Fortunately, the `DashboardComponent` isn't doing much with the `Router` - - - - - -This is often the case. +This is often the case with _routing components_. As a rule you test the component, not the router, and care only if the component navigates with the right address under the given conditions. -Stubbing the router with a test implementation is an easy option. This should do the trick: - +Providing a router spy for _this component_ test suite happens to be as easy +as providing a `HeroService` spy. + + +The following test clicks the displayed hero and confirms that +`Router.navigateByUrl` is called with the expected url. -Now set up the testing module with the test stubs for the `Router` and `HeroService`, and -create a test instance of the `DashboardComponent` for subsequent testing. - - - - - -The following test clicks the displayed hero and confirms (with the help of a spy) that `Router.navigateByUrl` is called with the expected url. - - - - - -{@a inject} - - -### The _inject_ function - -Notice the `inject` function in the second `it` argument. - - - - - -The `inject` function is one of the Angular testing utilities. -It injects services into the test function where you can alter, spy on, and manipulate them. - -The `inject` function has two parameters: - -1. An array of Angular dependency injection tokens. -1. A test function whose parameters correspond exactly to each item in the injection token array. - - -
- - - -
- inject uses the TestBed Injector -
- - - -The `inject` function uses the current `TestBed` injector and can only return services provided at that level. -It does not return services from component providers. - - -
- - - -This example injects the `Router` from the current `TestBed` injector. -That's fine for this test because the `Router` is, and must be, provided by the application root injector. - -If you need a service provided by the component's _own_ injector, call `fixture.debugElement.injector.get` instead: - - - - - -
- - - -Use the component's own injector to get the service actually injected into the component. - - -
- - - -The `inject` function closes the current `TestBed` instance to further configuration. -You cannot call any more `TestBed` configuration methods, not `configureTestingModule` -nor any of the `override...` methods. The `TestBed` throws an error if you try. - - -
- - - -Do not configure the `TestBed` after calling `inject`. - - -
- - + + {@a routed-component-w-param} +### Routed components -### Test a routed component with parameters +A _routed component_ is the destination of a `Router` navigation. +It can be trickier to test, especially when the route to the component _includes parameters_. +The `HeroDetailComponent` is a _routed component_ that is the destination of such a route. -Clicking a _Dashboard_ hero triggers navigation to `heroes/:id`, where `:id` -is a route parameter whose value is the `id` of the hero to edit. -That URL matches a route to the `HeroDetailComponent`. +When a user clicks a _Dashboard_ hero, the `DashboardComponent` tells the `Router` +to navigate to `heroes/:id`. +The `:id` is a route parameter whose value is the `id` of the hero to edit. + +The `Router` matches that URL to a route to the `HeroDetailComponent`. +It creates an `ActivatedRoute` object with the routing information and +injects it into a new instance of the `HeroDetailComponent`. -The router pushes the `:id` token value into the `ActivatedRoute.params` _Observable_ property, -Angular injects the `ActivatedRoute` into the `HeroDetailComponent`, -and the component extracts the `id` so it can fetch the corresponding hero via the `HeroDetailService`. Here's the `HeroDetailComponent` constructor: - + +The `HeroDetail` component needs the `id` parameter so it can fetch +the corresponding hero via the `HeroDetailService`. +The component has to get the `id` from the `ActivatedRoute.paramMap` property +which is an _Observable_. -`HeroDetailComponent` subscribes to `ActivatedRoute.params` changes in its `ngOnInit` method. - - - +It can't just reference the `id` property of the `ActivatedRoute.paramMap`. +The component has to _subscribe_ to the `ActivatedRoute.paramMap` observable and be prepared +for the `id` to change during its lifetime. +
- - -The expression after `route.params` chains an _Observable_ operator that _plucks_ the `id` from the `params` -and then chains a `forEach` operator to subscribe to `id`-changing events. -The `id` changes every time the user navigates to a different hero. - -The `forEach` passes the new `id` value to the component's `getHero` method (not shown) -which fetches a hero and sets the component's `hero` property. -If the`id` parameter is missing, the `pluck` operator fails and the `catch` treats failure as a request to edit a new hero. - -The [Router](guide/router#route-parameters) guide covers `ActivatedRoute.params` in more detail. +The [Router](guide/router#route-parameters) guide covers `ActivatedRoute.paramMap` in more detail.
- - -A test can explore how the `HeroDetailComponent` responds to different `id` parameter values +Tests can explore how the `HeroDetailComponent` responds to different `id` parameter values by manipulating the `ActivatedRoute` injected into the component's constructor. -By now you know how to stub the `Router` and a data service. -Stubbing the `ActivatedRoute` follows the same pattern except for a complication: -the `ActivatedRoute.params` is an _Observable_. +You know how to spy on the `Router` and a data service. +You'll take a different approach with `ActivatedRoute` because +* `paramMap` returns an `Observable` that can emit more than one value +during a test. +* You need the router helper function, `convertToParamMap()`, to create a `ParamMap`. +* Other _routed components_ tests need a test double for `ActivatedRoute`. -{@a stub-observable} +These differences argue for a re-usable stub class. +#### _ActivatedRouteStub_ -### Create an _Observable_ test double +The following `ActivatedRouteStub` class serves as a test double for `ActivatedRoute`. + + + -The `hero-detail.component.spec.ts` relies on an `ActivatedRouteStub` to set `ActivatedRoute.params` values for each test. -This is a cross-application, re-usable _test helper class_. Consider placing such helpers in a `testing` folder sibling to the `app` folder. -This sample keeps `ActivatedRouteStub` in `testing/router-stubs.ts`: +This sample puts `ActivatedRouteStub` in `testing/activated-route-stub.ts`. +
- - - - -Notable features of this stub are: - -* The stub implements only two of the `ActivatedRoute` capabilities: `params` and `snapshot.params`. - -* _BehaviorSubject_ -drives the stub's `params` _Observable_ and returns the same value to every `params` subscriber until it's given a new value. - -* The `HeroDetailComponent` chains its expressions to this stub `params` _Observable_ which is now under the tester's control. - -* Setting the `testParams` property causes the `subject` to push the assigned value into `params`. - That triggers the `HeroDetailComponent` _params_ subscription, described above, in the same way that navigation does. - -* Setting the `testParams` property also updates the stub's internal value for the `snapshot` property to return. - -
- - - -The [_snapshot_](guide/router#snapshot "Router guide: snapshot") is another popular way for components to consume route parameters. + Consider writing a more capable version of this stub class with + the [_marble testing library_](#marble-testing).
+{@a tests-w-test-double} +#### Testing with _ActivatedRouteStub_ -
- - - -The router stubs in this guide are meant to inspire you. Create your own stubs to fit your testing needs. - - -
- - - -{@a tests-w-observable-double} - - -### Testing with the _Observable_ test double Here's a test demonstrating the component's behavior when the observed `id` refers to an existing hero: - - - +
- - -The `createComponent` method and `page` object are discussed [in the next section](guide/testing#page-object). +The `createComponent()` method and `page` object are discussed [below](#page-object). Rely on your intuition for now.
- - When the `id` cannot be found, the component should re-route to the `HeroListComponent`. -The test suite setup provided the same `RouterStub` [described above](guide/testing#routed-component) which spies on the router without actually navigating. -This test supplies a "bad" id and expects the component to try to navigate. - - - +The test suite setup provided the same router spy [described above](#routing-component) which spies on the router without actually navigating. +This test expects the component to try to navigate to the `HeroListComponent`. + While this app doesn't have a route to the `HeroDetailComponent` that omits the `id` parameter, it might add such a route someday. The component should do something reasonable when there is no `id`. @@ -1621,31 +1748,256 @@ The component should do something reasonable when there is no `id`. In this implementation, the component should create and display a new hero. New heroes have `id=0` and a blank `name`. This test confirms that the component behaves as expected: - + + +
+### Nested component tests +Component templates often have nested components, whose templates +may contain more components. +The component tree can be very deep and, most of the time, the nested components +play no role in testing the component at the top of the tree. -
+The `AppComponent`, for example, displays a navigation bar with anchors and their `RouterLink` directives. + + +While the `AppComponent` _class_ is empty, +you may want to write unit tests to confirm that the links are wired properly +to the `RouterLink` directives, perhaps for the reasons [explained below](#why-stubbed-routerlink-tests). -Inspect and download _all_ of the guide's application test code with this -live example. +To validate the links, you don't need the `Router` to navigate and you don't +need the `` to mark where the `Router` inserts _routed components_. +The `BannerComponent` and `WelcomeComponent` +(indicated by `` and ``) are also irrelevant. + +Yet any test that creates the `AppComponent` in the DOM will also create instances of +these three components and, if you let that happen, +you'll have to configure the `TestBed` to create them. + +If you neglect to declare them, the Angular compiler won't recognize the +``, ``, and `` tags in the `AppComponent` template +and will throw an error. + +If you declare the real components, you'll also have to declare _their_ nested components +and provide for _all_ services injected in _any_ component in the tree. + +That's too much effort just to answer a few simple questions about links. + +This section describes two techniques for minimizing the setup. +Use them, alone or in combination, to stay focused on the testing the primary component. + +{@a stub-component} + +##### Stubbing unneeded components + +In the first technique, you create and declare stub versions of the components +and directive that play little or no role in the tests. + + + + +The stub selectors match the selectors for the corresponding real components. +But their templates and classes are empty. + +Then declare them in the `TestBed` configuration next to the +components, directives, and pipes that need to be real. + + + + +The `AppComponent` is the test subject, so of course you declare the real version. + +The `RouterLinkDirectiveStub`, [described later](#routerlink), is a test version +of the real `RouterLink` that helps with the link tests. + +The rest are stubs. + +{@a no-errors-schema} + +#### *NO\_ERRORS\_SCHEMA* + +In the second approach, add `NO_ERRORS_SCHEMA` to the `TestBed.schemas` metadata. + + + + +The `NO_ERRORS_SCHEMA` tells the Angular compiler to ignore unrecognized elements and attributes. + +The compiler will recognize the `` element and the `routerLink` attribute +because you declared a corresponding `AppComponent` and `RouterLinkDirectiveStub` +in the `TestBed` configuration. + +But the compiler won't throw an error when it encounters ``, ``, or ``. +It simply renders them as empty tags and the browser ignores them. + +You no longer need the stub components. + +#### Use both techniques together + +These are techniques for _Shallow Component Testing_ , +so-named because they reduce the visual surface of the component to just those elements +in the component's template that matter for tests. + +The `NO_ERRORS_SCHEMA` approach is the easier of the two but don't overuse it. + +The `NO_ERRORS_SCHEMA` also prevents the compiler from telling you about the missing +components and attributes that you omitted inadvertently or misspelled. +You could waste hours chasing phantom bugs that the compiler would have caught in an instant. + +The _stub component_ approach has another advantage. +While the stubs in _this_ example were empty, +you could give them stripped-down templates and classes if your tests +need to interact with them in some way. + +In practice you will combine the two techniques in the same setup, +as seen in this example. + + + + +The Angular compiler creates the `BannerComponentStub` for the `` element +and applies the `RouterLinkStubDirective` to the anchors with the `routerLink` attribute, +but it ignores the `` and `` tags. + +
+ +{@a routerlink} +### Components with _RouterLink_ + +The real `RouterLinkDirective` is quite complicated and entangled with other components +and directives of the `RouterModule`. +It requires challenging setup to mock and use in tests. + +The `RouterLinkDirectiveStub` in this sample code replaces the real directive +with an alternative version designed to validate the kind of anchor tag wiring +seen in the `AppComponent` template. + + + + +The URL bound to the `[routerLink]` attribute flows in to the directive's `linkParams` property. + +The `host` metadata property wires the click event of the host element +(the `` anchor elements in `AppComponent`) to the stub directive's `onClick` method. + +Clicking the anchor should trigger the `onClick()` method, +which sets the stub's telltale `navigatedTo` property. +Tests inspect `navigatedTo` to confirm that clicking the anchor +set the expected route definition. + +
+ +Whether the router is configured properly to navigate with that route definition is a +question for a separate set of tests.
+{@a by-directive} +{@a inject-directive} -
+#### _By.directive_ and injected directives +A little more setup triggers the initial data binding and gets references to the navigation links: + + + +Three points of special interest: + +1. You can locate the anchor elements with an attached directive using `By.directive`. + +1. The query returns `DebugElement` wrappers around the matching elements. + +1. Each `DebugElement` exposes a dependency injector with the + specific instance of the directive attached to that element. + +The `AppComponent` links to validate are as follows: + + + + +{@a app-component-tests} + +Here are some tests that confirm those links are wired to the `routerLink` directives +as expected: + + + +
+ +The "click" test _in this example_ is misleading. +It tests the `RouterLinkDirectiveStub` rather than the _component_. +This is a common failing of directive stubs. + +It has a legitimate purpose in this guide. +It demonstrates how to find a `RouterLink` element, click it, and inspect a result, +without engaging the full router machinery. +This is a skill you may need to test a more sophisticated component, one that changes the display, +re-calculates parameters, or re-arranges navigation options when the user clicks the link. + +
+ +{@a why-stubbed-routerlink-tests} + +#### 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. +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. +Relying on the real router would make them brittle. +They could fail for reasons unrelated to the component. +For example, a navigation guard could prevent an unauthorized user from visiting the `HeroListComponent`. +That's not the fault of the `AppComponent` and no change to that component could cure the failed test. + +A _different_ battery of tests can explore whether the application navigates as expected +in the presence of conditions that influence guards such as whether the user is authenticated and authorized. + +
+ +A future guide update will explain how to write such +tests with the `RouterTestingModule`. + +
+ +
{@a page-object} - -## Use a _page_ object to simplify setup +### Use a _page_ object The `HeroDetailComponent` is a simple view with a title, two hero fields, and two buttons. @@ -1653,62 +2005,218 @@ The `HeroDetailComponent` is a simple view with a title, two hero fields, and tw HeroDetailComponent in action +But there's plenty of template complexity even in this simple form. + + -But there's already plenty of template complexity. +Tests that exercise the component need ... - - - - -To fully exercise the component, the test needs a lot of setup: - -* It must wait until a hero arrives before `*ngIf` allows any element in DOM. -* It needs references to the title `` and the name `` so it can inspect their values. -* It needs references to the two buttons so it can click them. -* It needs spies for some of the component and router methods. +* to wait until a hero arrives before elements appear in the DOM. +* a reference to the title text. +* a reference to the name input box to inspect and set it. +* references to the two buttons so they can click them. +* spies for some of the component and router methods. Even a small form such as this one can produce a mess of tortured conditional setup and CSS element selection. -Tame the madness with a `Page` class that simplifies access to component properties and encapsulates the logic that sets them. -Here's the `Page` class for the `hero-detail.component.spec.ts` - - +Tame the complexity with a `Page` class that handles access to component properties +and encapsulates the logic that sets them. +Here is such a `Page` class for the `hero-detail.component.spec.ts` + + Now the important hooks for component manipulation and inspection are neatly organized and accessible from an instance of `Page`. - A `createComponent` method creates a `page` object and fills in the blanks once the `hero` arrives. - + + - - -The [observable tests](guide/testing#tests-w-observable-double) in the previous section demonstrate how `createComponent` and `page` +The [_HeroDetailComponent_ tests](#tests-w-test-double) in an earlier section demonstrate how `createComponent` and `page` keep the tests short and _on message_. There are no distractions: no waiting for promises to resolve and no searching the DOM for element values to compare. -Here are a few more `HeroDetailComponent` tests to drive the point home. +Here are a few more `HeroDetailComponent` tests to reinforce the point. - + + +
+{@a compile-components} +### Calling _compileComponents()_ -
+
+You can ignore this section if you _only_ run tests with the CLI `ng test` command +because the CLI compiles the application before running the tests. +
+ +If you run tests in a **non-CLI environment**, the tests may fail with a message like this one: + + +Error: This test module uses the component BannerComponent +which is using a "templateUrl" or "styleUrls", but they were never compiled. +Please call "TestBed.compileComponents" before your test. + + +The root of the problem is at least one of the components involved in the test +specifies an external template or CSS file as +the following version of the `BannerComponent` does. + + + + +The test fails when the `TestBed` tries to create the component. + + + + +Recall that the app hasn't been compiled. +So when you call `createComponent()`, the `TestBed` compiles implicitly. + +That's not a problem when the source code is in memory. +But the `BannerComponent` requires external files +that the compile must read from the file system, +an inherently _asynchronous_ operation. + +If the `TestBed` were allowed to continue, the tests would run and fail mysteriously +before the compiler could finished. + +The preemptive error message tells you to compile explicitly with `compileComponents()`. + +#### _compileComponents()_ is async + +You must call `compileComponents()` within an asynchronous test function. + +
+ +If you neglect to make the test function async +(e.g., forget to use `async()` as described below), +you'll see this error message + + +Error: ViewDestroyedError: Attempt to use a destroyed view + + +
+ +A typical approach is to divide the setup logic into two separate `beforeEach()` functions: + +1. An async `beforeEach()` that compiles the components +1. A synchronous `beforeEach()` that performs the remaining setup. + +To follow this pattern, import the `async()` helper with the other testing symbols. + + + + +#### The async _beforeEach_ + +Write the first async `beforeEach` like this. + + + + +The `async()` helper function takes a parameterless function with the body of the setup. + +The `TestBed.configureTestingModule()` method returns the `TestBed` class so you can chain +calls to other `TestBed` static methods such as `compileComponents()`. + +In this example, the `BannerComponent` is the only component to compile. +Other examples configure the testing module with multiple components +and may import application modules that hold yet more components. +Any of them could be require external files. + +The `TestBed.compileComponents` method asynchronously compiles all components configured in the testing module. + +
+ +Do not re-configure the `TestBed` after calling `compileComponents()`. + +
+ +Calling `compileComponents()` closes the current `TestBed` instance to further configuration. +You cannot call any more `TestBed` configuration methods, not `configureTestingModule()` +nor any of the `override...` methods. The `TestBed` throws an error if you try. + +Make `compileComponents()` the last step +before calling `TestBed.createComponent()`. + +#### The synchronous _beforeEach_ + +The second, synchronous `beforeEach()` contains the remaining setup steps, +which include creating the component and querying for elements to inspect. + + + + +You can count on the test runner to wait for the first asynchronous `beforeEach` to finish before calling the second. + +#### Consolidated setup + +You can consolidate the two `beforeEach()` functions into a single, async `beforeEach()`. + +The `compileComponents()` method returns a promise so you can perform the +synchronous setup tasks _after_ compilation by moving the synchronous code +into a `then(...)` callback. + + + + +#### _compileComponents()_ is harmless + +There's no harm in calling `compileComponents()` when it's not required. + +The component test file generated by the CLI calls `compileComponents()` +even though it is never required when running `ng test`. + +The tests in this guide only call `compileComponents` when necessary. + +
{@a import-module} +### Setup with module imports -## Setup with module imports Earlier component tests configured the testing module with a few `declarations` like this: - - - + + The `DashboardComponent` is simple. It needs no help. But more complex components often depend on other components, directives, pipes, and providers @@ -1726,139 +2234,119 @@ In addition to the support it receives from the default testing module `CommonMo * 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: - + + +
+Notice that the `beforeEach()` is asynchronous and calls `TestBed.compileComponents` +because the `HeroDetailComponent` has an external template and css file. + +As explained in [_Calling compileComponents()_](#compile-components) above, +these tests could be run in a non-CLI environment +where Angular would have to compile them in the browser. + +
+ +#### Import a shared module 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: - - - - + + It's a bit tighter and smaller, with fewer import statements (not shown). - {@a feature-module-import} - -### Import the feature module +#### Import a feature module The `HeroDetailComponent` is part of the `HeroModule` [Feature Module](guide/feature-modules) that aggregates more of the interdependent pieces including the `SharedModule`. Try a test configuration that imports the `HeroModule` like this one: - - - + That's _really_ crisp. Only the _test doubles_ in the `providers` remain. Even the `HeroDetailComponent` declaration is gone. -
- - - -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). - - -
- - +In fact, if you try to declare it, Angular will throw an error because +`HeroDetailComponent` is declared in both the `HeroModule` and the `DynamicTestModule` +created by the `TestBed`.
- - -Importing the component's feature module 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. +Importing the component's feature module can be the easiest way to configure tests +when there are many mutual dependencies within the module and +the module is small, as feature modules tend to be.
- - -
- - +
{@a component-override} - -## Override a component's providers +### Override component providers The `HeroDetailComponent` provides its own `HeroDetailService`. - - - + 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! -
- - 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`. + - - - - -The [previous test configuration](guide/testing#feature-module-import) replaces the real `HeroService` with a `FakeHeroService` +The [previous test configuration](#feature-module-import) replaces the real `HeroService` with a `TestHeroService` that intercepts server requests and fakes their responses. -
- - 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: + - - - - -Notice that `TestBed.configureTestingModule` no longer provides a (fake) `HeroService` because it's [not needed](guide/testing#spy-stub). - +Notice that `TestBed.configureTestingModule` no longer provides a (fake) `HeroService` because it's [not needed](#spy-stub). {@a override-component-method} - -### The _overrideComponent_ method +#### The _overrideComponent_ method Focus on the `overrideComponent` method. - - - + It takes two arguments: the component type to override (`HeroDetailComponent`) and an override metadata object. -The [overide metadata object](guide/testing#metadata-override-object) is a generic defined as follows: - +The [overide metadata object](#metadata-override-object) is a generic defined as follows: type MetadataOverride = { @@ -1868,8 +2356,6 @@ The [overide metadata object](guide/testing#metadata-override-object) is a gener }; - - 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. @@ -1881,15 +2367,11 @@ The type parameter, `T`, is the kind of metadata you'd pass to the `@Component` templateUrl?: string; providers?: any[]; ... - - - {@a spy-stub} - -### Provide a _spy stub_ (_HeroDetailServiceSpy_) +#### Provide a _spy stub_ (_HeroDetailServiceSpy_) This example completely replaces the component's `providers` array with a new array containing a `HeroDetailServiceSpy`. @@ -1902,249 +2384,32 @@ The related `HeroDetailComponent` tests will assert that methods of the `HeroDet were called by spying on the service methods. Accordingly, the stub implements its methods as spies: - - - - + {@a override-tests} - -### The override tests +#### The override tests Now the tests can control the component's hero directly by manipulating the spy-stub's `testHero` and confirm that service methods were called. - - - + {@a more-overrides} +#### More overrides -### 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 router-outlet-component} - - -## Test a _RouterOutlet_ component - -The `AppComponent` displays routed components in a ``. -It also displays a navigation bar with anchors and their `RouterLink` directives. - -{@a app-component-html} - - - - - - -The component class does nothing. - - - - - -Unit tests can confirm that the anchors are wired properly without engaging the router. -See why this is worth doing [below](guide/testing#why-stubbed-routerlink-tests). - - -{@a stub-component} - - -### Stubbing unneeded components - -The test setup should look familiar. - - - - - - -The `AppComponent` is the declared test subject. - -The setup extends the default testing module with one real component (`BannerComponent`) and several stubs. - -* `BannerComponent` is simple and harmless to use as is. - -* The real `WelcomeComponent` has an injected service. `WelcomeStubComponent` is a placeholder with no service to worry about. - -* The real `RouterOutlet` is complex and errors easily. -The `RouterOutletStubComponent` (in `testing/router-stubs.ts`) is safely inert. - -The component stubs are essential. -Without them, the Angular compiler doesn't recognize the `` and `` tags -and throws an error. - -{@a router-link-stub} - - -### Stubbing the _RouterLink_ - -The `RouterLinkStubDirective` contributes substantively to the test: - - - - - - -The `host` metadata property wires the click event of the host element (the `
`) to the directive's `onClick` method. -The URL bound to the `[routerLink]` attribute flows to the directive's `linkParams` property. -Clicking the anchor should trigger the `onClick` method which sets the telltale `navigatedTo` property. -Tests can inspect that property to confirm the expected _click-to-navigation_ behavior. - - -{@a by-directive} - - -{@a inject-directive} - - -### _By.directive_ and injected directives - -A little more setup triggers the initial data binding and gets references to the navigation links: - - - - - -Two points of special interest: - -1. You can locate elements _by directive_, using `By.directive`, not just by css selectors. - -1. You can use the component's dependency injector to get an attached directive because -Angular always adds attached directives to the component's injector. - - -{@a app-component-tests} - - -Here are some tests that leverage this setup: - - - - - -
- - - -The "click" test _in this example_ is worthless. -It works hard to appear useful when in fact it -tests the `RouterLinkStubDirective` rather than the _component_. -This is a common failing of directive stubs. - -It has a legitimate purpose in this guide. -It demonstrates how to find a `RouterLink` element, click it, and inspect a result, -without engaging the full router machinery. -This is a skill you may need to test a more sophisticated component, one that changes the display, -re-calculates parameters, or re-arranges navigation options when the user clicks the link. - - -
- - - -{@a why-stubbed-routerlink-tests} - - -### 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. -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. -Relying on the real router would make them brittle. -They could fail for reasons unrelated to the component. -For example, a navigation guard could prevent an unauthorized user from visiting the `HeroListComponent`. -That's not the fault of the `AppComponent` and no change to that component could cure the failed test. - -A _different_ battery of tests can explore whether the application navigates as expected -in the presence of conditions that influence guards such as whether the user is authenticated and authorized. - - -
- - - -A future guide update will explain how to write such -tests with the `RouterTestingModule`. - - -
- -
- - - -{@a shallow-component-test} - - -## "Shallow component tests" with *NO\_ERRORS\_SCHEMA* - -The [previous setup](guide/testing#stub-component) declared the `BannerComponent` and stubbed two other components -for _no reason other than to avoid a compiler error_. - -Without them, the Angular compiler doesn't recognize the ``, `` and `` tags -in the [_app.component.html_](guide/testing#app-component-html) template and throws an error. - -Add `NO_ERRORS_SCHEMA` to the testing module's `schemas` metadata -to tell the compiler to ignore unrecognized elements and attributes. -You no longer have to declare irrelevant components and directives. - -These tests are ***shallow*** because they only "go deep" into the components you want to test. - -Here is a setup, with `import` statements, that demonstrates the improved simplicity of _shallow_ tests, relative to the stubbing setup. - - - - - - - - - - - - - - - -The _only_ declarations are the _component-under-test_ (`AppComponent`) and the `RouterLinkStubDirective` -that contributes actively to the tests. -The [tests in this example](guide/testing#app-component-tests) are unchanged. - - -
- - - -_Shallow component tests_ with `NO_ERRORS_SCHEMA` greatly simplify unit testing of complex templates. -However, the compiler no longer alerts you to mistakes -such as misspelled or misused components and directives. - - -
- - -
- - +
{@a attribute-directive} - -## Test an attribute directive +## Attribute Directive Testing An _attribute directive_ modifies the behavior of an element, component or another directive. Its name reflects the way the directive is applied: as an attribute on a host element. @@ -2154,60 +2419,43 @@ based on either a data bound color or a default color (lightgray). It also sets a custom property of the element (`customProperty`) to `true` for no reason other than to show that it can. - - - + It's used throughout the application, perhaps most simply in the `AboutComponent`: - - - + Testing the specific use of the `HighlightDirective` within the `AboutComponent` requires only the -techniques explored above (in particular the ["Shallow test"](guide/testing#shallow-component-test) approach). - - - +techniques explored above (in particular the ["Shallow test"](#nested-component-tests) approach). + However, testing a single use case is unlikely to explore the full range of a directive's capabilities. Finding and testing all components that use the directive is tedious, brittle, and almost as unlikely to afford full coverage. -[Isolated unit tests](guide/testing#isolated-unit-tests) might be helpful, +_Class-only tests_ might be helpful, but attribute directives like this one tend to manipulate the DOM. Isolated unit tests don't touch the DOM and, therefore, do not inspire confidence in the directive's efficacy. A better solution is to create an artificial test component that demonstrates all ways to apply the directive. - - - - +
HighlightDirective spec in action
- -
- - The `` case binds the `HighlightDirective` to the name of a color value in the input box. The initial value is the word "cyan" which should be the background color of the input box.
- - Here are some tests of this component: - - - + A few techniques are noteworthy: @@ -2226,137 +2474,10 @@ and its `defaultColor`. * `DebugElement.properties` affords access to the artificial custom property that is set by the directive. +
-
+## Pipe Testing - - -{@a isolated-unit-tests} - - -## Isolated Unit Tests - -Testing applications with the help of the Angular testing utilities is the main focus of this guide. - -However, it's often more productive to explore the inner logic of application classes -with _isolated_ unit tests that don't depend upon Angular. -Such tests are often smaller and easier to read, write, and maintain. - -They don't carry extra baggage: - -* Import from the Angular test libraries. -* Configure a module. -* Prepare dependency injection `providers`. -* Call `inject` or `async` or `fakeAsync`. - -They follow patterns familiar to test developers everywhere: - -* Exhibit standard, Angular-agnostic testing techniques. -* Create instances directly with `new`. -* Substitute test doubles (stubs, spys, and mocks) for the real dependencies. - - -
- - - -
- Write both kinds of tests -
- - - -Good developers write both kinds of tests for the same application part, often in the same spec file. -Write simple _isolated_ unit tests to validate the part in isolation. -Write _Angular_ tests to validate the part as it interacts with Angular, -updates the DOM, and collaborates with the rest of the application. - - -
- - - -{@a isolated-service-tests} - - -### Services -Services are good candidates for isolated unit testing. -Here are some synchronous and asynchronous unit tests of the `FancyService` -written without assistance from Angular testing utilities. - - - - - - -A rough line count suggests that these isolated unit tests are about 25% smaller than equivalent Angular tests. -That's telling but not decisive. -The benefit comes from reduced setup and code complexity. - -Compare these equivalent tests of `FancyService.getTimeoutValue`. - - - - - - - - - - - - - - - -They have about the same line-count, but the Angular-dependent version -has more moving parts including a couple of utility functions (`async` and `inject`). -Both approaches work and it's not much of an issue if you're using the -Angular testing utilities nearby for other reasons. -On the other hand, why burden simple service tests with added complexity? - -Pick the approach that suits you. - - -{@a services-with-dependencies} - - -### Services with dependencies - -Services often depend on other services that Angular injects into the constructor. -You can test these services _without_ the `TestBed`. -In many cases, it's easier to create and _inject_ dependencies by hand. - -The `DependentService` is a simple example: - - - - - -It delegates its only method, `getValue`, to the injected `FancyService`. - -Here are several ways to test it. - - - - - -The first test creates a `FancyService` with `new` and passes it to the `DependentService` constructor. - -However, it's rarely that simple. The injected service can be difficult to create or control. -You can mock the dependency, use a dummy value, or stub the pertinent service method -with a substitute method that's easy to control. - -These _isolated_ unit testing techniques are great for exploring the inner logic of a service or its -simple integration with a component class. -Use the Angular testing utilities when writing tests that validate how a service interacts with components -_within the Angular runtime environment_. - - -{@a isolated-pipe-tests} - - -### Pipes Pipes are easy to test without the Angular testing utilities. A pipe class has one method, `transform`, that manipulates the input @@ -2368,193 +2489,139 @@ metadata and an interface. Consider a `TitleCasePipe` that capitalizes the first letter of each word. Here's a naive implementation with a regular expression. - - - + Anything that uses a regular expression is worth testing thoroughly. Use simple Jasmine to explore the expected cases and the edge cases. - - - - + {@a write-tests} +#### Write DOM tests too -### Write Angular tests too These are tests of the pipe _in isolation_. They can't tell if the `TitleCasePipe` is working properly as applied in the application components. Consider adding component tests such as this one: - + +
+{@a test-debugging} -{@a isolated-component-tests} +## Test debugging +Debug specs in the browser in the same way that you debug an application. -### Components - -Component tests typically examine how a component class interacts with its own template or with collaborating components. -The Angular testing utilities are specifically designed to facilitate such tests. - -Consider this `ButtonComp` component. - - - - - -The following Angular test demonstrates that clicking a button in the template leads -to an update of the on-screen message. - - - - - -The assertions verify that the data values flow from one HTML control (the `