diff --git a/.all-contributorsrc b/.all-contributorsrc index 54c827e..c1c0709 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -370,6 +370,16 @@ "code", "test" ] + }, + { + "login": "mumenthalers", + "name": "S. Mumenthaler", + "avatar_url": "https://avatars.githubusercontent.com/u/3604424?v=4", + "profile": "https://github.com/mumenthalers", + "contributions": [ + "code", + "test" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index ce2e906..883c9dc 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ Thanks goes to these people ([emoji key][emojis]):
= { [P in keyof Q]: BoundFunction}; export interface RenderResultextends RenderResultQueries { /** @@ -60,7 +68,7 @@ export interface RenderResult extend rerender: ( properties?: Pick< RenderTemplateOptions , - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => Promise ; /** @@ -205,12 +213,12 @@ export interface RenderComponentOptions { ... } + * const sendValue = new EventEmitter (); * await render(AppComponent, { * componentOutputs: { * send: { @@ -220,6 +228,24 @@ export interface RenderComponentOptions ; + + /** + * @description + * An object with callbacks to subscribe to EventEmitters/Observables of the component + * + * @default + * {} + * + * @example + * const sendValue = (value) => { ... } + * await render(AppComponent, { + * on: { + * send: (_v:any) => void + * } + * }) + */ + on?: OutputRefKeysWithCallback ; + /** * @description * A collection of providers to inject dependencies of the component. @@ -379,7 +405,7 @@ export interface RenderComponentOptions { diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 9b57b50..0ceda24 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -5,6 +5,8 @@ import { isStandalone, NgZone, OnChanges, + OutputRef, + OutputRefSubscription, SimpleChange, SimpleChanges, Type, @@ -25,9 +27,17 @@ import { waitForOptions as dtlWaitForOptions, within as dtlWithin, } from '@testing-library/dom'; -import { ComponentOverride, RenderComponentOptions, RenderResult, RenderTemplateOptions } from './models'; +import { + ComponentOverride, + RenderComponentOptions, + RenderResult, + RenderTemplateOptions, + OutputRefKeysWithCallback, +} from './models'; import { getConfig } from './config'; +type SubscribedOutput = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription]; + const mountedFixtures = new Set >(); const safeInject = TestBed.inject || TestBed.get; @@ -57,6 +67,7 @@ export async function render ( componentProperties = {}, componentInputs = {}, componentOutputs = {}, + on = {}, componentProviders = [], childComponentOverrides = [], componentImports: componentImports, @@ -165,7 +176,55 @@ export async function render ( let detectChanges: () => void; - const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs); + let renderedPropKeys = Object.keys(componentProperties); + let renderedInputKeys = Object.keys(componentInputs); + let renderedOutputKeys = Object.keys(componentOutputs); + let subscribedOutputs: SubscribedOutput [] = []; + + const renderFixture = async ( + properties: Partial , + inputs: Partial , + outputs: Partial , + subscribeTo: OutputRefKeysWithCallback , + ): Promise > => { + const createdFixture: ComponentFixture = await createComponent(componentContainer); + setComponentProperties(createdFixture, properties); + setComponentInputs(createdFixture, inputs); + setComponentOutputs(createdFixture, outputs); + subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo); + + if (removeAngularAttributes) { + createdFixture.nativeElement.removeAttribute('ng-version'); + const idAttribute = createdFixture.nativeElement.getAttribute('id'); + if (idAttribute && idAttribute.startsWith('root')) { + createdFixture.nativeElement.removeAttribute('id'); + } + } + + mountedFixtures.add(createdFixture); + + let isAlive = true; + createdFixture.componentRef.onDestroy(() => (isAlive = false)); + + if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) { + const changes = getChangesObj(null, componentProperties); + createdFixture.componentInstance.ngOnChanges(changes); + } + + detectChanges = () => { + if (isAlive) { + createdFixture.detectChanges(); + } + }; + + if (detectChangesOnRender) { + detectChanges(); + } + + return createdFixture; + }; + + const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, on); if (deferBlockStates) { if (Array.isArray(deferBlockStates)) { @@ -177,13 +236,10 @@ export async function render ( } } - let renderedPropKeys = Object.keys(componentProperties); - let renderedInputKeys = Object.keys(componentInputs); - let renderedOutputKeys = Object.keys(componentOutputs); const rerender = async ( properties?: Pick< RenderTemplateOptions , - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => { const newComponentInputs = properties?.componentInputs ?? {}; @@ -205,6 +261,22 @@ export async function render ( setComponentOutputs(fixture, newComponentOutputs); renderedOutputKeys = Object.keys(newComponentOutputs); + // first unsubscribe the no longer available or changed callback-fns + const newObservableSubscriptions: OutputRefKeysWithCallback = properties?.on ?? {}; + for (const [key, cb, subscription] of subscribedOutputs) { + // when no longer provided or when the callback has changed + if (!(key in newObservableSubscriptions) || cb !== (newObservableSubscriptions as any)[key]) { + subscription.unsubscribe(); + } + } + // then subscribe the new callback-fns + subscribedOutputs = Object.entries(newObservableSubscriptions).map(([key, cb]) => { + const existing = subscribedOutputs.find(([k]) => k === key); + return existing && existing[1] === cb + ? existing // nothing to do + : subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void); + }); + const newComponentProps = properties?.componentProperties ?? {}; const changesInComponentProps = update( fixture, @@ -249,47 +321,6 @@ export async function render ( : console.log(dtlPrettyDOM(element, maxLength, options)), ...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)), }; - - async function renderFixture( - properties: Partial , - inputs: Partial , - outputs: Partial , - ): Promise > { - const createdFixture = await createComponent(componentContainer); - setComponentProperties(createdFixture, properties); - setComponentInputs(createdFixture, inputs); - setComponentOutputs(createdFixture, outputs); - - if (removeAngularAttributes) { - createdFixture.nativeElement.removeAttribute('ng-version'); - const idAttribute = createdFixture.nativeElement.getAttribute('id'); - if (idAttribute && idAttribute.startsWith('root')) { - createdFixture.nativeElement.removeAttribute('id'); - } - } - - mountedFixtures.add(createdFixture); - - let isAlive = true; - createdFixture.componentRef.onDestroy(() => (isAlive = false)); - - if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) { - const changes = getChangesObj(null, componentProperties); - createdFixture.componentInstance.ngOnChanges(changes); - } - - detectChanges = () => { - if (isAlive) { - createdFixture.detectChanges(); - } - }; - - if (detectChangesOnRender) { - detectChanges(); - } - - return createdFixture; - } } async function createComponent (component: Type ): Promise > { @@ -355,6 +386,27 @@ function setComponentInputs ( } } +function subscribeToComponentOutputs ( + fixture: ComponentFixture , + listeners: OutputRefKeysWithCallback , +): SubscribedOutput [] { + // with Object.entries we lose the type information of the key and callback, therefore we need to cast them + return Object.entries(listeners).map(([key, cb]) => + subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void), + ); +} + +function subscribeToComponentOutput ( + fixture: ComponentFixture , + key: keyof SutType, + cb: (val: any) => void, +): SubscribedOutput { + const eventEmitter = (fixture.componentInstance as any)[key] as OutputRef ; + const subscription = eventEmitter.subscribe(cb); + fixture.componentRef.onDestroy(subscription.unsubscribe.bind(subscription)); + return [key, cb, subscription]; +} + function overrideComponentImports (sut: Type | string, imports: (Type | any[])[] | undefined) { if (imports) { if (typeof sut === 'function' && isStandalone(sut)) { diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index 56f4608..b73c9c7 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -10,12 +10,16 @@ import { Injectable, EventEmitter, Output, + ElementRef, + inject, + output, } from '@angular/core'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TestBed } from '@angular/core/testing'; -import { render, fireEvent, screen } from '../src/public_api'; +import { render, fireEvent, screen, OutputRefKeysWithCallback } from '../src/public_api'; import { ActivatedRoute, Resolve, RouterModule } from '@angular/router'; -import { map } from 'rxjs'; +import { fromEvent, map } from 'rxjs'; import { AsyncPipe, NgIf } from '@angular/common'; @Component({ @@ -183,6 +187,130 @@ describe('componentOutputs', () => { }); }); +describe('on', () => { + @Component({ template: ``, standalone: true }) + class TestFixtureWithEventEmitterComponent { + @Output() readonly event = new EventEmitter (); + } + + @Component({ template: ``, standalone: true }) + class TestFixtureWithDerivedEventComponent { + @Output() readonly event = fromEvent (inject(ElementRef).nativeElement, 'click'); + } + + @Component({ template: ``, standalone: true }) + class TestFixtureWithFunctionalOutputComponent { + readonly event = output (); + } + + @Component({ template: ``, standalone: true }) + class TestFixtureWithFunctionalDerivedEventComponent { + readonly event = outputFromObservable(fromEvent (inject(ElementRef).nativeElement, 'click')); + } + + it('should subscribe passed listener to the component EventEmitter', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithEventEmitterComponent, { on: { event: spy } }); + fixture.componentInstance.event.emit(); + expect(spy).toHaveBeenCalled(); + }); + + it('should unsubscribe on rerender without listener', async () => { + const spy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + on: { event: spy }, + }); + + await rerender({}); + + fixture.componentInstance.event.emit(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not unsubscribe when same listener function is used on rerender', async () => { + const spy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + on: { event: spy }, + }); + + await rerender({ on: { event: spy } }); + + fixture.componentInstance.event.emit(); + expect(spy).toHaveBeenCalled(); + }); + + it('should unsubscribe old and subscribe new listener function on rerender', async () => { + const firstSpy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + on: { event: firstSpy }, + }); + + const newSpy = jest.fn(); + await rerender({ on: { event: newSpy } }); + + fixture.componentInstance.event.emit(); + + expect(firstSpy).not.toHaveBeenCalled(); + expect(newSpy).toHaveBeenCalled(); + }); + + it('should subscribe passed listener to a derived component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithDerivedEventComponent, { + on: { event: spy }, + }); + fireEvent.click(fixture.nativeElement); + expect(spy).toHaveBeenCalled(); + }); + + it('should subscribe passed listener to a functional component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithFunctionalOutputComponent, { + on: { event: spy }, + }); + fixture.componentInstance.event.emit('test'); + expect(spy).toHaveBeenCalledWith('test'); + }); + + it('should subscribe passed listener to a functional derived component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithFunctionalDerivedEventComponent, { + on: { event: spy }, + }); + fireEvent.click(fixture.nativeElement); + expect(spy).toHaveBeenCalled(); + }); + + it('OutputRefKeysWithCallback is correctly typed', () => { + const fnWithVoidArg = (_: void) => void 0; + const fnWithNumberArg = (_: number) => void 0; + const fnWithStringArg = (_: string) => void 0; + const fnWithMouseEventArg = (_: MouseEvent) => void 0; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + function _test (_on: OutputRefKeysWithCallback ) {} + + // @ts-expect-error + _test ({ event: fnWithNumberArg }); + _test ({ event: fnWithVoidArg }); + + // @ts-expect-error + _test ({ event: fnWithNumberArg }); + _test ({ event: fnWithMouseEventArg }); + + // @ts-expect-error + _test ({ event: fnWithNumberArg }); + _test ({ event: fnWithStringArg }); + + // @ts-expect-error + _test ({ event: fnWithNumberArg }); + _test ({ event: fnWithMouseEventArg }); + + // add a statement so the test succeeds + expect(true).toBeTruthy(); + }); +}); + describe('animationModule', () => { @NgModule({ declarations: [FixtureComponent],