Skip to content

Commit c112930

Browse files
author
Simon Mumenthaler
committed
new option subscribeToOutputs for render and rerender
1 parent 0652a14 commit c112930

File tree

3 files changed

+199
-54
lines changed

3 files changed

+199
-54
lines changed

projects/testing-library/src/lib/models.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import { Type, DebugElement } from '@angular/core';
2-
import {ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed} from '@angular/core/testing';
1+
import { Type, DebugElement, OutputRef } from '@angular/core';
2+
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
33
import { Routes } from '@angular/router';
44
import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom';
55

6+
export type SubscribeToOutputsKeysWithCallback<T> = {
7+
[key in keyof T as T[key] extends OutputRef<any> ? key : never]?: T[key] extends OutputRef<infer U>
8+
? (val: U) => void
9+
: never;
10+
};
11+
612
export type RenderResultQueries<Q extends Queries = typeof queries> = { [P in keyof Q]: BoundFunction<Q[P]> };
713
export interface RenderResult<ComponentType, WrapperType = ComponentType> extends RenderResultQueries {
814
/**
@@ -60,7 +66,7 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
6066
rerender: (
6167
properties?: Pick<
6268
RenderTemplateOptions<ComponentType>,
63-
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender'
69+
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'subscribeToOutputs' | 'detectChangesOnRender'
6470
> & { partialUpdate?: boolean },
6571
) => Promise<void>;
6672
/**
@@ -205,12 +211,12 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
205211
/**
206212
* @description
207213
* An object to set `@Output` properties of the component
208-
*
214+
* @deprecated use the `subscribeToOutputs` option instead. When actually wanting to override properties, use the `componentProperties` option.
209215
* @default
210216
* {}
211217
*
212218
* @example
213-
* const sendValue = (value) => { ... }
219+
* const sendValue = new EventEmitter<any>();
214220
* await render(AppComponent, {
215221
* componentOutputs: {
216222
* send: {
@@ -220,6 +226,24 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
220226
* })
221227
*/
222228
componentOutputs?: Partial<ComponentType>;
229+
230+
/**
231+
* @description
232+
* An object to subscribe to EventEmitters/Observables of the component
233+
*
234+
* @default
235+
* {}
236+
*
237+
* @example
238+
* const sendValue = (value) => { ... }
239+
* await render(AppComponent, {
240+
* subscribeToOutputs: {
241+
* send: (_v:any) => void
242+
* }
243+
* })
244+
*/
245+
subscribeToOutputs?: SubscribeToOutputsKeysWithCallback<ComponentType>;
246+
223247
/**
224248
* @description
225249
* A collection of providers to inject dependencies of the component.
@@ -379,7 +403,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
379403
* @description
380404
* Set the defer blocks behavior.
381405
*/
382-
deferBlockBehavior?: DeferBlockBehavior
406+
deferBlockBehavior?: DeferBlockBehavior;
383407
}
384408

385409
export interface ComponentOverride<T> {

projects/testing-library/src/lib/testing-library.ts

Lines changed: 99 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
isStandalone,
66
NgZone,
77
OnChanges,
8+
OutputRef,
9+
OutputRefSubscription,
810
SimpleChange,
911
SimpleChanges,
1012
Type,
@@ -25,9 +27,17 @@ import {
2527
waitForOptions as dtlWaitForOptions,
2628
within as dtlWithin,
2729
} from '@testing-library/dom';
28-
import { ComponentOverride, RenderComponentOptions, RenderResult, RenderTemplateOptions } from './models';
30+
import {
31+
ComponentOverride,
32+
RenderComponentOptions,
33+
RenderResult,
34+
RenderTemplateOptions,
35+
SubscribeToOutputsKeysWithCallback,
36+
} from './models';
2937
import { getConfig } from './config';
3038

39+
type SubscribedOutput<T> = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription];
40+
3141
const mountedFixtures = new Set<ComponentFixture<any>>();
3242
const safeInject = TestBed.inject || TestBed.get;
3343

