Skip to content

Commit 658cf8c

Browse files
atscottdylhunn
authored andcommitted
fix(core): ComponentFixture stability should match ApplicationRef (angular#54949)
This change aligns the stability of `ComponentFixture` with that of `ApplicationRef`, preventing confusing differences between the two as more APIs start using the `PendingTasks` that may not be tracked by `NgZone`. BREAKING CHANGE: `ComponentFixture.whenStable` now matches the `ApplicationRef.isStable` observable. Prior to this change, stability of the fixture did not include everything that was considered in `ApplicationRef`. `whenStable` of the fixture will now include unfinished router navigations and unfinished `HttpClient` requests. This will cause tests that `await` the `whenStable` promise to time out when there are incomplete requests. To fix this, remove the `whenStable`, instead wait for another condition, or ensure `HttpTestingController` mocks responses for all requests. Try adding `HttpTestingController.verify()` before your `await fixture.whenStable` to identify the open requests. Also, make sure your tests wait for the stability promise. We found many examples of tests that did not, meaning the expectations did not execute within the test body. In addition, `ComponentFixture.isStable` would synchronously switch to true in some scenarios but will now always be asynchronous. PR Close angular#54949
1 parent 60f1d68 commit 658cf8c

File tree

4 files changed

+31
-87
lines changed

4 files changed

+31
-87
lines changed

goldens/public-api/core/testing/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ export abstract class ComponentFixture<T> {
4444
abstract detectChanges(checkNoChanges?: boolean): void;
4545
elementRef: ElementRef;
4646
getDeferBlocks(): Promise<DeferBlockFixture[]>;
47-
abstract isStable(): boolean;
47+
isStable(): boolean;
4848
nativeElement: any;
4949
// (undocumented)
5050
ngZone: NgZone | null;
5151
whenRenderingDone(): Promise<any>;
52-
abstract whenStable(): Promise<any>;
52+
whenStable(): Promise<any>;
5353
}
5454

5555
// @public (undocumented)

packages/core/test/component_fixture_spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ describe('ComponentFixture', () => {
133133
const element = componentFixture.debugElement.children[0];
134134
dispatchEvent(element.nativeElement, 'click');
135135

136-
expect(componentFixture.isStable()).toBe(true);
137136
expect(componentFixture.nativeElement).toHaveText('11');
138137
});
139138

packages/core/testing/src/component_fixture.ts

Lines changed: 11 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ApplicationRef, ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, inject, NgZone, RendererFactory2, ViewRef, ɵDeferBlockDetails as DeferBlockDetails, ɵdetectChangesInViewIfRequired, ɵEffectScheduler as EffectScheduler, ɵgetDeferBlocks as getDeferBlocks, ɵNoopNgZone as NoopNgZone, ɵPendingTasks as PendingTasks,} from '@angular/core';
9+
import {ApplicationRef, ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, inject, NgZone, RendererFactory2, ViewRef, ɵDeferBlockDetails as DeferBlockDetails, ɵdetectChangesInViewIfRequired, ɵEffectScheduler as EffectScheduler, ɵgetDeferBlocks as getDeferBlocks, ɵNoopNgZone as NoopNgZone, ɵPendingTasks as PendingTasks} from '@angular/core';
1010
import {Subject, Subscription} from 'rxjs';
1111
import {first} from 'rxjs/operators';
1212

