-
- // args passed to router.navigateByUrl() spy
- const spy = router.navigateByUrl as jasmine.Spy;
- const navArgs = spy.calls.first().args[0];
+ it('should tell navigate when hero clicked', async () => {
+ await heroClick(); // trigger click on first inner
// expecting to navigate to id of the component's first hero
const id = comp.heroes[0].id;
- expect(navArgs)
- .withContext('should nav to HeroDetail for first hero')
- .toBe('/heroes/' + id);
+ expect(TestBed.inject(Router).url)
+ .withContext('should nav to HeroDetail for first hero')
+ .toEqual(`/heroes/${id}`);
});
// #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 9a65b047891c..83512c628f9b 100644
--- a/aio/content/examples/testing/src/app/dashboard/dashboard.component.ts
+++ b/aio/content/examples/testing/src/app/dashboard/dashboard.component.ts
@@ -1,29 +1,24 @@
// #docregion
-import { Component, OnInit } from '@angular/core';
-import { Router } from '@angular/router';
+import {Component, OnInit} from '@angular/core';
+import {Router} from '@angular/router';
-import { Hero } from '../model/hero';
-import { HeroService } from '../model/hero.service';
+import {Hero} from '../model/hero';
+import {HeroService} from '../model/hero.service';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
- styleUrls: [ './dashboard.component.css' ]
+ styleUrls: ['./dashboard.component.css']
})
export class DashboardComponent implements OnInit {
-
heroes: Hero[] = [];
// #docregion ctor
- constructor(
- private router: Router,
- private heroService: HeroService) {
- }
+ constructor(private router: Router, private heroService: HeroService) {}
// #enddocregion ctor
ngOnInit() {
- this.heroService.getHeroes()
- .subscribe(heroes => this.heroes = heroes.slice(1, 5));
+ this.heroService.getHeroes().subscribe(heroes => this.heroes = heroes.slice(1, 5));
}
// #docregion goto-detail
@@ -35,7 +30,6 @@ export class DashboardComponent implements OnInit {
get title() {
const cnt = this.heroes.length;
- return cnt === 0 ? 'No Heroes' :
- cnt === 1 ? 'Top Hero' : `Top ${cnt} Heroes`;
+ return cnt === 0 ? 'No Heroes' : cnt === 1 ? 'Top Hero' : `Top ${cnt} Heroes`;
}
}
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
deleted file mode 100644
index c0c55f56ad2a..000000000000
--- a/aio/content/examples/testing/src/app/hero/hero-detail.component.no-testbed.spec.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Router } from '@angular/router';
-
-import { asyncData, ActivatedRouteStub } from '../../testing';
-
-import { HeroDetailComponent } from './hero-detail.component';
-import { HeroDetailService } from './hero-detail.service';
-import { Hero } from '../model/hero';
-
-////////// Tests ////////////////////
-
-describe('HeroDetailComponent - no TestBed', () => {
- let comp: HeroDetailComponent;
- let expectedHero: Hero;
- let hds: jasmine.SpyObj
;
- let router: jasmine.SpyObj;
-
- 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(asyncData(expectedHero));
- hds.saveHero.and.returnValue(asyncData(expectedHero));
-
- comp = new HeroDetailComponent(hds, activatedRoute as any, router);
- comp.ngOnInit();
-
- // OnInit calls HDS.getHero; wait for it to get the fake hero
- hds.getHero.calls.first().returnValue.subscribe(() => done());
- });
-
- it('should expose the hero retrieved from the service', () => {
- expect(comp.hero).toBe(expectedHero);
- });
-
- it('should navigate when click cancel', () => {
- comp.cancel();
- expect(router.navigate.calls.any())
- .withContext('router.navigate called')
- .toBe(true);
- });
-
- it('should save when click save', () => {
- comp.save();
- expect(hds.saveHero.calls.any())
- .withContext('HeroDetailService.save called')
- .toBe(true);
- expect(router.navigate.calls.any())
- .withContext('router.navigate not called yet')
- .toBe(false);
- });
-
- it('should navigate when click save resolves', (done: DoneFn) => {
- comp.save();
- // waits for async save to complete before navigating
- hds.saveHero.calls.first().returnValue
- .subscribe(() => {
- expect(router.navigate.calls.any())
- .withContext('router.navigate called')
- .toBe(true);
- 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 5ef0f102a63f..384be7065a53 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
@@ -1,27 +1,26 @@
// #docplaster
-import { ComponentFixture, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
-import { Router } from '@angular/router';
+import {provideHttpClient} from '@angular/common/http';
+import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
+import {fakeAsync, TestBed, tick} from '@angular/core/testing';
+import {provideRouter, Router} from '@angular/router';
+import {RouterTestingHarness} from '@angular/router/testing';
-import {
- ActivatedRoute, ActivatedRouteStub, asyncData, click
-} from '../../testing';
+import {asyncData, click} from '../../testing';
+import {Hero} from '../model/hero';
+import {SharedModule} from '../shared/shared.module';
-import { Hero } from '../model/hero';
-import { HeroDetailComponent } from './hero-detail.component';
-import { HeroDetailService } from './hero-detail.service';
-import { HeroModule } from './hero.module';
+import {HeroDetailComponent} from './hero-detail.component';
+import {HeroDetailService} from './hero-detail.service';
+import {HeroListComponent} from './hero-list.component';
+import {HeroModule} from './hero.module';
////// Testing Vars //////
-let activatedRoute: ActivatedRouteStub;
let component: HeroDetailComponent;
-let fixture: ComponentFixture;
+let harness: RouterTestingHarness;
let page: Page;
////// Tests //////
describe('HeroDetailComponent', () => {
- beforeEach(() => {
- activatedRoute = new ActivatedRouteStub();
- });
describe('with HeroModule setup', heroModuleSetup);
describe('when override its provided HeroDetailService', overrideSetup);
describe('with FormsModule setup', formsModuleSetup);
@@ -30,10 +29,11 @@ describe('HeroDetailComponent', () => {
///////////////////
+const testHero = getTestHeroes()[0];
function overrideSetup() {
// #docregion hds-spy
class HeroDetailServiceSpy {
- testHero: Hero = {id: 42, name: 'Test Hero'};
+ testHero: Hero = {...testHero};
/* emit cloned test hero */
getHero = jasmine.createSpy('getHero').and.callFake(
@@ -46,33 +46,22 @@ function overrideSetup() {
// #enddocregion hds-spy
- // the `id` value is irrelevant because ignored by service stub
- beforeEach(() => activatedRoute.setParamMap({id: 99999}));
-
// #docregion setup-override
beforeEach(async () => {
- const routerSpy = createRouterSpy();
-
await TestBed
.configureTestingModule({
imports: [HeroModule],
providers: [
- {provide: ActivatedRoute, useValue: activatedRoute},
- {provide: Router, useValue: routerSpy},
- // #enddocregion setup-override
+ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
// HeroDetailService at this level is IRRELEVANT!
{provide: HeroDetailService, useValue: {}}
- // #docregion setup-override
]
})
-
- // Override component's own provider
// #docregion override-component-method
.overrideComponent(
HeroDetailComponent,
{set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}})
// #enddocregion override-component-method
-
.compileComponents();
});
// #enddocregion setup-override
@@ -81,18 +70,22 @@ function overrideSetup() {
let hdsSpy: HeroDetailServiceSpy;
beforeEach(async () => {
- await createComponent();
+ harness = await RouterTestingHarness.create();
+ component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent);
+ page = new Page();
// get the component's injected HeroDetailServiceSpy
- hdsSpy = fixture.debugElement.injector.get(HeroDetailService) as any;
+ hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;
+
+ harness.detectChanges();
});
it('should have called `getHero`', () => {
expect(hdsSpy.getHero.calls.count())
- .withContext('getHero called once')
- .toBe(1, 'getHero called once');
+ .withContext('getHero called once')
+ .toBe(1, 'getHero called once');
});
- it("should display stub hero's name", () => {
+ it('should display stub hero\'s name', () => {
expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});
@@ -102,62 +95,43 @@ function overrideSetup() {
page.nameInput.value = newName;
- page.nameInput.dispatchEvent(new Event('input')); // tell Angular
+ page.nameInput.dispatchEvent(new Event('input')); // tell Angular
- expect(component.hero.name)
- .withContext('component hero has new name')
- .toBe(newName);
+ expect(component.hero.name).withContext('component hero has new name').toBe(newName);
expect(hdsSpy.testHero.name)
- .withContext('service hero unchanged before save')
- .toBe(origName);
+ .withContext('service hero unchanged before save')
+ .toBe(origName);
click(page.saveBtn);
- expect(hdsSpy.saveHero.calls.count())
- .withContext('saveHero called once')
- .toBe(1);
+ expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1);
tick(); // wait for async save to complete
expect(hdsSpy.testHero.name)
- .withContext('service hero has new name after save')
- .toBe(newName);
- expect(page.navigateSpy.calls.any())
- .withContext('router.navigate called')
- .toBe(true);
- }));
- // #enddocregion override-tests
-
- it('fixture injected service is not the component injected service',
- // inject gets the service from the fixture
- inject([HeroDetailService], (fixtureService: HeroDetailService) => {
- // use `fixture.debugElement.injector` to get service from component
- const componentService = fixture.debugElement.injector.get(HeroDetailService);
-
- expect(fixtureService)
- .withContext('service injected from fixture')
- .not.toBe(componentService);
+ .withContext('service hero has new name after save')
+ .toBe(newName);
+ expect(TestBed.inject(Router).url).toEqual('/heroes');
}));
}
////////////////////
-import { getTestHeroes, TestHeroService, HeroService } from '../model/testing/test-hero.service';
+import {getTestHeroes} from '../model/testing/test-hero.service';
const firstHero = getTestHeroes()[0];
function heroModuleSetup() {
// #docregion setup-hero-module
beforeEach(async () => {
- const routerSpy = createRouterSpy();
-
await 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: TestHeroService},
- {provide: Router, useValue: routerSpy},
+ provideRouter([
+ {path: 'heroes/:id', component: HeroDetailComponent},
+ {path: 'heroes', component: HeroListComponent},
+ ]),
+ provideHttpClient(),
+ provideHttpClientTesting(),
]
})
.compileComponents();
@@ -170,50 +144,35 @@ function heroModuleSetup() {
beforeEach(async () => {
expectedHero = firstHero;
- activatedRoute.setParamMap({id: expectedHero.id});
- await createComponent();
+ await createComponent(expectedHero.id);
});
-
// #docregion selected-tests
- it("should display that hero's name", () => {
+ it('should display that hero\'s name', () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
// #enddocregion route-good-id
it('should navigate when click cancel', () => {
click(page.cancelBtn);
- expect(page.navigateSpy.calls.any())
- .withContext('router.navigate called')
- .toBe(true);
+ expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);
});
it('should save when click save but not navigate immediately', () => {
- // Get service injected into component and spy on its`saveHero` method.
- // It delegates to fake `HeroService.updateHero` which delivers a safe test result.
- const hds = fixture.debugElement.injector.get(HeroDetailService);
- const saveSpy = spyOn(hds, 'saveHero').and.callThrough();
-
click(page.saveBtn);
- expect(saveSpy.calls.any())
- .withContext('HeroDetailService.save called')
- .toBe(true);
- expect(page.navigateSpy.calls.any())
- .withContext('router.navigate not called')
- .toBe(false);
+ expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));
+ expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});
it('should navigate when click save and save resolves', fakeAsync(() => {
click(page.saveBtn);
tick(); // wait for async save to complete
- expect(page.navigateSpy.calls.any())
- .withContext('router.navigate called')
- .toBe(true);
+ expect(TestBed.inject(Router).url).toEqual('/heroes/41');
}));
// #docregion title-case-pipe
it('should convert hero name to Title Case', () => {
// get the name's input and display elements from the DOM
- const hostElement: HTMLElement = fixture.nativeElement;
+ const hostElement: HTMLElement = harness.routeNativeElement!;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
@@ -224,146 +183,97 @@ function heroModuleSetup() {
nameInput.dispatchEvent(new Event('input'));
// Tell Angular to update the display binding through the title pipe
- fixture.detectChanges();
+ harness.detectChanges();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
- // #enddocregion title-case-pipe
// #enddocregion selected-tests
- // #docregion route-good-id
- });
- // #enddocregion route-good-id
- // #docregion route-no-id
- describe('when navigate with no hero id', () => {
- beforeEach(async () => {
- await createComponent();
- });
-
- it('should have hero.id === 0', () => {
- expect(component.hero.id).toBe(0);
- });
-
- it('should display empty hero name', () => {
- expect(page.nameDisplay.textContent).toBe('');
- });
+ // #enddocregion title-case-pipe
});
- // #enddocregion route-no-id
// #docregion route-bad-id
describe('when navigate to non-existent hero id', () => {
beforeEach(async () => {
- activatedRoute.setParamMap({id: 99999});
- await createComponent();
+ await createComponent(999);
});
it('should try to navigate back to hero list', () => {
- expect(page.gotoListSpy.calls.any())
- .withContext('comp.gotoList called')
- .toBe(true);
- expect(page.navigateSpy.calls.any())
- .withContext('router.navigate called')
- .toBe(true);
+ expect(TestBed.inject(Router).url).toEqual('/heroes');
});
});
// #enddocregion route-bad-id
-
- // Why we must use `fixture.debugElement.injector` in `Page()`
- it("cannot use `inject` to get component's provided HeroDetailService", () => {
- let service: HeroDetailService;
- fixture = TestBed.createComponent(HeroDetailComponent);
- expect(
- // Throws because `inject` only has access to TestBed's injector
- // which is an ancestor of the component's injector
- inject([HeroDetailService], (hds: HeroDetailService) => service = hds))
- .toThrowError(/No provider for HeroDetailService/);
-
- // get `HeroDetailService` with component's own injector
- service = fixture.debugElement.injector.get(HeroDetailService);
- expect(service)
- .withContext('debugElement.injector')
- .toBeDefined();
- });
}
/////////////////////
-import { FormsModule } from '@angular/forms';
-import { TitleCasePipe } from '../shared/title-case.pipe';
+import {FormsModule} from '@angular/forms';
+import {TitleCasePipe} from '../shared/title-case.pipe';
function formsModuleSetup() {
// #docregion setup-forms-module
beforeEach(async () => {
- const routerSpy = createRouterSpy();
-
await TestBed
.configureTestingModule({
imports: [FormsModule],
declarations: [HeroDetailComponent, TitleCasePipe],
providers: [
- {provide: ActivatedRoute, useValue: activatedRoute},
- {provide: HeroService, useClass: TestHeroService},
- {provide: Router, useValue: routerSpy},
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
]
})
.compileComponents();
});
// #enddocregion setup-forms-module
- it("should display 1st hero's name", waitForAsync(() => {
- const expectedHero = firstHero;
- activatedRoute.setParamMap({id: expectedHero.id});
- createComponent().then(() => {
- expect(page.nameDisplay.textContent).toBe(expectedHero.name);
- });
- }));
+ it('should display 1st hero\'s name', async () => {
+ const expectedHero = firstHero;
+ await createComponent(expectedHero.id).then(() => {
+ expect(page.nameDisplay.textContent).toBe(expectedHero.name);
+ });
+ });
}
///////////////////////
-import { SharedModule } from '../shared/shared.module';
function sharedModuleSetup() {
// #docregion setup-shared-module
beforeEach(async () => {
- const routerSpy = createRouterSpy();
-
await TestBed
.configureTestingModule({
imports: [SharedModule],
declarations: [HeroDetailComponent],
providers: [
- {provide: ActivatedRoute, useValue: activatedRoute},
- {provide: HeroService, useClass: TestHeroService},
- {provide: Router, useValue: routerSpy},
+ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]),
+ provideHttpClient(),
+ provideHttpClientTesting(),
]
})
.compileComponents();
});
// #enddocregion setup-shared-module
- it("should display 1st hero's name", waitForAsync(() => {
- const expectedHero = firstHero;
- activatedRoute.setParamMap({id: expectedHero.id});
- createComponent().then(() => {
- expect(page.nameDisplay.textContent).toBe(expectedHero.name);
- });
- }));
+ it('should display 1st hero\'s name', async () => {
+ const expectedHero = firstHero;
+ await createComponent(expectedHero.id).then(() => {
+ expect(page.nameDisplay.textContent).toBe(expectedHero.name);
+ });
+ });
}
/////////// Helpers /////
-// #docregion create-component
/** Create the HeroDetailComponent, initialize it, set test variables */
-function createComponent() {
- fixture = TestBed.createComponent(HeroDetailComponent);
- 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();
- });
+// #docregion create-component
+async function createComponent(id: number) {
+ harness = await RouterTestingHarness.create();
+ component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent);
+ page = new Page();
+
+ const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);
+ const hero = getTestHeroes().find(h => h.id === Number(id));
+ request.flush(hero ? [hero] : []);
+ harness.detectChanges();
}
// #enddocregion create-component
@@ -386,30 +296,13 @@ class Page {
return this.query('input');
}
- gotoListSpy: jasmine.Spy;
- navigateSpy: jasmine.Spy;
-
- constructor(someFixture: ComponentFixture) {
- // get the navigate spy from the injected router spy object
- const routerSpy = someFixture.debugElement.injector.get(Router) as any;
- this.navigateSpy = routerSpy.navigate;
-
- // spy on component's `gotoList()` method
- const someComponent = someFixture.componentInstance;
- this.gotoListSpy = spyOn(someComponent, 'gotoList').and.callThrough();
- }
-
//// query helpers ////
private query(selector: string): T {
- return fixture.nativeElement.querySelector(selector);
+ return harness.routeNativeElement!.querySelector(selector)! as T;
}
private queryAll(selector: string): T[] {
- return fixture.nativeElement.querySelectorAll(selector);
+ return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];
}
}
// #enddocregion page
-
-function createRouterSpy() {
- return jasmine.createSpyObj('Router', ['navigate']);
-}
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 71befa59ccba..16abbc88c0ff 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,30 +1,29 @@
-import { Injectable } from '@angular/core';
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
-import { Observable } from 'rxjs';
-import { map } from 'rxjs/operators';
-
-import { Hero } from '../model/hero';
-import { HeroService } from '../model/hero.service';
+import {Hero} from '../model/hero';
+import {HeroService} from '../model/hero.service';
// #docregion prototype
-@Injectable()
+@Injectable({providedIn: 'root'})
export class HeroDetailService {
- constructor(private heroService: HeroService) { }
-// #enddocregion prototype
+ constructor(private heroService: HeroService) {}
+ // #enddocregion prototype
// Returns a clone which caller may modify safely
- getHero(id: number | string): Observable {
+ getHero(id: number|string): Observable {
if (typeof id === 'string') {
id = parseInt(id, 10);
}
return this.heroService.getHero(id).pipe(
- map(hero => hero ? Object.assign({}, hero) : null) // clone or null
+ map(hero => hero ? Object.assign({}, hero) : null) // clone or null
);
}
saveHero(hero: Hero) {
return this.heroService.updateHero(hero);
}
-// #docregion prototype
+ // #docregion prototype
}
// #enddocregion prototype
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 933ae1eee33a..6dbc9137fca9 100644
--- a/aio/content/examples/testing/src/app/model/hero.service.ts
+++ b/aio/content/examples/testing/src/app/model/hero.service.ts
@@ -1,74 +1,69 @@
-import { Injectable } from '@angular/core';
-import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
+import {HttpClient, HttpErrorResponse, HttpHeaders} from '@angular/common/http';
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs';
+import {catchError, map, tap} from 'rxjs/operators';
-import { Observable } from 'rxjs';
-import { catchError, map, tap } from 'rxjs/operators';
-
-import { Hero } from './hero';
+import {Hero} from './hero';
const httpOptions = {
- headers: new HttpHeaders({ 'Content-Type': 'application/json' })
+ headers: new HttpHeaders({'Content-Type': 'application/json'})
};
-@Injectable()
+@Injectable({providedIn: 'root'})
export class HeroService {
-
readonly heroesUrl = 'api/heroes'; // URL to web api
- constructor(private http: HttpClient) { }
+ 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;
+ .pipe(
+ tap(heroes => this.log('fetched heroes')),
+ catchError(this.handleError('getHeroes'))) as Observable;
}
/** GET hero by id. Return `undefined` when id not found */
- getHero(id: number | string): Observable {
+ getHero(id: number|string): Observable {
if (typeof id === 'string') {
id = parseInt(id, 10);
}
const url = `${this.heroesUrl}/?id=${id}`;
- return this.http.get(url)
- .pipe(
- map(heroes => heroes[0]), // returns a {0|1} element array
+ 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}`))
- );
+ 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((addedHero) => this.log(`added hero w/ id=${addedHero.id}`)),
- catchError(this.handleError('addHero'))
- );
+ return this.http.post(this.heroesUrl, hero, httpOptions)
+ .pipe(
+ tap((addedHero) => this.log(`added hero w/ id=${addedHero.id}`)),
+ catchError(this.handleError('addHero')));
}
/** DELETE: delete the hero from the server */
- deleteHero(hero: Hero | number): Observable {
+ 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'))
- );
+ return this.http.delete(url, httpOptions)
+ .pipe(
+ tap(_ => this.log(`deleted hero id=${id}`)),
+ catchError(this.handleError('deleteHero')));
}
/** 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'))
- );
+ 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.
@@ -78,9 +73,8 @@ export class HeroService {
*/
private handleError(operation = 'operation') {
return (error: HttpErrorResponse): Observable => {
-
// TODO: send the error to remote logging infrastructure
- console.error(error); // log to console instead
+ console.error(error); // log to console instead
// If a native error is caught, do not transform it. We only want to
// transform response errors that are not wrapped in an `Error`.
@@ -92,7 +86,6 @@ export class HeroService {
// TODO: better job of transforming error for user consumption
throw new Error(`${operation} failed: ${message}`);
};
-
}
private log(message: string) {
diff --git a/aio/content/examples/testing/src/testing/activated-route-stub.ts b/aio/content/examples/testing/src/testing/activated-route-stub.ts
deleted file mode 100644
index 59fec15af781..000000000000
--- a/aio/content/examples/testing/src/testing/activated-route-stub.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-// export for convenience.
-export { ActivatedRoute } from '@angular/router';
-
-// #docregion activated-route-stub
-import { convertToParamMap, ParamMap, Params } from '@angular/router';
-import { ReplaySubject } from 'rxjs';
-
-/**
- * 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 observable's next value */
- setParamMap(params: Params = {}) {
- this.subject.next(convertToParamMap(params));
- }
-}
-// #enddocregion activated-route-stub
diff --git a/aio/content/examples/testing/src/testing/index.ts b/aio/content/examples/testing/src/testing/index.ts
index bcea4482a219..3c930d4a3739 100644
--- a/aio/content/examples/testing/src/testing/index.ts
+++ b/aio/content/examples/testing/src/testing/index.ts
@@ -1,10 +1,8 @@
-import { DebugElement } from '@angular/core';
-import { tick, ComponentFixture } from '@angular/core/testing';
+import {DebugElement} from '@angular/core';
+import {ComponentFixture, tick} from '@angular/core/testing';
export * from './async-observable-helpers';
-export * from './activated-route-stub';
export * from './jasmine-matchers';
-export * from './router-link-directive-stub';
///// Short utilities /////
@@ -18,12 +16,12 @@ export function advance(f: ComponentFixture): void {
// #docregion click-event
/** Button events to pass to `DebugElement.triggerEventHandler` for RouterLink event handler */
export const ButtonClickEvents = {
- left: { button: 0 },
- right: { button: 2 }
+ left: {button: 0},
+ right: {button: 2}
};
/** Simulate element click. Defaults to mouse left-button click event. */
-export function click(el: DebugElement | HTMLElement, eventObj: any = ButtonClickEvents.left): void {
+export function click(el: DebugElement|HTMLElement, eventObj: any = ButtonClickEvents.left): void {
if (el instanceof HTMLElement) {
el.click();
} else {
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
deleted file mode 100644
index e1967ab3bd66..000000000000
--- a/aio/content/examples/testing/src/testing/router-link-directive-stub.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Directive, Input, HostListener } from '@angular/core';
-
-// export for convenience.
-export { RouterLink} from '@angular/router';
-
-/* eslint-disable @angular-eslint/directive-class-suffix, @angular-eslint/directive-selector */
-// #docregion router-link
-@Directive({
- selector: '[routerLink]'
-})
-export class RouterLinkDirectiveStub {
- @Input('routerLink') linkParams: any;
- navigatedTo: any = null;
-
- @HostListener('click')
- 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/toh-pt6/example-config.json b/aio/content/examples/toh-pt6/example-config.json
index e2cf1e1e4e75..ccf472031b4d 100644
--- a/aio/content/examples/toh-pt6/example-config.json
+++ b/aio/content/examples/toh-pt6/example-config.json
@@ -1,6 +1,6 @@
{
"tests": [
- {"cmd": "yarn", "args": ["test", "--browsers=ChromeHeadless", "--no-watch"]},
+ {"cmd": "yarn", "args": ["test", "--browsers=ChromeHeadlessNoSandbox", "--no-watch"]},
{"cmd": "yarn", "args": ["e2e", "--configuration=production", "--protractor-config=e2e/protractor-bazel.conf.js", "--no-webdriver-update", "--port=0"]}
]
}
diff --git a/aio/content/extended-diagnostics/NG8107.md b/aio/content/extended-diagnostics/NG8107.md
new file mode 100644
index 000000000000..5d1f706d8a63
--- /dev/null
+++ b/aio/content/extended-diagnostics/NG8107.md
@@ -0,0 +1,86 @@
+@name Optional chain not nullable
+
+@description
+
+This diagnostic detects when the left side of an optional chain operation (`.?`) does not include `null` or `undefined` in its type in Angular templates.
+
+
+
+import {Component} from '@angular/core';
+
+@Component({
+ template: `{{ foo?.bar }}
`,
+ // …
+})
+class MyComponent {
+ // `foo` is declared as an object which *cannot* be `null` or `undefined`.
+ foo: { bar: string} = { bar: 'bar'};
+}
+
+
+
+## What should I do instead?
+
+Update the template and declared type to be in sync. Double-check the type of the input and confirm whether it is actually expected to be nullable.
+
+If the input should be nullable, add `null` or `undefined` to its type to indicate this.
+
+
+
+import {Component} from '@angular/core';
+
+@Component({
+ // If `foo` is nullish, `bar` won't be evaluated and the express will return the nullish value (`null` or `undefined`).
+ template: `{{ foo?.bar }}
`,
+ // …
+})
+class MyComponent {
+ foo: { bar: string} | null = { bar: 'bar'};
+}
+
+
+
+If the input should not be nullable, delete the `?` operator.
+
+
+
+import {Component} from '@angular/core';
+
+@Component({
+ // Template always displays `bar` as `foo` is guaranteed to never be `null` or `undefined`
+ template: `{{ foo.bar }}
`,
+ // …
+})
+class MyComponent {
+ foo: { bar: string} = { bar: 'bar'};
+}
+
+
+
+## What if I can't avoid this?
+
+This diagnostic can be disabled by editing the project's `tsconfig.json` file:
+
+
+
+{
+ "angularCompilerOptions": {
+ "extendedDiagnostics": {
+ "checks": {
+ "optionalChainNotNullable": "suppress"
+ }
+ }
+ }
+}
+
+
+
+See [extended diagnostic configuration](extended-diagnostics#configuration) for more info.
+
+
+
+
+
+
+
+@reviewed 2023-03-02
diff --git a/aio/content/extended-diagnostics/index.md b/aio/content/extended-diagnostics/index.md
index c8d9dbf6a32a..497f65530324 100644
--- a/aio/content/extended-diagnostics/index.md
+++ b/aio/content/extended-diagnostics/index.md
@@ -11,9 +11,10 @@ Currently, Angular supports the following extended diagnostics:
* [NG8101 - `invalidBananaInBox`](extended-diagnostics/NG8101)
* [NG8102 - `nullishCoalescingNotNullable`](extended-diagnostics/NG8102)
* [NG8103 - `missingControlFlowDirective`](extended-diagnostics/NG8103)
+* [NG8104 - `textAttributeNotBinding`](extended-diagnostics/NG8104)
* [NG8105 - `missingNgForOfLet`](extended-diagnostics/NG8105)
* [NG8106 - `suffixNotSupported`](extended-diagnostics/NG8106)
-* [NG8104 - `textAttributeNotBinding`](extended-diagnostics/NG8104)
+* [NG8107 - `optionalChainNotNullable`](extended-diagnostics/NG8107)
## Configuration
diff --git a/aio/content/guide/glossary.md b/aio/content/guide/glossary.md
index abcb478e15ed..4d98b326dba8 100644
--- a/aio/content/guide/glossary.md
+++ b/aio/content/guide/glossary.md
@@ -929,7 +929,7 @@ View Engine was deprecated in version 9 and removed in version 13.
## view hierarchy
A tree of related views that can be acted on as a unit.
-The root view referenced as the *host view* of a component.
+The root view is referenced as the *host view* of a component.
A host view is the root of a tree of *embedded views*, collected in a `ViewContainerRef` view container attached to an anchor element in the hosting component.
The view hierarchy is a key part of Angular [change detection][AioGuideGlossaryChangeDetection].
diff --git a/aio/content/guide/testing-components-scenarios.md b/aio/content/guide/testing-components-scenarios.md
index 0d6b99229941..192398a9fe13 100644
--- a/aio/content/guide/testing-components-scenarios.md
+++ b/aio/content/guide/testing-components-scenarios.md
@@ -812,22 +812,13 @@ Testing the `DashboardComponent` seemed daunting in part because it involves the
-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?
-
-Fortunately, not in this case because the `DashboardComponent` isn't doing much with the `Router`
-
-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.
+Angular provides test helpers to reduce boilerplate and more effectively test code which depends on the Router and HttpClient.
-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.
+The following test clicks the displayed hero and confirms that we navigate to the expected URL.
@@ -863,36 +854,9 @@ The [ActivatedRoute in action](guide/router-tutorial-toh#activated-route-in-acti
-Tests can explore how the `HeroDetailComponent` responds to different `id` parameter values by manipulating the `ActivatedRoute` injected into the component's constructor.
-
-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 component* tests need a test double for `ActivatedRoute`
-
-These differences argue for a re-usable stub class.
-
-#### `ActivatedRouteStub`
-
-The following `ActivatedRouteStub` class serves as a test double for `ActivatedRoute`.
-
-
-
-Consider placing such helpers in a `testing` folder sibling to the `app` folder.
-This sample puts `ActivatedRouteStub` in `testing/activated-route-stub.ts`.
-
-
-
-Consider writing a more capable version of this stub class with the [*marble testing library*](#marble-testing).
-
-
+Tests can explore how the `HeroDetailComponent` responds to different `id` parameter values by navigating to different routes.
-#### Testing with `ActivatedRouteStub`
+#### Testing with the `RouterTestingHarness`
Here's a test demonstrating the component's behavior when the observed `id` refers to an existing hero:
@@ -907,21 +871,12 @@ 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 router spy [described above](#routing-component) which spies on the router without actually navigating.
+The test suite setup provided the same router harness [described above](#routing-component).
This test expects the component to try to navigate to the `HeroListComponent`.
-While this application 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`.
-
-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 might contain more components.
@@ -932,8 +887,6 @@ The `AppComponent`, for example, displays a navigation bar with anchors and thei
-While the `AppComponent` *class* is empty, you might want to write unit tests to confirm that the links are wired properly to the `RouterLink` directives, perhaps for the reasons as explained in the [following section](#why-stubbed-routerlink-tests).
-
To validate the links, you don't need the `Router` to navigate and you don't need the `