From 3ca34e606d9bc11abe42bbb37c5d6a2b74dc17ea Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 18 Jan 2024 14:09:19 -0800 Subject: [PATCH] refactor(core): Update `ComponentFixture` behavior when using zoneless scheduler (#54024) When the zoneless scheduler is provided, we want to update the behavior of `ComponentFixture` to address common issues and painpoints in testing. Developers should never have to call `detectChanges` on a fixture manually. Instead of calling `detectChanges` after performing an action that updates state and requies a template refresh, developers should wait for change detection to run because the update needs to also have notified the scheduler. If this was not the case, the component would not work correctly in the application. Calling `detectChanges` to force an update could hide real bugs. This commit also updates the zoneless tests to uses `ComponentFixture` instead of manually attaching to the `ApplicationRef` and rewriting a lot of the helpers (`getDebugNode`, `isStable` as a value, `whenStable` as a Promise). PR Close #54024 --- goldens/public-api/core/testing/index.md | 10 +- .../test/change_detection_scheduler_spec.ts | 187 ++++++------ .../core/testing/src/component_fixture.ts | 274 +++++++++++------- packages/core/testing/src/test_bed.ts | 15 +- packages/core/testing/src/test_bed_common.ts | 7 + packages/core/testing/src/testing.ts | 2 +- 6 files changed, 280 insertions(+), 215 deletions(-) diff --git a/goldens/public-api/core/testing/index.md b/goldens/public-api/core/testing/index.md index 3b81a9a8dc8..914ac63e97d 100644 --- a/goldens/public-api/core/testing/index.md +++ b/goldens/public-api/core/testing/index.md @@ -31,9 +31,9 @@ export const __core_private_testing_placeholder__ = ""; export function async(fn: Function): (done: any) => any; // @public -export class ComponentFixture { +export abstract class ComponentFixture { constructor(componentRef: ComponentRef); - autoDetectChanges(autoDetect?: boolean): void; + abstract autoDetectChanges(autoDetect?: boolean): void; changeDetectorRef: ChangeDetectorRef; checkNoChanges(): void; componentInstance: T; @@ -41,15 +41,15 @@ export class ComponentFixture { componentRef: ComponentRef; debugElement: DebugElement; destroy(): void; - detectChanges(checkNoChanges?: boolean): void; + abstract detectChanges(checkNoChanges?: boolean): void; elementRef: ElementRef; getDeferBlocks(): Promise; - isStable(): boolean; + abstract isStable(): boolean; nativeElement: any; // (undocumented) ngZone: NgZone | null; whenRenderingDone(): Promise; - whenStable(): Promise; + abstract whenStable(): Promise; } // @public (undocumented) diff --git a/packages/core/test/change_detection_scheduler_spec.ts b/packages/core/test/change_detection_scheduler_spec.ts index 940b2f0f592..2f12fbab87e 100644 --- a/packages/core/test/change_detection_scheduler_spec.ts +++ b/packages/core/test/change_detection_scheduler_spec.ts @@ -8,30 +8,19 @@ import {AsyncPipe} from '@angular/common'; import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id'; -import {ApplicationRef, ChangeDetectorRef, Component, ComponentRef, createComponent, DebugElement, destroyPlatform, ElementRef, EnvironmentInjector, ErrorHandler, getDebugNode, inject, Injectable, Input, NgZone, PLATFORM_ID, signal, TemplateRef, Type, ViewChild, ViewContainerRef, ɵprovideZonelessChangeDetection as provideZonelessChangeDetection} from '@angular/core'; +import {ApplicationRef, ChangeDetectorRef, Component, createComponent, destroyPlatform, ElementRef, EnvironmentInjector, ErrorHandler, inject, Input, PLATFORM_ID, signal, TemplateRef, Type, ViewChild, ViewContainerRef, ɵprovideZonelessChangeDetection as provideZonelessChangeDetection} from '@angular/core'; import {toSignal} from '@angular/core/rxjs-interop'; -import {TestBed} from '@angular/core/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; import {bootstrapApplication} from '@angular/platform-browser'; import {withBody} from '@angular/private/testing'; import {BehaviorSubject, firstValueFrom} from 'rxjs'; import {filter, take, tap} from 'rxjs/operators'; describe('Angular with NoopNgZone', () => { - function whenStable(applicationRef = TestBed.inject(ApplicationRef)): Promise { - return firstValueFrom(applicationRef.isStable.pipe(filter(stable => stable))); - } - - function isStable(injector = TestBed.inject(EnvironmentInjector)): boolean { - return toSignal(injector.get(ApplicationRef).isStable, {requireSync: true, injector})(); - } - - async function createAndAttachComponent(type: Type): Promise> { - const environmentInjector = TestBed.inject(EnvironmentInjector); - const component = createComponent(type, {environmentInjector}); - environmentInjector.get(ApplicationRef).attachView(component.hostView); - expect(isStable()).toBeFalse(); - await whenStable(); - return component; + async function createFixture(type: Type): Promise> { + const fixture = TestBed.createComponent(type); + await fixture.whenStable(); + return fixture; } describe('notifies scheduler', () => { @@ -45,21 +34,16 @@ describe('Angular with NoopNgZone', () => { class TestComponent { val = val; } - const environmentInjector = TestBed.inject(EnvironmentInjector); - const component = createComponent(TestComponent, {environmentInjector}); - const appRef = environmentInjector.get(ApplicationRef); - - appRef.attachView(component.hostView); - expect(isStable()).toBeFalse(); + const fixture = await createFixture(TestComponent); // Cause another pending CD immediately after render and verify app has not stabilized - await whenStable().then(() => { + await fixture.whenStable().then(() => { val.set('new'); }); - expect(isStable()).toBeFalse(); + expect(fixture.isStable()).toBeFalse(); - await whenStable(); - expect(isStable()).toBeTrue(); + await fixture.whenStable(); + expect(fixture.isStable()).toBeTrue(); }); it('when signal updates', async () => { @@ -69,13 +53,13 @@ describe('Angular with NoopNgZone', () => { val = val; } - const component = await createAndAttachComponent(TestComponent); - expect(component.location.nativeElement.innerText).toEqual('initial'); + const fixture = await createFixture(TestComponent); + expect(fixture.nativeElement.innerText).toEqual('initial'); val.set('new'); - expect(isStable()).toBeFalse(); - await whenStable(); - expect(component.location.nativeElement.innerText).toEqual('new'); + expect(fixture.isStable()).toBeFalse(); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('new'); }); it('when using markForCheck()', async () => { @@ -89,13 +73,13 @@ describe('Angular with NoopNgZone', () => { } } - const component = await createAndAttachComponent(TestComponent); - expect(component.location.nativeElement.innerText).toEqual('initial'); + const fixture = await createFixture(TestComponent); + expect(fixture.nativeElement.innerText).toEqual('initial'); - component.instance.setVal('new'); - expect(isStable()).toBe(false); - await whenStable(); - expect(component.location.nativeElement.innerText).toEqual('new'); + fixture.componentInstance.setVal('new'); + expect(fixture.isStable()).toBe(false); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('new'); }); it('on input binding', async () => { @@ -104,13 +88,13 @@ describe('Angular with NoopNgZone', () => { @Input() val = 'initial'; } - const component = await createAndAttachComponent(TestComponent); - expect(component.location.nativeElement.innerText).toEqual('initial'); + const fixture = await createFixture(TestComponent); + expect(fixture.nativeElement.innerText).toEqual('initial'); - component.setInput('val', 'new'); - expect(isStable()).toBe(false); - await whenStable(); - expect(component.location.nativeElement.innerText).toEqual('new'); + fixture.componentRef.setInput('val', 'new'); + expect(fixture.isStable()).toBe(false); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('new'); }); it('on event listener bound in template', async () => { @@ -123,15 +107,14 @@ describe('Angular with NoopNgZone', () => { } } - const component = await createAndAttachComponent(TestComponent); - expect(component.location.nativeElement.innerText).toEqual('initial'); + const fixture = await createFixture(TestComponent); + expect(fixture.nativeElement.innerText).toEqual('initial'); - getDebugElement(component) - .query(p => p.nativeElement.tagName === 'DIV') + fixture.debugElement.query(p => p.nativeElement.tagName === 'DIV') .triggerEventHandler('click'); - expect(isStable()).toBe(false); - await whenStable(); - expect(component.location.nativeElement.innerText).toEqual('new'); + expect(fixture.isStable()).toBe(false); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('new'); }); it('on event listener bound in host', async () => { @@ -144,13 +127,13 @@ describe('Angular with NoopNgZone', () => { } } - const component = await createAndAttachComponent(TestComponent); - expect(component.location.nativeElement.innerText).toEqual('initial'); + const fixture = await createFixture(TestComponent); + expect(fixture.nativeElement.innerText).toEqual('initial'); - getDebugElement(component).triggerEventHandler('click'); - expect(isStable()).toBe(false); - await whenStable(); - expect(component.location.nativeElement.innerText).toEqual('new'); + fixture.debugElement.triggerEventHandler('click'); + expect(fixture.isStable()).toBe(false); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('new'); }); it('with async pipe', async () => { @@ -159,13 +142,13 @@ describe('Angular with NoopNgZone', () => { val = new BehaviorSubject('initial'); } - const component = await createAndAttachComponent(TestComponent); - expect(component.location.nativeElement.innerText).toEqual('initial'); + const fixture = await createFixture(TestComponent); + expect(fixture.nativeElement.innerText).toEqual('initial'); - component.instance.val.next('new'); - expect(isStable()).toBe(false); - await whenStable(); - expect(component.location.nativeElement.innerText).toEqual('new'); + fixture.componentInstance.val.next('new'); + expect(fixture.isStable()).toBe(false); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('new'); }); it('when creating a view', async () => { @@ -182,12 +165,12 @@ describe('Angular with NoopNgZone', () => { } } - const component = await createAndAttachComponent(TestComponent); + const fixture = await createFixture(TestComponent); - component.instance.createView(); - expect(isStable()).toBe(false); - await whenStable(); - expect(component.location.nativeElement.innerText).toEqual('binding'); + fixture.componentInstance.createView(); + expect(fixture.isStable()).toBe(false); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('binding'); }); it('when inserting a view', async () => { @@ -206,14 +189,14 @@ describe('Angular with NoopNgZone', () => { @ViewChild('ref', {read: ViewContainerRef}) viewContainer!: ViewContainerRef; } - const componentRef = await createAndAttachComponent(TestComponent); + const fixture = await createFixture(TestComponent); const otherComponent = createComponent(DynamicCmp, {environmentInjector: TestBed.inject(EnvironmentInjector)}); - componentRef.instance.viewContainer.insert(otherComponent.hostView); - expect(isStable()).toBe(false); - await whenStable(); - expect(componentRef.location.nativeElement.innerText).toEqual('binding'); + fixture.componentInstance.viewContainer.insert(otherComponent.hostView); + expect(fixture.isStable()).toBe(false); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('binding'); }); it('when destroying a view (with animations)', async () => { @@ -232,28 +215,35 @@ describe('Angular with NoopNgZone', () => { @ViewChild('ref', {read: ViewContainerRef}) viewContainer!: ViewContainerRef; } - const fixture = await createAndAttachComponent(TestComponent); + const fixture = await createFixture(TestComponent); const component = createComponent(DynamicCmp, {environmentInjector: TestBed.inject(EnvironmentInjector)}); - fixture.instance.viewContainer.insert(component.hostView); - await whenStable(); - expect(fixture.location.nativeElement.innerText).toEqual('binding'); - fixture.instance.viewContainer.remove(); - await whenStable(); - expect(fixture.location.nativeElement.innerText).toEqual(''); + fixture.componentInstance.viewContainer.insert(component.hostView); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('binding'); + fixture.componentInstance.viewContainer.remove(); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual(''); const component2 = createComponent(DynamicCmp, {environmentInjector: TestBed.inject(EnvironmentInjector)}); - fixture.instance.viewContainer.insert(component2.hostView); - await whenStable(); - expect(fixture.location.nativeElement.innerText).toEqual('binding'); + fixture.componentInstance.viewContainer.insert(component2.hostView); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('binding'); component2.destroy(); - expect(isStable()).toBe(false); - await whenStable(); - expect(fixture.location.nativeElement.innerText).toEqual(''); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual(''); }); + function whenStable(applicationRef = TestBed.inject(ApplicationRef)): Promise { + return firstValueFrom(applicationRef.isStable.pipe(filter(stable => stable))); + } + + function isStable(injector = TestBed.inject(EnvironmentInjector)): boolean { + return toSignal(injector.get(ApplicationRef).isStable, {requireSync: true, injector})(); + } + it('when destroying a view (*no* animations)', withBody('', async () => { destroyPlatform(); @Component({ @@ -335,8 +325,8 @@ describe('Angular with NoopNgZone', () => { val = val; } - const component = await createAndAttachComponent(TestComponent); - expect(component.location.nativeElement.innerText).toEqual('initial'); + const fixture = await createFixture(TestComponent); + expect(fixture.nativeElement.innerText).toEqual('initial'); val.set('new'); await TestBed.inject(ApplicationRef) @@ -347,8 +337,8 @@ describe('Angular with NoopNgZone', () => { tap(() => val.set('newer')), ) .toPromise(); - await whenStable(); - expect(component.location.nativeElement.innerText).toEqual('newer'); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('newer'); }); }); @@ -379,23 +369,18 @@ describe('Angular with NoopNgZone', () => { ] }); - const component = await createAndAttachComponent(TestComponent); - expect(component.location.nativeElement.innerText).toEqual('initial'); + const fixture = await createFixture(TestComponent); + expect(fixture.nativeElement.innerText).toEqual('initial'); val.set('new'); throwError = true; // error is thrown in a timeout and can't really be "caught". // Still need to wrap in expect so it happens in the expect context and doesn't fail the test. - expect(async () => await whenStable()).not.toThrow(); - expect(component.location.nativeElement.innerText).toEqual('initial'); + expect(async () => await fixture.whenStable()).not.toThrow(); + expect(fixture.nativeElement.innerText).toEqual('initial'); throwError = false; - await whenStable(); - expect(component.location.nativeElement.innerText).toEqual('new'); + await fixture.whenStable(); + expect(fixture.nativeElement.innerText).toEqual('new'); }); }); - - -function getDebugElement(component: ComponentRef) { - return getDebugNode(component.location.nativeElement) as DebugElement; -} diff --git a/packages/core/testing/src/component_fixture.ts b/packages/core/testing/src/component_fixture.ts index c271f40fd6e..be60ae90f1f 100644 --- a/packages/core/testing/src/component_fixture.ts +++ b/packages/core/testing/src/component_fixture.ts @@ -6,11 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {ApplicationRef, ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, inject, NgZone, RendererFactory2, ɵDeferBlockDetails as DeferBlockDetails, ɵEffectScheduler as EffectScheduler, ɵgetDeferBlocks as getDeferBlocks, ɵNoopNgZone as NoopNgZone} from '@angular/core'; -import {Subscription} from 'rxjs'; +import {ApplicationRef, ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, inject, NgZone, RendererFactory2, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵDeferBlockDetails as DeferBlockDetails, ɵEffectScheduler as EffectScheduler, ɵgetDeferBlocks as getDeferBlocks, ɵNoopNgZone as NoopNgZone, ɵPendingTasks as PendingTasks} from '@angular/core'; +import {firstValueFrom, Subscription} from 'rxjs'; +import {filter, take} from 'rxjs/operators'; import {DeferBlockFixture} from './defer'; -import {ComponentFixtureAutoDetect, ComponentFixtureNoNgZone} from './test_bed_common'; +import {AllowDetectChangesAndAcknowledgeItCanHideApplicationBugs, ComponentFixtureAutoDetect, ComponentFixtureNoNgZone} from './test_bed_common'; /** @@ -18,7 +19,7 @@ import {ComponentFixtureAutoDetect, ComponentFixtureNoNgZone} from './test_bed_c * * @publicApi */ -export class ComponentFixture { +export abstract class ComponentFixture { /** * The DebugElement associated with the root element of this component. */ @@ -45,25 +46,24 @@ export class ComponentFixture { changeDetectorRef: ChangeDetectorRef; private _renderer: RendererFactory2|null|undefined; - private _isStable: boolean = true; private _isDestroyed: boolean = false; - private _resolve: ((result: boolean) => void)|null = null; - private _promise: Promise|null = null; - private readonly noZoneOptionIsSet = inject(ComponentFixtureNoNgZone, {optional: true}); - private _ngZone: NgZone = this.noZoneOptionIsSet ? new NoopNgZone() : inject(NgZone); - private _autoDetect = inject(ComponentFixtureAutoDetect, {optional: true}) ?? false; - private effectRunner = inject(EffectScheduler); - private _subscriptions = new Subscription(); + /** @internal */ + protected readonly _noZoneOptionIsSet = inject(ComponentFixtureNoNgZone, {optional: true}); + /** @internal */ + protected _ngZone: NgZone = this._noZoneOptionIsSet ? new NoopNgZone() : inject(NgZone); + /** @internal */ + protected _effectRunner = inject(EffectScheduler); // Inject ApplicationRef to ensure NgZone stableness causes after render hooks to run // This will likely happen as a result of fixture.detectChanges because it calls ngZone.run // This is a crazy way of doing things but hey, it's the world we live in. // The zoneless scheduler should instead do this more imperatively by attaching // the `ComponentRef` to `ApplicationRef` and calling `appRef.tick` as the `detectChanges` // behavior. - private appRef = inject(ApplicationRef); + /** @internal */ + protected readonly _appRef = inject(ApplicationRef); // TODO(atscott): Remove this from public API - public ngZone = this.noZoneOptionIsSet ? null : this._ngZone; + ngZone = this._noZoneOptionIsSet ? null : this._ngZone; /** @nodoc */ constructor(public componentRef: ComponentRef) { @@ -73,10 +73,146 @@ export class ComponentFixture { this.componentInstance = componentRef.instance; this.nativeElement = this.elementRef.nativeElement; this.componentRef = componentRef; - this.setupNgZone(); } - private setupNgZone() { + /** + * Trigger a change detection cycle for the component. + */ + abstract detectChanges(checkNoChanges?: boolean): void; + + /** + * Do a change detection run to make sure there were no changes. + */ + checkNoChanges(): void { + this.changeDetectorRef.checkNoChanges(); + } + + /** + * Set whether the fixture should autodetect changes. + * + * Also runs detectChanges once so that any existing change is detected. + */ + abstract autoDetectChanges(autoDetect?: boolean): void; + + /** + * Return whether the fixture is currently stable or has async tasks that have not been completed + * yet. + */ + abstract isStable(): boolean; + + /** + * Get a promise that resolves when the fixture is stable. + * + * This can be used to resume testing after events have triggered asynchronous activity or + * asynchronous change detection. + */ + abstract whenStable(): Promise; + + /** + * Retrieves all defer block fixtures in the component fixture. + * + * @developerPreview + */ + getDeferBlocks(): Promise { + const deferBlocks: DeferBlockDetails[] = []; + const lView = (this.componentRef.hostView as any)['_lView']; + getDeferBlocks(lView, deferBlocks); + + const deferBlockFixtures = []; + for (const block of deferBlocks) { + deferBlockFixtures.push(new DeferBlockFixture(block, this)); + } + + return Promise.resolve(deferBlockFixtures); + } + + + private _getRenderer() { + if (this._renderer === undefined) { + this._renderer = this.componentRef.injector.get(RendererFactory2, null); + } + return this._renderer as RendererFactory2 | null; + } + + /** + * Get a promise that resolves when the ui state is stable following animations. + */ + whenRenderingDone(): Promise { + const renderer = this._getRenderer(); + if (renderer && renderer.whenRenderingDone) { + return renderer.whenRenderingDone(); + } + return this.whenStable(); + } + + /** + * Trigger component destruction. + */ + destroy(): void { + if (!this._isDestroyed) { + this.componentRef.destroy(); + this._isDestroyed = true; + } + } +} + +/** + * ComponentFixture behavior that actually attaches the component to the application to ensure + * behaviors between fixture and application do not diverge. `detectChanges` is disabled by default + * (instead, tests should wait for the scheduler to detect changes), `whenStable` is directly the + * `ApplicationRef.isStable`, and `autoDetectChanges` cannot be disabled. + */ +export class ScheduledComponentFixture extends ComponentFixture { + private readonly disableDetectChangesError = + inject(AllowDetectChangesAndAcknowledgeItCanHideApplicationBugs, {optional: true}) ?? false; + private readonly pendingTasks = inject(PendingTasks); + + initialize(): void { + this._appRef.attachView(this.componentRef.hostView); + } + + override detectChanges(checkNoChanges: boolean = true): void { + if (!this.disableDetectChangesError) { + throw new Error( + 'Do not use `detectChanges` directly when using zoneless change detection.' + + ' Instead, wait for the next render or `fixture.whenStable`.'); + } else if (!checkNoChanges) { + throw new Error( + 'Cannot disable `checkNoChanges` in this configuration. ' + + 'Use `fixture.componentRef.hostView.changeDetectorRef.detectChanges()` instead.'); + } + this._effectRunner.flush(); + this._appRef.tick(); + this._effectRunner.flush(); + } + + override isStable(): boolean { + return !this.pendingTasks.hasPendingTasks.value; + } + + override whenStable(): Promise { + if (this.isStable()) { + return Promise.resolve(false); + } + return firstValueFrom(this._appRef.isStable.pipe(filter(stable => stable))); + } + + override autoDetectChanges(autoDetect?: boolean|undefined): void { + throw new Error('Cannot call autoDetectChanges when using change detection scheduling.'); + } +} + +/** + * ComponentFixture behavior that attempts to act as a "mini application". + */ +export class PseudoApplicationComponentFixture extends ComponentFixture { + private _subscriptions = new Subscription(); + private _autoDetect = inject(ComponentFixtureAutoDetect, {optional: true}) ?? false; + private _isStable: boolean = true; + private _promise: Promise|null = null; + private _resolve: ((result: boolean) => void)|null = null; + + initialize(): void { // Create subscriptions outside the NgZone so that the callbacks run outside // of NgZone. this._ngZone.runOutsideAngular(() => { @@ -123,63 +259,26 @@ export class ComponentFixture { }); } - private _tick(checkNoChanges: boolean) { - this.changeDetectorRef.detectChanges(); - if (checkNoChanges) { - this.checkNoChanges(); - } - } - - /** - * Trigger a change detection cycle for the component. - */ - detectChanges(checkNoChanges: boolean = true): void { - this.effectRunner.flush(); + override detectChanges(checkNoChanges = true): void { + this._effectRunner.flush(); // Run the change detection inside the NgZone so that any async tasks as part of the change // detection are captured by the zone and can be waited for in isStable. this._ngZone.run(() => { - this._tick(checkNoChanges); + this.changeDetectorRef.detectChanges(); + if (checkNoChanges) { + this.checkNoChanges(); + } }); // Run any effects that were created/dirtied during change detection. Such effects might become // dirty in response to input signals changing. - this.effectRunner.flush(); + this._effectRunner.flush(); } - /** - * Do a change detection run to make sure there were no changes. - */ - checkNoChanges(): void { - this.changeDetectorRef.checkNoChanges(); - } - - /** - * Set whether the fixture should autodetect changes. - * - * Also runs detectChanges once so that any existing change is detected. - */ - autoDetectChanges(autoDetect: boolean = true) { - if (this.noZoneOptionIsSet) { - throw new Error('Cannot call autoDetectChanges when ComponentFixtureNoNgZone is set'); - } - this._autoDetect = autoDetect; - this.detectChanges(); - } - - /** - * Return whether the fixture is currently stable or has async tasks that have not been completed - * yet. - */ - isStable(): boolean { + override isStable(): boolean { return this._isStable && !this._ngZone.hasPendingMacrotasks; } - /** - * Get a promise that resolves when the fixture is stable. - * - * This can be used to resume testing after events have triggered asynchronous activity or - * asynchronous change detection. - */ - whenStable(): Promise { + override whenStable(): Promise { if (this.isStable()) { return Promise.resolve(false); } else if (this._promise !== null) { @@ -192,51 +291,16 @@ export class ComponentFixture { } } - /** - * Retrieves all defer block fixtures in the component fixture. - * - * @developerPreview - */ - getDeferBlocks(): Promise { - const deferBlocks: DeferBlockDetails[] = []; - const lView = (this.componentRef.hostView as any)['_lView']; - getDeferBlocks(lView, deferBlocks); - - const deferBlockFixtures = []; - for (const block of deferBlocks) { - deferBlockFixtures.push(new DeferBlockFixture(block, this)); + override autoDetectChanges(autoDetect = true): void { + if (this._noZoneOptionIsSet) { + throw new Error('Cannot call autoDetectChanges when ComponentFixtureNoNgZone is set.'); } - - return Promise.resolve(deferBlockFixtures); + this._autoDetect = autoDetect; + this.detectChanges(); } - - private _getRenderer() { - if (this._renderer === undefined) { - this._renderer = this.componentRef.injector.get(RendererFactory2, null); - } - return this._renderer as RendererFactory2 | null; - } - - /** - * Get a promise that resolves when the ui state is stable following animations. - */ - whenRenderingDone(): Promise { - const renderer = this._getRenderer(); - if (renderer && renderer.whenRenderingDone) { - return renderer.whenRenderingDone(); - } - return this.whenStable(); - } - - /** - * Trigger component destruction. - */ - destroy(): void { - if (!this._isDestroyed) { - this.componentRef.destroy(); - this._subscriptions.unsubscribe(); - this._isDestroyed = true; - } + override destroy(): void { + this._subscriptions.unsubscribe(); + super.destroy(); } } diff --git a/packages/core/testing/src/test_bed.ts b/packages/core/testing/src/test_bed.ts index 616cca628fd..723e529080b 100644 --- a/packages/core/testing/src/test_bed.ts +++ b/packages/core/testing/src/test_bed.ts @@ -13,6 +13,7 @@ /* clang-format off */ import { Component, + ComponentRef, Directive, EnvironmentInjector, InjectFlags, @@ -25,6 +26,7 @@ import { ProviderToken, runInInjectionContext, Type, + ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵconvertToBitFlags as convertToBitFlags, ɵDeferBlockBehavior as DeferBlockBehavior, ɵEffectScheduler as EffectScheduler, @@ -45,7 +47,7 @@ import { -import {ComponentFixture} from './component_fixture'; +import {ComponentFixture, PseudoApplicationComponentFixture, ScheduledComponentFixture} from './component_fixture'; import {MetadataOverride} from './metadata_override'; import {ComponentFixtureNoNgZone, DEFER_BLOCK_DEFAULT_BEHAVIOR, ModuleTeardownOptions, TEARDOWN_TESTING_MODULE_ON_DESTROY_DEFAULT, TestComponentRenderer, TestEnvironmentOptions, TestModuleMetadata, THROW_ON_UNKNOWN_ELEMENTS_DEFAULT, THROW_ON_UNKNOWN_PROPERTIES_DEFAULT} from './test_bed_common'; import {TestBedCompiler} from './test_bed_compiler'; @@ -632,8 +634,15 @@ export class TestBedImpl implements TestBed { const componentFactory = new ComponentFactory(componentDef); const initComponent = () => { const componentRef = - componentFactory.create(Injector.NULL, [], `#${rootElId}`, this.testModuleRef); - return this.runInInjectionContext(() => new ComponentFixture(componentRef)); + componentFactory.create(Injector.NULL, [], `#${rootElId}`, this.testModuleRef) as + ComponentRef; + return this.runInInjectionContext(() => { + const hasScheduler = this.inject(ChangeDetectionScheduler, null) !== null; + const fixture = hasScheduler ? new ScheduledComponentFixture(componentRef) : + new PseudoApplicationComponentFixture(componentRef); + fixture.initialize(); + return fixture; + }); }; const noNgZone = this.inject(ComponentFixtureNoNgZone, false); const ngZone = noNgZone ? null : this.inject(NgZone, null); diff --git a/packages/core/testing/src/test_bed_common.ts b/packages/core/testing/src/test_bed_common.ts index ead472e7e56..9f15ab0b5c8 100644 --- a/packages/core/testing/src/test_bed_common.ts +++ b/packages/core/testing/src/test_bed_common.ts @@ -36,6 +36,13 @@ export class TestComponentRenderer { */ export const ComponentFixtureAutoDetect = new InjectionToken('ComponentFixtureAutoDetect'); +/** + * TODO(atscott): Make public API once we have decided if we want this error and how we want devs to + * disable it. + */ +export const AllowDetectChangesAndAcknowledgeItCanHideApplicationBugs = + new InjectionToken('AllowDetectChangesAndAcknowledgeItCanHideApplicationBugs'); + /** * @publicApi */ diff --git a/packages/core/testing/src/testing.ts b/packages/core/testing/src/testing.ts index 6de700dc073..16c0be4c0ef 100644 --- a/packages/core/testing/src/testing.ts +++ b/packages/core/testing/src/testing.ts @@ -13,7 +13,7 @@ */ export * from './async'; -export * from './component_fixture'; +export {ComponentFixture} from './component_fixture'; export * from './fake_async'; export {TestBed, getTestBed, TestBedStatic, inject, InjectSetupWrapper, withModule} from './test_bed'; export {TestComponentRenderer, ComponentFixtureAutoDetect, ComponentFixtureNoNgZone, TestModuleMetadata, TestEnvironmentOptions, ModuleTeardownOptions} from './test_bed_common';