@@ -62,6 +62,7 @@ export abstract class ComponentFixture<T> {
6262
protected readonly _appRef = inject(ApplicationRef);
6363
/** @internal */
6464
protected readonly _testAppRef = this._appRef as unknown as TestAppRef;
65+
private readonly pendingTasks = inject(PendingTasks);
6566

6667
// TODO(atscott): Remove this from public API
6768
ngZone = this._noZoneOptionIsSet ? null : this._ngZone;
@@ -99,15 +100,22 @@ export abstract class ComponentFixture<T> {
99100
* Return whether the fixture is currently stable or has async tasks that have not been completed
100101
* yet.
101102
*/
102-
abstract isStable(): boolean;
103+
isStable(): boolean {
104+
return !this.pendingTasks.hasPendingTasks.value;
105+
}
103106

104107
/**
105108
* Get a promise that resolves when the fixture is stable.
106109
*
107110
* This can be used to resume testing after events have triggered asynchronous activity or
108111
* asynchronous change detection.
109112
*/
110-
abstract whenStable(): Promise<any>;
113+
whenStable(): Promise<any> {
114+
if (this.isStable()) {
115+
return Promise.resolve(false);
116+
}
117+
return this._appRef.isStable.pipe(first(stable => stable)).toPromise();
118+
}
111119

112120
/**
113121
* Retrieves all defer block fixtures in the component fixture.
@@ -165,7 +173,6 @@ export abstract class ComponentFixture<T> {
165173
export class ScheduledComponentFixture<T> extends ComponentFixture<T> {
166174
private readonly disableDetectChangesError =
167175
inject(AllowDetectChangesAndAcknowledgeItCanHideApplicationBugs, {optional: true}) ?? false;
168-
private readonly pendingTasks = inject(PendingTasks);
169176

170177
initialize(): void {
171178
this._appRef.attachView(this.componentRef.hostView);
@@ -188,17 +195,6 @@ export class ScheduledComponentFixture<T> extends ComponentFixture<T> {
188195
this._effectRunner.flush();
189196
}
190197

191-
override isStable(): boolean {
192-
return !this.pendingTasks.hasPendingTasks.value;
193-
}
194-
195-
override whenStable(): Promise<boolean> {
196-
if (this.isStable()) {
197-
return Promise.resolve(false);
198-
}
199-
return this._appRef.isStable.pipe(first((stable) => stable)).toPromise().then(() => true);
200-
}
201-
202198
override autoDetectChanges(autoDetect?: boolean|undefined): void {
203199
throw new Error('Cannot call autoDetectChanges when using change detection scheduling.');
204200
}
@@ -216,9 +212,6 @@ interface TestAppRef {
216212
export class PseudoApplicationComponentFixture<T> extends ComponentFixture<T> {
217213
private _subscriptions = new Subscription();
218214
private _autoDetect = inject(ComponentFixtureAutoDetect, {optional: true}) ?? false;
219-
private _isStable: boolean = true;
220-
private _promise: Promise<boolean>|null = null;
221-
private _resolve: ((result: boolean) => void)|null = null;
222215
private afterTickSubscription: Subscription|undefined = undefined;
223216
private beforeRenderSubscription: Subscription|undefined = undefined;
224217

@@ -232,36 +225,6 @@ export class PseudoApplicationComponentFixture<T> extends ComponentFixture<T> {
232225
// Create subscriptions outside the NgZone so that the callbacks run outside
233226
// of NgZone.
234227
this._ngZone.runOutsideAngular(() => {
235-
this._subscriptions.add(
236-
this._ngZone.onUnstable.subscribe({
237-
next: () => {
238-
this._isStable = false;
239-
},
240-
}),
241-
);
242-
this._subscriptions.add(
243-
this._ngZone.onStable.subscribe({
244-
next: () => {
245-
this._isStable = true;
246-
// Check whether there is a pending whenStable() completer to resolve.
247-
if (this._promise !== null) {
248-
// If so check whether there are no pending macrotasks before resolving.
249-
// Do this check in the next tick so that ngZone gets a chance to update the state
250-
// of pending macrotasks.
251-
queueMicrotask(() => {
252-
if (!this._ngZone.hasPendingMacrotasks) {
253-
if (this._promise !== null) {
254-
this._resolve!(true);
255-
this._resolve = null;
256-
this._promise = null;
257-
}
258-
}
259-
});
260-
}
261-
},
262-
}),
263-
);
264-
265228
this._subscriptions.add(
266229
this._ngZone.onError.subscribe({
267230
next: (error: any) => {
@@ -287,23 +250,6 @@ export class PseudoApplicationComponentFixture<T> extends ComponentFixture<T> {
287250
this._effectRunner.flush();
288251
}
289252

290-
override isStable(): boolean {
291-
return this._isStable && !this._ngZone.hasPendingMacrotasks;
292-
}
293-
294-
override whenStable(): Promise<boolean> {
295-
if (this.isStable()) {
296-
return Promise.resolve(false);
297-
} else if (this._promise !== null) {
298-
return this._promise;
299-
} else {
300-
this._promise = new Promise((res) => {
301-
this._resolve = res;
302-
});
303-
return this._promise;
304-
}
305-
}
306-
307253
override autoDetectChanges(autoDetect = true): void {
308254
if (this._noZoneOptionIsSet) {
309255
throw new Error('Cannot call autoDetectChanges when ComponentFixtureNoNgZone is set.');

packages/forms/test/template_integration_spec.ts

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ describe('template-driven forms integration tests', () => {
267267
});
268268
}));
269269

270-
it('should set status classes involving nested FormGroups', () => {
270+
it('should set status classes involving nested FormGroups', async () => {
271271
const fixture = initTest(NgModelNestedForm);
272272
fixture.componentInstance.first = '';
273273
fixture.componentInstance.other = '';
@@ -277,29 +277,28 @@ describe('template-driven forms integration tests', () => {
277277
const modelGroup = fixture.debugElement.query(By.css('[ngModelGroup]')).nativeElement;
278278
const input = fixture.debugElement.query(By.css('input')).nativeElement;
279279

280-
fixture.whenStable().then(() => {
281-
fixture.detectChanges();
282-
expect(sortedClassList(modelGroup)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
280+
await fixture.whenStable();
281+
fixture.detectChanges();
282+
expect(sortedClassList(modelGroup)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
283283

284-
expect(sortedClassList(form)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
284+
expect(sortedClassList(form)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
285285

286-
const formEl = fixture.debugElement.query(By.css('form')).nativeElement;
287-
dispatchEvent(formEl, 'submit');
288-
fixture.detectChanges();
286+
const formEl = fixture.debugElement.query(By.css('form')).nativeElement;
287+
dispatchEvent(formEl, 'submit');
288+
fixture.detectChanges();
289289

290-
expect(sortedClassList(modelGroup)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
291-
expect(sortedClassList(form)).toEqual([
292-
'ng-pristine', 'ng-submitted', 'ng-untouched', 'ng-valid'
293-
]);
294-
expect(sortedClassList(input)).not.toContain('ng-submitted');
290+
expect(sortedClassList(modelGroup)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
291+
expect(sortedClassList(form)).toEqual([
292+
'ng-pristine', 'ng-submitted', 'ng-untouched', 'ng-valid'
293+
]);
294+
expect(sortedClassList(input)).not.toContain('ng-submitted');
295295

296-
dispatchEvent(formEl, 'reset');
297-
fixture.detectChanges();
296+
dispatchEvent(formEl, 'reset');
297+
fixture.detectChanges();
298298

299-
expect(sortedClassList(modelGroup)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
300-
expect(sortedClassList(form)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
301-
expect(sortedClassList(input)).not.toContain('ng-submitted');
302-
});
299+
expect(sortedClassList(modelGroup)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
300+
expect(sortedClassList(form)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
301+
expect(sortedClassList(input)).not.toContain('ng-submitted');
303302
});
304303

305304
it('should not create a template-driven form when ngNoForm is used', () => {

0 commit comments

Comments
 (0)