Skip to content

Commit 9fd2ef9

Browse files
atscottpkozlowski-opensource
authored andcommitted
docs: Update component testing scenarios (angular#56388)
This update goes through half of the component-scenarios guide and makes relevant updates to examples. Updates include: * Use signals in examples * Advise and use `await fixture.whenStable` more frequently than `detectChanges` * Use and advise mocks and spies more sparingly * Remove `waitForAsync` and jasmine `done` guidance - The ecosystem has evolved and these aren't needed * Remove marble testing for rxjs - this belongs in rxjs documentation, not in Angular * Remove class-only component testing. This approach is not advisable for components. related to angular#48510 PR Close angular#56388
1 parent 8466572 commit 9fd2ef9

File tree

12 files changed

+123
-411
lines changed

12 files changed

+123
-411
lines changed

adev/src/content/examples/testing/src/app/banner/banner.component.detect-changes.spec.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ describe('BannerComponent (AutoChangeDetect)', () => {
1515
beforeEach(() => {
1616
// #docregion auto-detect
1717
TestBed.configureTestingModule({
18-
imports: [BannerComponent],
1918
providers: [{provide: ComponentFixtureAutoDetect, useValue: true}],
2019
});
2120
// #enddocregion auto-detect
@@ -30,15 +29,18 @@ describe('BannerComponent (AutoChangeDetect)', () => {
3029
expect(h1.textContent).toContain(comp.title);
3130
});
3231

33-
it('should still see original title after comp.title change', () => {
32+
it('should still see original title after comp.title change', async () => {
3433
const oldTitle = comp.title;
35-
comp.title = 'Test Title';
36-
// Displayed title is old because Angular didn't hear the change :(
34+
const newTitle = 'Test Title';
35+
comp.title.set(newTitle);
36+
// Displayed title is old because Angular didn't yet run change detection
3737
expect(h1.textContent).toContain(oldTitle);
38+
await fixture.whenStable();
39+
expect(h1.textContent).toContain(newTitle);
3840
});
3941

4042
it('should display updated title after detectChanges', () => {
41-
comp.title = 'Test Title';
43+
comp.title.set('Test Title');
4244
fixture.detectChanges(); // detect changes explicitly
4345
expect(h1.textContent).toContain(comp.title);
4446
});
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import {Component} from '@angular/core';
1+
import {Component, signal} from '@angular/core';
22

33
// #docregion component
44
@Component({
55
standalone: true,
66
selector: 'app-banner',
7-
template: '<h1>{{title}}</h1>',
7+
template: '<h1>{{title()}}</h1>',
88
styles: ['h1 { color: green; font-size: 350%}'],
99
})
1010
export class BannerComponent {
11-
title = 'Test Tour of Heroes';
11+
title = signal('Test Tour of Heroes');
1212
}
1313
// #enddocregion component

adev/src/content/examples/testing/src/app/dashboard/dashboard-hero.component.spec.ts

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,39 +12,25 @@ import {DashboardHeroComponent} from './dashboard-hero.component';
1212

1313
beforeEach(addMatchers);
1414

15-
describe('DashboardHeroComponent class only', () => {
16-
// #docregion class-only
17-
it('raises the selected event when clicked', () => {
18-
const comp = new DashboardHeroComponent();
19-
const hero: Hero = {id: 42, name: 'Test'};
20-
comp.hero = hero;
21-
22-
comp.selected.pipe(first()).subscribe((selectedHero: Hero) => expect(selectedHero).toBe(hero));
23-
comp.click();
24-
});
25-
// #enddocregion class-only
26-
});
27-
2815
describe('DashboardHeroComponent when tested directly', () => {
2916
let comp: DashboardHeroComponent;
3017
let expectedHero: Hero;
3118
let fixture: ComponentFixture<DashboardHeroComponent>;
3219
let heroDe: DebugElement;
3320
let heroEl: HTMLElement;
3421

35-
beforeEach(waitForAsync(() => {
22+
beforeEach(() => {
3623
// #docregion setup, config-testbed
3724
TestBed.configureTestingModule({
3825
providers: appProviders,
39-
imports: [DashboardHeroComponent],
40-
})
41-
// #enddocregion setup, config-testbed
42-
.compileComponents();
43-
}));
26+
});
27+
// #enddocregion setup, config-testbed
28+
});
4429

45-
beforeEach(() => {
30+
beforeEach(async () => {
4631
// #docregion setup
4732
fixture = TestBed.createComponent(DashboardHeroComponent);
33+
fixture.autoDetectChanges();
4834
comp = fixture.componentInstance;
4935

5036
// find the hero's DebugElement and element
@@ -55,10 +41,10 @@ describe('DashboardHeroComponent when tested directly', () => {
5541
expectedHero = {id: 42, name: 'Test Name'};
5642

5743
// simulate the parent setting the input property with that hero
58-
comp.hero = expectedHero;
44+
fixture.componentRef.setInput('hero', expectedHero);
5945

60-
// trigger initial data binding
61-
fixture.detectChanges();
46+
// wait for initial data binding
47+
await fixture.whenStable();
6248
// #enddocregion setup
6349
});
6450

@@ -72,7 +58,7 @@ describe('DashboardHeroComponent when tested directly', () => {
7258
// #docregion click-test
7359
it('should raise selected event when clicked (triggerEventHandler)', () => {
7460
let selectedHero: Hero | undefined;
75-
comp.selected.pipe(first()).subscribe((hero: Hero) => (selectedHero = hero));
61+
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
7662

7763
// #docregion trigger-event-handler
7864
heroDe.triggerEventHandler('click');
@@ -84,7 +70,7 @@ describe('DashboardHeroComponent when tested directly', () => {
8470
// #docregion click-test-2
8571
it('should raise selected event when clicked (element.click)', () => {
8672
let selectedHero: Hero | undefined;
87-
comp.selected.pipe(first()).subscribe((hero: Hero) => (selectedHero = hero));
73+
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
8874

8975
heroEl.click();
9076
expect(selectedHero).toBe(expectedHero);
@@ -94,7 +80,7 @@ describe('DashboardHeroComponent when tested directly', () => {
9480
// #docregion click-test-3
9581
it('should raise selected event when clicked (click helper with DebugElement)', () => {
9682
let selectedHero: Hero | undefined;
97-
comp.selected.pipe(first()).subscribe((hero: Hero) => (selectedHero = hero));
83+
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
9884

9985
click(heroDe); // click helper with DebugElement
10086

@@ -104,7 +90,7 @@ describe('DashboardHeroComponent when tested directly', () => {
10490

10591
it('should raise selected event when clicked (click helper with native element)', () => {
10692
let selectedHero: Hero | undefined;
107-
comp.selected.pipe(first()).subscribe((hero: Hero) => (selectedHero = hero));
93+
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
10894

10995
click(heroEl); // click helper with native element
11096

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// #docregion
2-
import {Component, EventEmitter, Input, Output} from '@angular/core';
2+
import {Component, input, output} from '@angular/core';
33
import {UpperCasePipe} from '@angular/common';
44

55
import {Hero} from '../model/hero';
@@ -10,18 +10,18 @@ import {Hero} from '../model/hero';
1010
selector: 'dashboard-hero',
1111
template: `
1212
<button type="button" (click)="click()" class="hero">
13-
{{ hero.name | uppercase }}
13+
{{ hero().name | uppercase }}
1414
</button>
1515
`,
1616
styleUrls: ['./dashboard-hero.component.css'],
1717
imports: [UpperCasePipe],
1818
})
1919
// #docregion class
2020
export class DashboardHeroComponent {
21-
@Input() hero!: Hero;
22-
@Output() selected = new EventEmitter<Hero>();
21+
hero = input.required<Hero>();
22+
selected = output<Hero>();
2323
click() {
24-
this.selected.emit(this.hero);
24+
this.selected.emit(this.hero());
2525
}
2626
}
2727
// #enddocregion component, class

adev/src/content/examples/testing/src/app/hero/hero-detail.component.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ function heroModuleSetup() {
172172
}));
173173

174174
// #docregion title-case-pipe
175-
it('should convert hero name to Title Case', () => {
175+
it('should convert hero name to Title Case', async () => {
176+
harness.fixture.autoDetectChanges();
176177
// get the name's input and display elements from the DOM
177178
const hostElement: HTMLElement = harness.routeNativeElement!;
178179
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
@@ -184,8 +185,8 @@ function heroModuleSetup() {
184185
// Dispatch a DOM event so that Angular learns of input value change.
185186
nameInput.dispatchEvent(new Event('input'));
186187

187-
// Tell Angular to update the display binding through the title pipe
188-
harness.detectChanges();
188+
// Wait for Angular to update the display binding through the title pipe
189+
await harness.fixture.whenStable();
189190

190191
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
191192
});
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import {Injectable} from '@angular/core';
1+
import {Injectable, signal} from '@angular/core';
22

3-
@Injectable()
3+
@Injectable({providedIn: 'root'})
44
export class UserService {
5-
isLoggedIn = true;
6-
user = {name: 'Sam Spade'};
5+
isLoggedIn = signal(true);
6+
user = signal({name: 'Sam Spade'});
77
}

adev/src/content/examples/testing/src/app/twain/twain.component.spec.ts

Lines changed: 29 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular
33

44
import {asyncData, asyncError} from '../../testing';
55

6-
import {of, throwError} from 'rxjs';
6+
import {Subject, defer, of, throwError} from 'rxjs';
77
import {last} from 'rxjs/operators';
88

99
import {TwainComponent} from './twain.component';
@@ -25,21 +25,21 @@ describe('TwainComponent', () => {
2525

2626
// #docregion setup
2727
beforeEach(() => {
28+
TestBed.configureTestingModule({
29+
imports: [TwainComponent],
30+
providers: [TwainService],
31+
});
2832
testQuote = 'Test Quote';
2933

3034
// #docregion spy
3135
// Create a fake TwainService object with a `getQuote()` spy
32-
const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
36+
const twainService = TestBed.inject(TwainService);
3337
// Make the spy return a synchronous Observable with the test data
34-
getQuoteSpy = twainService.getQuote.and.returnValue(of(testQuote));
38+
getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote));
3539
// #enddocregion spy
3640

37-
TestBed.configureTestingModule({
38-
imports: [TwainComponent],
39-
providers: [{provide: TwainService, useValue: twainService}],
40-
});
41-
4241
fixture = TestBed.createComponent(TwainComponent);
42+
fixture.autoDetectChanges();
4343
component = fixture.componentInstance;
4444
quoteEl = fixture.nativeElement.querySelector('.twain');
4545
});
@@ -54,8 +54,8 @@ describe('TwainComponent', () => {
5454

5555
// The quote would not be immediately available if the service were truly async.
5656
// #docregion sync-test
57-
it('should show quote after component initialized', () => {
58-
fixture.detectChanges(); // onInit()
57+
it('should show quote after component initialized', async () => {
58+
await fixture.whenStable(); // onInit()
5959

6060
// sync spy result shows testQuote immediately after init
6161
expect(quoteEl.textContent).toBe(testQuote);
@@ -67,12 +67,20 @@ describe('TwainComponent', () => {
6767
// Use `fakeAsync` because the component error calls `setTimeout`
6868
// #docregion error-test
6969
it('should display error when TwainService fails', fakeAsync(() => {
70-
// tell spy to return an error observable
71-
getQuoteSpy.and.returnValue(throwError(() => new Error('TwainService test failure')));
70+
// tell spy to return an error observable after a timeout
71+
getQuoteSpy.and.returnValue(
72+
defer(() => {
73+
return new Promise((resolve, reject) => {
74+
setTimeout(() => {
75+
reject('TwainService test failure');
76+
});
77+
});
78+
}),
79+
);
7280
fixture.detectChanges(); // onInit()
7381
// sync spy errors immediately after init
7482

75-
tick(); // flush the component's setTimeout()
83+
tick(); // flush the setTimeout()
7684

7785
fixture.detectChanges(); // update errorMessage within setTimeout()
7886

@@ -120,46 +128,18 @@ describe('TwainComponent', () => {
120128
}));
121129
// #enddocregion fake-async-test
122130

123-
// #docregion waitForAsync-test
124-
it('should show quote after getQuote (waitForAsync)', waitForAsync(() => {
131+
// #docregion async-test
132+
it('should show quote after getQuote (async)', async () => {
125133
fixture.detectChanges(); // ngOnInit()
126134
expect(quoteEl.textContent).withContext('should show placeholder').toBe('...');
127135

128-
fixture.whenStable().then(() => {
129-
// wait for async getQuote
130-
fixture.detectChanges(); // update view with quote
131-
expect(quoteEl.textContent).toBe(testQuote);
132-
expect(errorMessage()).withContext('should not show error').toBeNull();
133-
});
134-
}));
135-
// #enddocregion waitForAsync-test
136-
137-
// #docregion quote-done-test
138-
it('should show last quote (quote done)', (done: DoneFn) => {
139-
fixture.detectChanges();
140-
141-
component.quote.pipe(last()).subscribe(() => {
142-
fixture.detectChanges(); // update view with quote
143-
expect(quoteEl.textContent).toBe(testQuote);
144-
expect(errorMessage()).withContext('should not show error').toBeNull();
145-
done();
146-
});
147-
});
148-
// #enddocregion quote-done-test
149-
150-
// #docregion spy-done-test
151-
it('should show quote after getQuote (spy done)', (done: DoneFn) => {
152-
fixture.detectChanges();
153-
154-
// the spy's most recent call returns the observable with the test quote
155-
getQuoteSpy.calls.mostRecent().returnValue.subscribe(() => {
156-
fixture.detectChanges(); // update view with quote
157-
expect(quoteEl.textContent).toBe(testQuote);
158-
expect(errorMessage()).withContext('should not show error').toBeNull();
159-
done();
160-
});
136+
await fixture.whenStable();
137+
// wait for async getQuote
138+
fixture.detectChanges(); // update view with quote
139+
expect(quoteEl.textContent).toBe(testQuote);
140+
expect(errorMessage()).withContext('should not show error').toBeNull();
161141
});
162-
// #enddocregion spy-done-test
142+
// #enddocregion async-test
163143

164144
it('should display error when TwainService fails', fakeAsync(() => {
165145
// tell spy to return an async error observable

adev/src/content/examples/testing/src/app/twain/twain.component.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// #docregion
2-
import {Component, OnInit} from '@angular/core';
2+
import {Component, OnInit, signal} from '@angular/core';
33
import {AsyncPipe} from '@angular/common';
44
import {sharedImports} from '../shared/shared';
55

@@ -16,16 +16,16 @@ import {TwainService} from './twain.service';
1616
<i>{{ quote | async }}</i>
1717
</p>
1818
<button type="button" (click)="getQuote()">Next quote</button>
19-
@if (errorMessage) {
20-
<p class="error">{{ errorMessage }}</p>
19+
@if (errorMessage()) {
20+
<p class="error">{{ errorMessage() }}</p>
2121
}`,
2222
// #enddocregion template
2323
styles: ['.twain { font-style: italic; } .error { color: red; }'],
2424
imports: [AsyncPipe, sharedImports],
2525
})
2626
export class TwainComponent implements OnInit {
27-
errorMessage!: string;
28-
quote!: Observable<string>;
27+
errorMessage = signal('');
28+
quote?: Observable<string>;
2929

3030
constructor(private twainService: TwainService) {}
3131

@@ -35,12 +35,11 @@ export class TwainComponent implements OnInit {
3535

3636
// #docregion get-quote
3737
getQuote() {
38-
this.errorMessage = '';
38+
this.errorMessage.set('');
3939
this.quote = this.twainService.getQuote().pipe(
4040
startWith('...'),
4141
catchError((err: any) => {
42-
// Wait a turn because errorMessage already set once this turn
43-
setTimeout(() => (this.errorMessage = err.message || err.toString()));
42+
this.errorMessage.set(err.message || err.toString());
4443
return of('...'); // reset message to placeholder
4544
}),
4645
);

0 commit comments

Comments
 (0)