Skip to content

new option on for render and rerender #465

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { Type, DebugElement } from '@angular/core';
import {ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed} from '@angular/core/testing';
import { Type, DebugElement, OutputRef } 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 SubscribeToOutputsKeysWithCallback<T> = {
[key in keyof T as T[key] extends OutputRef<any> ? key : never]?: T[key] extends OutputRef<infer U>
? (val: U) => void
: never;
};

export type RenderResultQueries<Q extends Queries = typeof queries> = { [P in keyof Q]: BoundFunction<Q[P]> };
export interface RenderResult<ComponentType, WrapperType = ComponentType> extends RenderResultQueries {
/**
Expand Down Expand Up @@ -60,7 +66,7 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
rerender: (
properties?: Pick<
RenderTemplateOptions<ComponentType>,
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender'
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'subscribeToOutputs' | 'detectChangesOnRender'
> & { partialUpdate?: boolean },
) => Promise<void>;
/**
Expand Down Expand Up @@ -205,12 +211,12 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
/**
* @description
* An object to set `@Output` properties of the component
*
* @deprecated use the `subscribeToOutputs` option instead. When actually wanting to override properties, use the `componentProperties` option.
* @default
* {}
*
* @example
* const sendValue = (value) => { ... }
* const sendValue = new EventEmitter<any>();
* await render(AppComponent, {
* componentOutputs: {
* send: {
Expand All @@ -220,6 +226,24 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
* })
*/
componentOutputs?: Partial<ComponentType>;

/**
* @description
* An object to subscribe to EventEmitters/Observables of the component
*
* @default
* {}
*
* @example
* const sendValue = (value) => { ... }
* await render(AppComponent, {
* subscribeToOutputs: {
* send: (_v:any) => void
* }
* })
*/
subscribeToOutputs?: SubscribeToOutputsKeysWithCallback<ComponentType>;

/**
* @description
* A collection of providers to inject dependencies of the component.
Expand Down Expand Up @@ -379,7 +403,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
* @description
* Set the defer blocks behavior.
*/
deferBlockBehavior?: DeferBlockBehavior
deferBlockBehavior?: DeferBlockBehavior;
}

export interface ComponentOverride<T> {
Expand Down
146 changes: 99 additions & 47 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
isStandalone,
NgZone,
OnChanges,
OutputRef,
OutputRefSubscription,
SimpleChange,
SimpleChanges,
Type,
Expand All @@ -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,
SubscribeToOutputsKeysWithCallback,
} from './models';
import { getConfig } from './config';

type SubscribedOutput<T> = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription];

const mountedFixtures = new Set<ComponentFixture<any>>();
const safeInject = TestBed.inject || TestBed.get;

