From 651c6a7c8c168aa3cd288376dd4ad8e5a204c8a7 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 4 Sep 2026 20:24:04 +0200 Subject: [PATCH] fix(cdk/testing): account for new DirectiveFixture API Since https://github.com/angular/angular/pull/70453, the framework has a `DirectiveFixture` in addition to the `ComponentFixture`. These changes expand the APIs in the `TestbedHarnessEnvironment` to account for directive fixtures. --- goldens/cdk/testing/testbed/index.api.md | 12 +- .../testbed/testbed-harness-environment.ts | 37 +- src/cdk/testing/tests/testbed.spec.ts | 407 +++++++++++++----- 3 files changed, 319 insertions(+), 137 deletions(-) diff --git a/goldens/cdk/testing/testbed/index.api.md b/goldens/cdk/testing/testbed/index.api.md index 76ff5c334882..2c3122fe8ac4 100644 --- a/goldens/cdk/testing/testbed/index.api.md +++ b/goldens/cdk/testing/testbed/index.api.md @@ -5,19 +5,23 @@ ```ts import { ComponentFixture } from '@angular/core/testing'; +import { DirectiveFixture } from '@angular/core/testing'; + +// @public +export type Fixture = ComponentFixture | DirectiveFixture; // @public export class TestbedHarnessEnvironment extends HarnessEnvironment { - protected constructor(rawRootElement: Element, _fixture: ComponentFixture, options?: TestbedHarnessEnvironmentOptions); + protected constructor(rawRootElement: Element, _fixture: Fixture, options?: TestbedHarnessEnvironmentOptions); protected createEnvironment(element: Element): HarnessEnvironment; protected createTestElement(element: Element): TestElement; - static documentRootLoader(fixture: ComponentFixture, options?: TestbedHarnessEnvironmentOptions): HarnessLoader; + static documentRootLoader(fixture: Fixture, options?: TestbedHarnessEnvironmentOptions): HarnessLoader; forceStabilize(): Promise; protected getAllRawElements(selector: string): Promise; protected getDocumentRoot(): Element; static getNativeElement(el: TestElement): Element; - static harnessForFixture(fixture: ComponentFixture, harnessType: ComponentHarnessConstructor, options?: TestbedHarnessEnvironmentOptions): Promise; - static loader(fixture: ComponentFixture, options?: TestbedHarnessEnvironmentOptions): HarnessLoader; + static harnessForFixture(fixture: Fixture, harnessType: ComponentHarnessConstructor, options?: TestbedHarnessEnvironmentOptions): Promise; + static loader(fixture: Fixture, options?: TestbedHarnessEnvironmentOptions): HarnessLoader; waitForTasksOutsideAngular(): Promise; } diff --git a/src/cdk/testing/testbed/testbed-harness-environment.ts b/src/cdk/testing/testbed/testbed-harness-environment.ts index eab4dfc8bf5b..e42a05ce3aa9 100644 --- a/src/cdk/testing/testbed/testbed-harness-environment.ts +++ b/src/cdk/testing/testbed/testbed-harness-environment.ts @@ -15,11 +15,12 @@ import { stopHandlingAutoChangeDetectionStatus, TestElement, } from '../../testing'; -import {ComponentFixture, flush} from '@angular/core/testing'; +import {ComponentFixture, DirectiveFixture, flush, TestBed} from '@angular/core/testing'; import {Observable} from 'rxjs'; import {takeWhile} from 'rxjs/operators'; import {TaskState, TaskStateZoneInterceptor} from './task-state-zone-interceptor'; import {UnitTestElement} from './unit-test-element'; +import {DestroyRef} from '@angular/core'; /** Options to configure the environment. */ export interface TestbedHarnessEnvironmentOptions { @@ -32,19 +33,22 @@ const defaultEnvironmentOptions: TestbedHarnessEnvironmentOptions = { queryFn: (selector: string, root: Element) => root.querySelectorAll(selector), }; +/** Covers all fixtures supported by `TestBed`. */ +export type Fixture = ComponentFixture | DirectiveFixture; + /** Whether auto change detection is currently disabled. */ let disableAutoChangeDetection = false; /** * The set of non-destroyed fixtures currently being used by `TestbedHarnessEnvironment` instances. */ -const activeFixtures = new Set>(); +const activeFixtures = new Set(); /** * Installs a handler for change detection batching status changes for a specific fixture. * @param fixture The fixture to handle change detection batching for. */ -function installAutoChangeDetectionStatusHandler(fixture: ComponentFixture) { +function installAutoChangeDetectionStatusHandler(fixture: Fixture) { if (!activeFixtures.size) { handleAutoChangeDetectionStatus(({isDisabled, onDetectChangesNow}) => { disableAutoChangeDetection = isDisabled; @@ -60,7 +64,7 @@ function installAutoChangeDetectionStatusHandler(fixture: ComponentFixture) { +function uninstallAutoChangeDetectionStatusHandler(fixture: Fixture) { activeFixtures.delete(fixture); if (!activeFixtures.size) { stopHandlingAutoChangeDetectionStatus(); @@ -76,7 +80,7 @@ function isInFakeAsyncZone() { * Triggers change detection for a specific fixture. * @param fixture The fixture to trigger change detection for. */ -async function detectChanges(fixture: ComponentFixture) { +async function detectChanges(fixture: Fixture) { fixture.detectChanges(); if (isInFakeAsyncZone()) { flush(); @@ -101,7 +105,7 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment { protected constructor( rawRootElement: Element, - private _fixture: ComponentFixture, + private _fixture: Fixture, options?: TestbedHarnessEnvironmentOptions, ) { super(rawRootElement); @@ -111,17 +115,22 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment { } this._stabilizeCallback = () => this.forceStabilize(); installAutoChangeDetectionStatusHandler(_fixture); - _fixture.componentRef.onDestroy(() => { + + const onDestroy = () => { uninstallAutoChangeDetectionStatusHandler(_fixture); this._destroyed = true; - }); + }; + + if (_fixture instanceof ComponentFixture) { + _fixture.componentRef.onDestroy(onDestroy); + } else { + // TODO(crisbeto): use host ref in directive fixture once it's available. + TestBed.inject(DestroyRef).onDestroy(onDestroy); + } } /** Creates a `HarnessLoader` rooted at the given fixture's root element. */ - static loader( - fixture: ComponentFixture, - options?: TestbedHarnessEnvironmentOptions, - ): HarnessLoader { + static loader(fixture: Fixture, options?: TestbedHarnessEnvironmentOptions): HarnessLoader { return new TestbedHarnessEnvironment(fixture.nativeElement, fixture, options); } @@ -130,7 +139,7 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment { * located outside of a fixture (e.g. overlays appended to the document body). */ static documentRootLoader( - fixture: ComponentFixture, + fixture: Fixture, options?: TestbedHarnessEnvironmentOptions, ): HarnessLoader { return new TestbedHarnessEnvironment(document.body, fixture, options); @@ -151,7 +160,7 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment { * of the fixture. */ static async harnessForFixture( - fixture: ComponentFixture, + fixture: Fixture, harnessType: ComponentHarnessConstructor, options?: TestbedHarnessEnvironmentOptions, ): Promise { diff --git a/src/cdk/testing/tests/testbed.spec.ts b/src/cdk/testing/tests/testbed.spec.ts index 7eadcfb07f06..7f04954f5974 100644 --- a/src/cdk/testing/tests/testbed.spec.ts +++ b/src/cdk/testing/tests/testbed.spec.ts @@ -1,8 +1,14 @@ import {_supportsShadowDom} from '../../platform'; -import {HarnessLoader, manualChangeDetection, parallel} from '../../testing'; +import {ComponentHarness, HarnessLoader, manualChangeDetection, parallel} from '../../testing'; import {TestbedHarnessEnvironment} from '../../testing/testbed'; -import {waitForAsync, ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing'; -import {provideZoneChangeDetection} from '@angular/core'; +import { + ComponentFixture, + DirectiveFixture, + fakeAsync, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import {Directive, provideZoneChangeDetection} from '@angular/core'; import {querySelectorAll as piercingQuerySelectorAll} from 'kagekiri'; import {crossEnvironmentSpecs} from './cross-environment-tests'; import {FakeOverlayHarness} from './harnesses/fake-overlay-harness'; @@ -10,23 +16,205 @@ import {MainComponentHarness} from './harnesses/main-component-harness'; import {TestMainComponent} from './test-main-component'; describe('TestbedHarnessEnvironment', () => { - let fixture: ComponentFixture<{}>; - beforeEach(() => { TestBed.configureTestingModule({ providers: [provideZoneChangeDetection()], }); - fixture = TestBed.createComponent(TestMainComponent); }); - describe('environment specific', () => { - describe('HarnessLoader', () => { - let loader: HarnessLoader; + describe('ComponentFixture', () => { + let fixture: ComponentFixture<{}>; + + beforeEach(() => { + fixture = TestBed.createComponent(TestMainComponent); + }); + + describe('environment specific', () => { + describe('HarnessLoader', () => { + let loader: HarnessLoader; + + beforeEach(() => { + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should create HarnessLoader from fixture', () => { + expect(loader).not.toBeNull(); + }); + + it('should create ComponentHarness for fixture', async () => { + const harness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + MainComponentHarness, + ); + expect(harness).not.toBeNull(); + }); - beforeEach(() => { - loader = TestbedHarnessEnvironment.loader(fixture); + it('should be able to load harness through document root loader', async () => { + const documentRootHarnesses = + await TestbedHarnessEnvironment.documentRootLoader(fixture).getAllHarnesses( + FakeOverlayHarness, + ); + const fixtureHarnesses = await loader.getAllHarnesses(FakeOverlayHarness); + expect(fixtureHarnesses.length).toBe(0); + expect(documentRootHarnesses.length).toBe(1); + expect(await documentRootHarnesses[0].getDescription()).toBe('This is a fake overlay.'); + }); }); + describe('harness', () => { + let harness: MainComponentHarness; + + beforeEach(async () => { + harness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + MainComponentHarness, + ); + }); + + it('can get elements outside of host', async () => { + const subcomponents = await harness.allLists(); + expect(subcomponents[0]).not.toBeNull(); + const globalEl = await subcomponents[0]!.globalElement(); + expect(globalEl).not.toBeNull(); + expect(await globalEl.text()).toBe('Hello Yi from Angular!'); + }); + + it('should be able to wait for tasks outside of Angular within native async/await', async () => { + expect(await harness.getTaskStateResult()).toBe('result'); + }); + + it('should be able to wait for tasks outside of Angular within async test zone', waitForAsync(() => { + harness.getTaskStateResult().then(res => expect(res).toBe('result')); + })); + + it('should be able to wait for tasks outside of Angular within fakeAsync test zone', fakeAsync(async () => { + expect(await harness.getTaskStateResult()).toBe('result'); + })); + + it('should be able to retrieve the native DOM element from a UnitTestElement', async () => { + const element = TestbedHarnessEnvironment.getNativeElement(await harness.host()); + expect(element.id).toContain('root'); + }); + + it('should wait for async operation to complete in fakeAsync test', fakeAsync(async () => { + const asyncCounter = await harness.asyncCounter(); + expect(await asyncCounter.text()).toBe('5'); + await harness.increaseCounter(3); + expect(await asyncCounter.text()).toBe('8'); + })); + }); + + describe('change detection behavior', () => { + it('manualChangeDetection should prevent auto change detection', async () => { + const detectChangesSpy = spyOn(fixture, 'detectChanges').and.callThrough(); + const harness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + MainComponentHarness, + ); + detectChangesSpy.calls.reset(); + await manualChangeDetection(async () => { + const button = await harness.button(); + await button.text(); + await button.click(); + }); + expect(detectChangesSpy).toHaveBeenCalledTimes(0); + }); + + it('parallel should only auto detect changes once before and after', async () => { + const detectChangesSpy = spyOn(fixture, 'detectChanges').and.callThrough(); + const harness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + MainComponentHarness, + ); + + // Run them in "parallel" (though the order is guaranteed because of how we constructed the + // promises. + detectChangesSpy.calls.reset(); + expect(detectChangesSpy).toHaveBeenCalledTimes(0); + await parallel(() => { + // Chain together our promises to ensure the before clause runs first and the after clause + // runs last. + const before = Promise.resolve().then(() => + expect(detectChangesSpy).toHaveBeenCalledTimes(1), + ); + const actions = before.then(() => + Promise.all(Array.from({length: 5}, () => harness.button().then(b => b.click()))), + ); + const after = actions.then(() => expect(detectChangesSpy).toHaveBeenCalledTimes(1)); + + return [before, actions, after]; + }); + expect(detectChangesSpy).toHaveBeenCalledTimes(2); + }); + + it('parallel inside manualChangeDetection should not cause change detection', async () => { + const detectChangesSpy = spyOn(fixture, 'detectChanges').and.callThrough(); + const harness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + MainComponentHarness, + ); + detectChangesSpy.calls.reset(); + await manualChangeDetection(() => + parallel(() => Array.from({length: 5}, () => harness.button().then(b => b.click()))), + ); + expect(detectChangesSpy).toHaveBeenCalledTimes(0); + }); + }); + + if (_supportsShadowDom()) { + describe('shadow DOM interaction', () => { + it('should not pierce shadow boundary by default', async () => { + const harness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + MainComponentHarness, + ); + expect(await harness.shadows()).toEqual([]); + }); + + it('should pierce shadow boundary when using piercing query', async () => { + const harness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + MainComponentHarness, + {queryFn: piercingQuerySelectorAll}, + ); + const shadows = await harness.shadows(); + expect( + await parallel(() => { + return shadows.map(el => el.text()); + }), + ).toEqual(['Shadow 1', 'Shadow 2']); + }); + + it('should allow querying across shadow boundary', async () => { + const harness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + MainComponentHarness, + {queryFn: piercingQuerySelectorAll}, + ); + expect(await (await harness.deepShadow()).text()).toBe('Shadow 2'); + }); + }); + } + }); + + describe('environment independent', () => + crossEnvironmentSpecs( + () => TestbedHarnessEnvironment.loader(fixture), + () => TestbedHarnessEnvironment.harnessForFixture(fixture, MainComponentHarness), + () => Promise.resolve(document.activeElement!.id), + )); + }); + + describe('DirectiveFixture', () => { + let fixture: DirectiveFixture; + let loader: HarnessLoader; + + beforeEach(() => { + fixture = TestBed.createDirective(TestDirective); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + describe('HarnessLoader', () => { it('should create HarnessLoader from fixture', () => { expect(loader).not.toBeNull(); }); @@ -34,98 +222,54 @@ describe('TestbedHarnessEnvironment', () => { it('should create ComponentHarness for fixture', async () => { const harness = await TestbedHarnessEnvironment.harnessForFixture( fixture, - MainComponentHarness, + TestDirectiveHarness, ); expect(harness).not.toBeNull(); }); + }); - it('should be able to load harness through document root loader', async () => { - const documentRootHarnesses = - await TestbedHarnessEnvironment.documentRootLoader(fixture).getAllHarnesses( - FakeOverlayHarness, - ); - const fixtureHarnesses = await loader.getAllHarnesses(FakeOverlayHarness); - expect(fixtureHarnesses.length).toBe(0); - expect(documentRootHarnesses.length).toBe(1); - expect(await documentRootHarnesses[0].getDescription()).toBe('This is a fake overlay.'); - }); + it('should be able to retrieve the native DOM element from a UnitTestElement', async () => { + const harness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + TestDirectiveHarness, + ); + const element = TestbedHarnessEnvironment.getNativeElement(await harness.host()); + expect(element).toBe(fixture.nativeElement); + expect(element.tagName.toLowerCase()).toBe('button'); }); - describe('ComponentHarness', () => { - let harness: MainComponentHarness; + describe('change detection behavior', () => { + let harness: TestDirectiveHarness; beforeEach(async () => { - harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, MainComponentHarness); - }); - - it('can get elements outside of host', async () => { - const subcomponents = await harness.allLists(); - expect(subcomponents[0]).not.toBeNull(); - const globalEl = await subcomponents[0]!.globalElement(); - expect(globalEl).not.toBeNull(); - expect(await globalEl.text()).toBe('Hello Yi from Angular!'); + harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, TestDirectiveHarness); }); - it('should be able to wait for tasks outside of Angular within native async/await', async () => { - expect(await harness.getTaskStateResult()).toBe('result'); - }); - - it('should be able to wait for tasks outside of Angular within async test zone', waitForAsync(() => { - harness.getTaskStateResult().then(res => expect(res).toBe('result')); - })); - - it('should be able to wait for tasks outside of Angular within fakeAsync test zone', fakeAsync(async () => { - expect(await harness.getTaskStateResult()).toBe('result'); - })); - - it('should be able to retrieve the native DOM element from a UnitTestElement', async () => { - const element = TestbedHarnessEnvironment.getNativeElement(await harness.host()); - expect(element.id).toContain('root'); + it('should auto-detect changes when interacting with harness', async () => { + expect(await harness.isActive()).toBe(false); + await harness.click(); + expect(await harness.isActive()).toBe(true); }); - it('should wait for async operation to complete in fakeAsync test', fakeAsync(async () => { - const asyncCounter = await harness.asyncCounter(); - expect(await asyncCounter.text()).toBe('5'); - await harness.increaseCounter(3); - expect(await asyncCounter.text()).toBe('8'); - })); - }); - - describe('change detection behavior', () => { it('manualChangeDetection should prevent auto change detection', async () => { const detectChangesSpy = spyOn(fixture, 'detectChanges').and.callThrough(); - const harness = await TestbedHarnessEnvironment.harnessForFixture( - fixture, - MainComponentHarness, - ); detectChangesSpy.calls.reset(); await manualChangeDetection(async () => { - const button = await harness.button(); - await button.text(); - await button.click(); + await harness.click(); }); expect(detectChangesSpy).toHaveBeenCalledTimes(0); }); it('parallel should only auto detect changes once before and after', async () => { const detectChangesSpy = spyOn(fixture, 'detectChanges').and.callThrough(); - const harness = await TestbedHarnessEnvironment.harnessForFixture( - fixture, - MainComponentHarness, - ); - - // Run them in "parallel" (though the order is guaranteed because of how we constructed the - // promises. detectChangesSpy.calls.reset(); expect(detectChangesSpy).toHaveBeenCalledTimes(0); await parallel(() => { - // Chain together our promises to ensure the before clause runs first and the after clause - // runs last. const before = Promise.resolve().then(() => expect(detectChangesSpy).toHaveBeenCalledTimes(1), ); const actions = before.then(() => - Promise.all(Array.from({length: 5}, () => harness.button().then(b => b.click()))), + Promise.all(Array.from({length: 5}, () => harness.click())), ); const after = actions.then(() => expect(detectChangesSpy).toHaveBeenCalledTimes(1)); @@ -136,58 +280,83 @@ describe('TestbedHarnessEnvironment', () => { it('parallel inside manualChangeDetection should not cause change detection', async () => { const detectChangesSpy = spyOn(fixture, 'detectChanges').and.callThrough(); - const harness = await TestbedHarnessEnvironment.harnessForFixture( - fixture, - MainComponentHarness, - ); detectChangesSpy.calls.reset(); await manualChangeDetection(() => - parallel(() => Array.from({length: 5}, () => harness.button().then(b => b.click()))), + parallel(() => Array.from({length: 5}, () => harness.click())), ); expect(detectChangesSpy).toHaveBeenCalledTimes(0); }); }); - if (_supportsShadowDom()) { - describe('shadow DOM interaction', () => { - it('should not pierce shadow boundary by default', async () => { - const harness = await TestbedHarnessEnvironment.harnessForFixture( - fixture, - MainComponentHarness, - ); - expect(await harness.shadows()).toEqual([]); - }); + it('should work with directive created with tagName option', async () => { + const customFixture = TestBed.createDirective(TestDirectiveNoTag, {tagName: 'div'}); + const customHarness = await TestbedHarnessEnvironment.harnessForFixture( + customFixture, + TestDirectiveNoTagHarness, + ); + expect(customFixture.nativeElement.tagName.toLowerCase()).toBe('div'); + expect(await customHarness.isCustom()).toBe(true); + }); + }); +}); - it('should pierce shadow boundary when using piercing query', async () => { - const harness = await TestbedHarnessEnvironment.harnessForFixture( - fixture, - MainComponentHarness, - {queryFn: piercingQuerySelectorAll}, - ); - const shadows = await harness.shadows(); - expect( - await parallel(() => { - return shadows.map(el => el.text()); - }), - ).toEqual(['Shadow 1', 'Shadow 2']); - }); +@Directive({ + selector: 'button[test-dir]', + host: { + '[class.active]': 'active', + '(click)': 'onClick()', + }, +}) +class TestDirective { + active = false; + clickCount = 0; - it('should allow querying across shadow boundary', async () => { - const harness = await TestbedHarnessEnvironment.harnessForFixture( - fixture, - MainComponentHarness, - {queryFn: piercingQuerySelectorAll}, - ); - expect(await (await harness.deepShadow()).text()).toBe('Shadow 2'); - }); - }); - } - }); + onClick() { + this.active = !this.active; + this.clickCount++; + } +} - describe('environment independent', () => - crossEnvironmentSpecs( - () => TestbedHarnessEnvironment.loader(fixture), - () => TestbedHarnessEnvironment.harnessForFixture(fixture, MainComponentHarness), - () => Promise.resolve(document.activeElement!.id), - )); -}); +@Directive({ + selector: '[test-dir-no-tag]', + host: { + '[class.custom]': 'true', + }, +}) +class TestDirectiveNoTag {} + +class TestDirectiveHarness extends ComponentHarness { + static readonly hostSelector = 'button[test-dir]'; + + readonly child = this.locatorFor('.child'); + readonly asyncCounter = this.locatorFor('.async-counter'); + readonly asyncButton = this.locatorFor('.async-button'); + + async isActive(): Promise { + return (await this.host()).hasClass('active'); + } + + async click(): Promise { + return (await this.host()).click(); + } + + async getChildText(): Promise { + return (await this.child()).text(); + } + + async getAsyncCounterText(): Promise { + return (await this.asyncCounter()).text(); + } + + async triggerAsync(): Promise { + return (await this.asyncButton()).click(); + } +} + +class TestDirectiveNoTagHarness extends ComponentHarness { + static readonly hostSelector = '[test-dir-no-tag]'; + + async isCustom(): Promise { + return (await this.host()).hasClass('custom'); + } +}