@@ -57,6 +67,7 @@ export async function render<SutType, WrapperType = SutType>(
5767
componentProperties = {},
5868
componentInputs = {},
5969
componentOutputs = {},
70+
subscribeToOutputs = {},
6071
componentProviders = [],
6172
childComponentOverrides = [],
6273
componentImports: componentImports,
@@ -165,7 +176,55 @@ export async function render<SutType, WrapperType = SutType>(
165176

166177
let detectChanges: () => void;
167178

168-
const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs);
179+
let renderedPropKeys = Object.keys(componentProperties);
180+
let renderedInputKeys = Object.keys(componentInputs);
181+
let renderedOutputKeys = Object.keys(componentOutputs);
182+
let subscribedOutputs: SubscribedOutput<SutType>[] = [];
183+
184+
const renderFixture = async (
185+
properties: Partial<SutType>,
186+
inputs: Partial<SutType>,
187+
outputs: Partial<SutType>,
188+
subscribeTo: SubscribeToOutputsKeysWithCallback<SutType>,
189+
): Promise<ComponentFixture<SutType>> => {
190+
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer);
191+
setComponentProperties(createdFixture, properties);
192+
setComponentInputs(createdFixture, inputs);
193+
setComponentOutputs(createdFixture, outputs);
194+
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
195+
196+
if (removeAngularAttributes) {
197+
createdFixture.nativeElement.removeAttribute('ng-version');
198+
const idAttribute = createdFixture.nativeElement.getAttribute('id');
199+
if (idAttribute && idAttribute.startsWith('root')) {
200+
createdFixture.nativeElement.removeAttribute('id');
201+
}
202+
}
203+
204+
mountedFixtures.add(createdFixture);
205+
206+
let isAlive = true;
207+
createdFixture.componentRef.onDestroy(() => (isAlive = false));
208+
209+
if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) {
210+
const changes = getChangesObj(null, componentProperties);
211+
createdFixture.componentInstance.ngOnChanges(changes);
212+
}
213+
214+
detectChanges = () => {
215+
if (isAlive) {
216+
createdFixture.detectChanges();
217+
}
218+
};
219+
220+
if (detectChangesOnRender) {
221+
detectChanges();
222+
}
223+
224+
return createdFixture;
225+
};
226+
227+
const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, subscribeToOutputs);
169228

170229
if (deferBlockStates) {
171230
if (Array.isArray(deferBlockStates)) {
@@ -177,13 +236,10 @@ export async function render<SutType, WrapperType = SutType>(
177236
}
178237
}
179238

180-
let renderedPropKeys = Object.keys(componentProperties);
181-
let renderedInputKeys = Object.keys(componentInputs);
182-
let renderedOutputKeys = Object.keys(componentOutputs);
183239
const rerender = async (
184240
properties?: Pick<
185241
RenderTemplateOptions<SutType>,
186-
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender'
242+
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'subscribeToOutputs' | 'detectChangesOnRender'
187243
> & { partialUpdate?: boolean },
188244
) => {
189245
const newComponentInputs = properties?.componentInputs ?? {};
@@ -205,6 +261,22 @@ export async function render<SutType, WrapperType = SutType>(
205261
setComponentOutputs(fixture, newComponentOutputs);
206262
renderedOutputKeys = Object.keys(newComponentOutputs);
207263

264+
// first unsubscribe the no longer available or changed callback-fns
265+
const newSubscribeToOutputs: SubscribeToOutputsKeysWithCallback<SutType> = properties?.subscribeToOutputs ?? {};
266+
for (const [key, cb, subscription] of subscribedOutputs) {
267+
// when no longer provided or when the callback has changed
268+
if (!(key in newSubscribeToOutputs) || cb !== (newSubscribeToOutputs as any)[key]) {
269+
subscription.unsubscribe();
270+
}
271+
}
272+
// then subscribe the new callback-fns
273+
subscribedOutputs = Object.entries(newSubscribeToOutputs).map(([key, cb]) => {
274+
const existing = subscribedOutputs.find(([k]) => k === key);
275+
return existing && existing[1] === cb
276+
? existing // nothing to do
277+
: subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void);
278+
});
279+
208280
const newComponentProps = properties?.componentProperties ?? {};
209281
const changesInComponentProps = update(
210282
fixture,
@@ -249,47 +321,6 @@ export async function render<SutType, WrapperType = SutType>(
249321
: console.log(dtlPrettyDOM(element, maxLength, options)),
250322
...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)),
251323
};
252-
253-
async function renderFixture(
254-
properties: Partial<SutType>,
255-
inputs: Partial<SutType>,
256-
outputs: Partial<SutType>,
257-
): Promise<ComponentFixture<SutType>> {
258-
const createdFixture = await createComponent(componentContainer);
259-
setComponentProperties(createdFixture, properties);
260-
setComponentInputs(createdFixture, inputs);
261-
setComponentOutputs(createdFixture, outputs);
262-
263-
if (removeAngularAttributes) {
264-
createdFixture.nativeElement.removeAttribute('ng-version');
265-
const idAttribute = createdFixture.nativeElement.getAttribute('id');
266-
if (idAttribute && idAttribute.startsWith('root')) {
267-
createdFixture.nativeElement.removeAttribute('id');
268-
}
269-
}
270-
271-
mountedFixtures.add(createdFixture);
272-
273-
let isAlive = true;
274-
createdFixture.componentRef.onDestroy(() => (isAlive = false));
275-
276-
if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) {
277-
const changes = getChangesObj(null, componentProperties);
278-
createdFixture.componentInstance.ngOnChanges(changes);
279-
}
280-
281-
detectChanges = () => {
282-
if (isAlive) {
283-
createdFixture.detectChanges();
284-
}
285-
};
286-
287-
if (detectChangesOnRender) {
288-
detectChanges();
289-
}
290-
291-
return createdFixture;
292-
}
293324
}
294325