Expand Down Expand Up @@ -57,6 +67,7 @@ export async function render<SutType, WrapperType = SutType>(
componentProperties = {},
componentInputs = {},
componentOutputs = {},
subscribeToOutputs = {},
componentProviders = [],
childComponentOverrides = [],
componentImports: componentImports,
Expand Down Expand Up @@ -165,7 +176,55 @@ export async function render<SutType, WrapperType = SutType>(

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<SutType>[] = [];

const renderFixture = async (
properties: Partial<SutType>,
inputs: Partial<SutType>,
outputs: Partial<SutType>,
subscribeTo: SubscribeToOutputsKeysWithCallback<SutType>,
): Promise<ComponentFixture<SutType>> => {
const createdFixture: ComponentFixture<SutType> = 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, subscribeToOutputs);

if (deferBlockStates) {
if (Array.isArray(deferBlockStates)) {
Expand All @@ -177,13 +236,10 @@ export async function render<SutType, WrapperType = SutType>(
}
}

let renderedPropKeys = Object.keys(componentProperties);
let renderedInputKeys = Object.keys(componentInputs);
let renderedOutputKeys = Object.keys(componentOutputs);
const rerender = async (
properties?: Pick<
RenderTemplateOptions<SutType>,
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender'
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'subscribeToOutputs' | 'detectChangesOnRender'
> & { partialUpdate?: boolean },
) => {
const newComponentInputs = properties?.componentInputs ?? {};
Expand All @@ -205,6 +261,22 @@ export async function render<SutType, WrapperType = SutType>(
setComponentOutputs(fixture, newComponentOutputs);
renderedOutputKeys = Object.keys(newComponentOutputs);

// first unsubscribe the no longer available or changed callback-fns
const newSubscribeToOutputs: SubscribeToOutputsKeysWithCallback<SutType> = properties?.subscribeToOutputs ?? {};
for (const [key, cb, subscription] of subscribedOutputs) {
// when no longer provided or when the callback has changed
if (!(key in newSubscribeToOutputs) || cb !== (newSubscribeToOutputs as any)[key]) {
subscription.unsubscribe();
}
}
// then subscribe the new callback-fns
subscribedOutputs = Object.entries(newSubscribeToOutputs).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,
Expand Down Expand Up @@ -249,47 +321,6 @@ export async function render<SutType, WrapperType = SutType>(
: console.log(dtlPrettyDOM(element, maxLength, options)),
...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)),
};

async function renderFixture(
properties: Partial<SutType>,
inputs: Partial<SutType>,
outputs: Partial<SutType>,
): Promise<ComponentFixture<SutType>> {
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<SutType>(component: Type<SutType>): Promise<ComponentFixture<SutType>> {
Expand Down Expand Up @@ -355,6 +386,27 @@ function setComponentInputs<SutType>(
}
}

function subscribeToComponentOutputs<SutType>(
fixture: ComponentFixture<SutType>,
listeners: SubscribeToOutputsKeysWithCallback<SutType>,
): SubscribedOutput<SutType>[] {
// 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<SutType>(
fixture: ComponentFixture<SutType>,
key: keyof SutType,
cb: (val: any) => void,
): SubscribedOutput<SutType> {
const eventEmitter = (fixture.componentInstance as any)[key] as OutputRef<any>;
const subscription = eventEmitter.subscribe(cb);
fixture.componentRef.onDestroy(subscription.unsubscribe.bind(subscription));
return [key, cb, subscription];
}

function overrideComponentImports<SutType>(sut: Type<SutType> | string, imports: (Type<any> | any[])[] | undefined) {
if (imports) {
if (typeof sut === 'function' && isStandalone(sut)) {
Expand Down
71 changes: 70 additions & 1 deletion projects/testing-library/tests/render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
Injectable,
EventEmitter,
Output,
ElementRef,
inject,
} from '@angular/core';
import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TestBed } from '@angular/core/testing';
import { render, fireEvent, screen } 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({
Expand Down Expand Up @@ -183,6 +185,73 @@ describe('componentOutputs', () => {
});
});

describe('subscribeToOutputs', () => {
@Component({ template: ``, standalone: true })
class TestFixtureWithEventEmitterComponent {
@Output() readonly event = new EventEmitter<void>();
}

@Component({ template: ``, standalone: true })
class TestFixtureWithDerivedEventComponent {
@Output() readonly event = fromEvent<MouseEvent>(inject(ElementRef).nativeElement, 'click');
}

it('should subscribe passed listener to the component EventEmitter', async () => {
const spy = jest.fn();
const { fixture } = await render(TestFixtureWithEventEmitterComponent, { subscribeToOutputs: { 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, {
subscribeToOutputs: { 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, {
subscribeToOutputs: { event: spy },
});

await rerender({ subscribeToOutputs: { 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, {
subscribeToOutputs: { event: firstSpy },
});

const newSpy = jest.fn();
await rerender({ subscribeToOutputs: { event: newSpy } });

fixture.componentInstance.event.emit();

expect(firstSpy).not.toHaveBeenCalled();
expect(newSpy).toHaveBeenCalled();
});

it('should subscribe passed listener to derived component outputs', async () => {
const spy = jest.fn();
const { fixture } = await render(TestFixtureWithDerivedEventComponent, {
subscribeToOutputs: { event: spy },
});
fireEvent.click(fixture.nativeElement);
expect(spy).toHaveBeenCalled();
});
});

describe('animationModule', () => {
@NgModule({
declarations: [FixtureComponent],
Expand Down