diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index fad3394..3cf053a 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,8 +1,16 @@ -import { Type, DebugElement } from '@angular/core'; -import {ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed} from '@angular/core/testing'; +import { Type, DebugElement, OutputRef, EventEmitter } from '@angular/core'; +import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; +export type OutputRefKeysWithCallback = { + [key in keyof T]?: T[key] extends EventEmitter + ? (val: U) => void + : T[key] extends OutputRef + ? (val: U) => void + : never; +}; + export type RenderResultQueries = { [P in keyof Q]: BoundFunction }; export interface RenderResult extends 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],