295326
async function createComponent<SutType>(component: Type<SutType>): Promise<ComponentFixture<SutType>> {
@@ -355,6 +386,27 @@ function setComponentInputs<SutType>(
355386
}
356387
}
357388

389+
function subscribeToComponentOutputs<SutType>(
390+
fixture: ComponentFixture<SutType>,
391+
listeners: SubscribeToOutputsKeysWithCallback<SutType>,
392+
): SubscribedOutput<SutType>[] {
393+
// with Object.entries we lose the type information of the key and callback, therefore we need to cast them
394+
return Object.entries(listeners).map(([key, cb]) =>
395+
subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void),
396+
);
397+
}
398+
399+
function subscribeToComponentOutput<SutType>(
400+
fixture: ComponentFixture<SutType>,
401+
key: keyof SutType,
402+
cb: (val: any) => void,
403+
): SubscribedOutput<SutType> {
404+
const eventEmitter = (fixture.componentInstance as any)[key] as OutputRef<any>;
405+
const subscription = eventEmitter.subscribe(cb);
406+
fixture.componentRef.onDestroy(subscription.unsubscribe.bind(subscription));
407+
return [key, cb, subscription];
408+
}
409+
358410
function overrideComponentImports<SutType>(sut: Type<SutType> | string, imports: (Type<any> | any[])[] | undefined) {
359411
if (imports) {
360412
if (typeof sut === 'function' && isStandalone(sut)) {

projects/testing-library/tests/render.spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import {
1010
Injectable,
1111
EventEmitter,
1212
Output,
13+
ElementRef,
14+
inject,
1315
} from '@angular/core';
1416
import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations';
1517
import { TestBed } from '@angular/core/testing';
1618
import { render, fireEvent, screen } from '../src/public_api';
1719
import { ActivatedRoute, Resolve, RouterModule } from '@angular/router';
18-
import { map } from 'rxjs';
20+
import { fromEvent, map } from 'rxjs';
1921
import { AsyncPipe, NgIf } from '@angular/common';
2022

2123
@Component({
@@ -183,6 +185,73 @@ describe('componentOutputs', () => {
183185
});
184186
});
185187

188+
describe('subscribeToOutputs', () => {
189+
@Component({ template: ``, standalone: true })
190+
class TestFixtureWithEventEmitterComponent {
191+
@Output() readonly event = new EventEmitter<void>();
192+
}
193+
194+
@Component({ template: ``, standalone: true })
195+
class TestFixtureWithDerivedEventComponent {
196+
@Output() readonly event = fromEvent<MouseEvent>(inject(ElementRef).nativeElement, 'click');
197+
}
198+
199+
it('should subscribe passed listener to the component EventEmitter', async () => {
200+
const spy = jest.fn();
201+
const { fixture } = await render(TestFixtureWithEventEmitterComponent, { subscribeToOutputs: { event: spy } });
202+
fixture.componentInstance.event.emit();
203+
expect(spy).toHaveBeenCalled();
204+
});
205+
206+
it('should unsubscribe on rerender without listener', async () => {
207+
const spy = jest.fn();
208+
const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, {
209+
subscribeToOutputs: { event: spy },
210+
});
211+
212+
await rerender({});
213+
214+
fixture.componentInstance.event.emit();
215+
expect(spy).not.toHaveBeenCalled();
216+
});
217+
218+
it('should not unsubscribe when same listener function is used on rerender', async () => {
219+
const spy = jest.fn();
220+
const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, {
221+
subscribeToOutputs: { event: spy },
222+
});
223+
224+
await rerender({ subscribeToOutputs: { event: spy } });
225+
226+
fixture.componentInstance.event.emit();
227+
expect(spy).toHaveBeenCalled();
228+
});
229+
230+
it('should unsubscribe old and subscribe new listener function on rerender', async () => {
231+
const firstSpy = jest.fn();
232+
const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, {
233+
subscribeToOutputs: { event: firstSpy },
234+
});
235+
236+
const newSpy = jest.fn();
237+
await rerender({ subscribeToOutputs: { event: newSpy } });
238+
239+
fixture.componentInstance.event.emit();
240+
241+
expect(firstSpy).not.toHaveBeenCalled();
242+
expect(newSpy).toHaveBeenCalled();
243+
});
244+
245+
it('should subscribe passed listener to derived component outputs', async () => {
246+
const spy = jest.fn();
247+
const { fixture } = await render(TestFixtureWithDerivedEventComponent, {
248+
subscribeToOutputs: { event: spy },
249+
});
250+
fireEvent.click(fixture.nativeElement);
251+
expect(spy).toHaveBeenCalled();
252+
});
253+
});
254+
186255
describe('animationModule', () => {
187256
@NgModule({
188257
declarations: [FixtureComponent],

0 commit comments

Comments
 (0)