Skip to content

Commit ce3a612

Browse files
feat: add more fine-grained control over inputs and outputs (#328)
BREAKING CHANGE: `rerender` expects properties to be wrapped in an object containing `componentProperties` (or `componentInputs` and `componentOutputs` to have a more fine-grained control). BEFORE: ```ts await render(PersonComponent, { componentProperties: { name: 'Sarah' } }); await rerender({ name: 'Sarah 2' }); ``` AFTER: ```ts await render(PersonComponent, { componentProperties: { name: 'Sarah' } }); await rerender({ componentProperties: { name: 'Sarah 2' } }); ```
1 parent 3fe94da commit ce3a612

File tree

6 files changed

+210
-17
lines changed

6 files changed

+210
-17
lines changed

Diff for: apps/example-app-karma/src/app/issues/issue-222.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ it('https://github.com/testing-library/angular-testing-library/issues/222 with r
99

1010
expect(screen.getByText('Hello Sarah')).toBeTruthy();
1111

12-
await rerender({ name: 'Mark' });
12+
await rerender({ componentProperties: { name: 'Mark' } });
1313

1414
expect(screen.getByText('Hello Mark')).toBeTruthy();
1515
});

Diff for: apps/example-app/src/app/examples/16-input-getter-setter.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ test('should run logic in the input setter and getter while re-rendering', async
3030
expect(screen.getByTestId('value')).toHaveTextContent('I am value from setter Angular');
3131
expect(screen.getByTestId('value-getter')).toHaveTextContent('I am value from getter Angular');
3232

33-
await rerender({ value: 'React' });
33+
await rerender({ componentProperties: { value: 'React' } });
3434

3535
// note we have to re-query because the elements are not the same anymore
3636
expect(screen.getByTestId('value')).toHaveTextContent('I am value from setter React');

Diff for: projects/testing-library/src/lib/models.ts

+42-3
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,22 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
5757
* Re-render the same component with different properties.
5858
* This creates a new instance of the component.
5959
*/
60-
rerender: (rerenderedProperties: Partial<ComponentType>) => Promise<void>;
61-
60+
rerender: (
61+
properties?: Pick<
62+
RenderTemplateOptions<ComponentType>,
63+
'componentProperties' | 'componentInputs' | 'componentOutputs'
64+
>,
65+
) => Promise<void>;
6266
/**
6367
* @description
6468
* Keeps the current fixture intact and invokes ngOnChanges with the updated properties.
6569
*/
6670
change: (changedProperties: Partial<ComponentType>) => void;
71+
/**
72+
* @description
73+
* Keeps the current fixture intact, update the @Input properties and invoke ngOnChanges with the updated properties.
74+
*/
75+
changeInput: (changedInputProperties: Partial<ComponentType>) => void;
6776
}
6877

6978
export interface RenderComponentOptions<ComponentType, Q extends Queries = typeof queries> {
@@ -155,7 +164,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
155164
schemas?: any[];
156165
/**
157166
* @description
158-
* An object to set `@Input` and `@Output` properties of the component
167+
* An object to set properties of the component
159168
*
160169
* @default
161170
* {}
@@ -169,6 +178,36 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
169178
* })
170179
*/
171180
componentProperties?: Partial<ComponentType>;
181+
/**
182+
* @description
183+
* An object to set `@Input` properties of the component
184+
*
185+
* @default
186+
* {}
187+
*
188+
* @example
189+
* const component = await render(AppComponent, {
190+
* componentInputs: {
191+
* counterValue: 10
192+
* }
193+
* })
194+
*/
195+
componentInputs?: Partial<ComponentType>;
196+
/**
197+
* @description
198+
* An object to set `@Output` properties of the component
199+
*
200+
* @default
201+
* {}
202+
*
203+
* @example
204+
* const component = await render(AppComponent, {
205+
* componentOutputs: {
206+
* send: (value) => { ... }
207+
* }
208+
* })
209+
*/
210+
componentOutputs?: Partial<ComponentType>;
172211
/**
173212
* @description
174213
* A collection of providers to inject dependencies of the component.

Diff for: projects/testing-library/src/lib/testing-library.ts

+59-9
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export async function render<SutType, WrapperType = SutType>(
5454
queries,
5555
wrapper = WrapperComponent as Type<WrapperType>,
5656
componentProperties = {},
57+
componentInputs = {},
58+
componentOutputs = {},
5759
componentProviders = [],
5860
componentImports: componentImports,
5961
excludeComponentDeclaration = false,
@@ -102,25 +104,51 @@ export async function render<SutType, WrapperType = SutType>(
102104

103105
if (typeof router?.initialNavigation === 'function') {
104106
if (zone) {
105-
zone.run(() => router?.initialNavigation());
107+
zone.run(() => router.initialNavigation());
106108
} else {
107-
router?.initialNavigation();
109+
router.initialNavigation();
108110
}
109111
}
110112

111113
let fixture: ComponentFixture<SutType>;
112114
let detectChanges: () => void;
113115

114-
await renderFixture(componentProperties);
116+
await renderFixture(componentProperties, componentInputs, componentOutputs);
115117

116-
const rerender = async (rerenderedProperties: Partial<SutType>) => {
117-
await renderFixture(rerenderedProperties);
118+
const rerender = async (
119+
properties?: Pick<RenderTemplateOptions<SutType>, 'componentProperties' | 'componentInputs' | 'componentOutputs'>,
120+
) => {
121+
await renderFixture(
122+
properties?.componentProperties ?? {},
123+
properties?.componentInputs ?? {},
124+
properties?.componentOutputs ?? {},
125+
);
126+
};
127+
128+
const changeInput = (changedInputProperties: Partial<SutType>) => {
129+
if (Object.keys(changedInputProperties).length === 0) {
130+
return;
131+
}
132+
133+
const changes = getChangesObj(fixture.componentInstance as Record<string, any>, changedInputProperties);
134+
135+
setComponentInputs(fixture, changedInputProperties);
136+
137+
if (hasOnChangesHook(fixture.componentInstance)) {
138+
fixture.componentInstance.ngOnChanges(changes);
139+
}
140+
141+
fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges();
118142
};
119143

120144
const change = (changedProperties: Partial<SutType>) => {
145+
if (Object.keys(changedProperties).length === 0) {
146+
return;
147+
}
148+
121149
const changes = getChangesObj(fixture.componentInstance as Record<string, any>, changedProperties);
122150

123-
setComponentProperties(fixture, { componentProperties: changedProperties });
151+
setComponentProperties(fixture, changedProperties);
124152

125153
if (hasOnChangesHook(fixture.componentInstance)) {
126154
fixture.componentInstance.ngOnChanges(changes);
@@ -176,6 +204,7 @@ export async function render<SutType, WrapperType = SutType>(
176204
navigate,
177205
rerender,
178206
change,
207+
changeInput,
179208
// @ts-ignore: fixture assigned
180209
debugElement: fixture.debugElement,
181210
// @ts-ignore: fixture assigned
@@ -188,13 +217,16 @@ export async function render<SutType, WrapperType = SutType>(
188217
...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)),
189218
};
190219

191-
async function renderFixture(properties: Partial<SutType>) {
220+
async function renderFixture(properties: Partial<SutType>, inputs: Partial<SutType>, outputs: Partial<SutType>) {
192221
if (fixture) {
193222
cleanupAtFixture(fixture);
194223
}
195224

196225
fixture = await createComponent(componentContainer);
197-
setComponentProperties(fixture, { componentProperties: properties });
226+
227+
setComponentProperties(fixture, properties);
228+
setComponentInputs(fixture, inputs);
229+
setComponentOutputs(fixture, outputs);
198230

199231
if (removeAngularAttributes) {
200232
fixture.nativeElement.removeAttribute('ng-version');
@@ -244,7 +276,7 @@ function createComponentFixture<SutType, WrapperType>(
244276

245277
function setComponentProperties<SutType>(
246278
fixture: ComponentFixture<SutType>,
247-
{ componentProperties = {} }: Pick<RenderTemplateOptions<SutType, any>, 'componentProperties'>,
279+
componentProperties: RenderTemplateOptions<SutType, any>['componentProperties'] = {},
248280
) {
249281
for (const key of Object.keys(componentProperties)) {
250282
const descriptor = Object.getOwnPropertyDescriptor((fixture.componentInstance as any).constructor.prototype, key);
@@ -270,6 +302,24 @@ function setComponentProperties<SutType>(
270302
return fixture;
271303
}
272304

305+
function setComponentOutputs<SutType>(
306+
fixture: ComponentFixture<SutType>,
307+
componentOutputs: RenderTemplateOptions<SutType, any>['componentOutputs'] = {},
308+
) {
309+
for (const [name, value] of Object.entries(componentOutputs)) {
310+
(fixture.componentInstance as any)[name] = value;
311+
}
312+
}
313+
314+
function setComponentInputs<SutType>(
315+
fixture: ComponentFixture<SutType>,
316+
componentInputs: RenderTemplateOptions<SutType>['componentInputs'] = {},
317+
) {
318+
for (const [name, value] of Object.entries(componentInputs)) {
319+
fixture.componentRef.setInput(name, value);
320+
}
321+
}
322+
273323
function overrideComponentImports<SutType>(sut: Type<SutType> | string, imports: (Type<any> | any[])[] | undefined) {
274324
if (imports) {
275325
if (typeof sut === 'function' && ɵisStandalone(sut)) {

Diff for: projects/testing-library/tests/changeInputs.spec.ts

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
2+
import { render, screen } from '../src/public_api';
3+
4+
@Component({
5+
selector: 'atl-fixture',
6+
template: ` {{ firstName }} {{ lastName }} `,
7+
})
8+
class FixtureComponent {
9+
@Input() firstName = 'Sarah';
10+
@Input() lastName?: string;
11+
}
12+
13+
test('changes the component with updated props', async () => {
14+
const { changeInput } = await render(FixtureComponent);
15+
expect(screen.getByText('Sarah')).toBeInTheDocument();
16+
17+
const firstName = 'Mark';
18+
changeInput({ firstName });
19+
20+
expect(screen.getByText(firstName)).toBeInTheDocument();
21+
});
22+
23+
test('changes the component with updated props while keeping other props untouched', async () => {
24+
const firstName = 'Mark';
25+
const lastName = 'Peeters';
26+
const { changeInput } = await render(FixtureComponent, {
27+
componentInputs: {
28+
firstName,
29+
lastName,
30+
},
31+
});
32+
33+
expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument();
34+
35+
const firstName2 = 'Chris';
36+
changeInput({ firstName: firstName2 });
37+
38+
expect(screen.getByText(`${firstName2} ${lastName}`)).toBeInTheDocument();
39+
});
40+
41+
@Component({
42+
selector: 'atl-fixture',
43+
template: ` {{ name }} `,
44+
})
45+
class FixtureWithNgOnChangesComponent implements OnChanges {
46+
@Input() name = 'Sarah';
47+
@Input() nameChanged?: (name: string, isFirstChange: boolean) => void;
48+
49+
ngOnChanges(changes: SimpleChanges) {
50+
if (changes.name && this.nameChanged) {
51+
this.nameChanged(changes.name.currentValue, changes.name.isFirstChange());
52+
}
53+
}
54+
}
55+
56+
test('will call ngOnChanges on change', async () => {
57+
const nameChanged = jest.fn();
58+
const componentInputs = { nameChanged };
59+
const { changeInput } = await render(FixtureWithNgOnChangesComponent, { componentInputs });
60+
expect(screen.getByText('Sarah')).toBeInTheDocument();
61+
62+
const name = 'Mark';
63+
changeInput({ name });
64+
65+
expect(screen.getByText(name)).toBeInTheDocument();
66+
expect(nameChanged).toHaveBeenCalledWith(name, false);
67+
});
68+
69+
@Component({
70+
changeDetection: ChangeDetectionStrategy.OnPush,
71+
selector: 'atl-fixture',
72+
template: ` <div data-testid="number" [class.active]="activeField === 'number'">Number</div> `,
73+
})
74+
class FixtureWithOnPushComponent {
75+
@Input() activeField = '';
76+
}
77+
78+
test('update properties on change', async () => {
79+
const { changeInput } = await render(FixtureWithOnPushComponent);
80+
const numberHtmlElementRef = screen.queryByTestId('number');
81+
82+
expect(numberHtmlElementRef).not.toHaveClass('active');
83+
changeInput({ activeField: 'number' });
84+
expect(numberHtmlElementRef).toHaveClass('active');
85+
});

Diff for: projects/testing-library/tests/rerender.spec.ts

+22-3
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,26 @@ test('rerenders the component with updated props', async () => {
1515
expect(screen.getByText('Sarah')).toBeInTheDocument();
1616

1717
const firstName = 'Mark';
18-
await rerender({ firstName });
18+
await rerender({ componentProperties: { firstName } });
19+
20+
expect(screen.getByText(firstName)).toBeInTheDocument();
21+
});
22+
23+
test('rerenders without props', async () => {
24+
const { rerender } = await render(FixtureComponent);
25+
expect(screen.getByText('Sarah')).toBeInTheDocument();
26+
27+
await rerender();
28+
29+
expect(screen.getByText('Sarah')).toBeInTheDocument();
30+
});
31+
32+
test('rerenders the component with updated inputs', async () => {
33+
const { rerender } = await render(FixtureComponent);
34+
expect(screen.getByText('Sarah')).toBeInTheDocument();
35+
36+
const firstName = 'Mark';
37+
await rerender({ componentInputs: { firstName } });
1938

2039
expect(screen.getByText(firstName)).toBeInTheDocument();
2140
});
@@ -33,8 +52,8 @@ test('rerenders the component with updated props and resets other props', async
3352
expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument();
3453

3554
const firstName2 = 'Chris';
36-
rerender({ firstName: firstName2 });
55+
await rerender({ componentProperties: { firstName: firstName2 } });
3756

3857
expect(screen.queryByText(`${firstName2} ${lastName}`)).not.toBeInTheDocument();
39-
expect(screen.queryByText(firstName2)).not.toBeInTheDocument();
58+
expect(screen.queryByText(`${firstName} ${lastName}`)).not.toBeInTheDocument();
4059
});

0 commit comments

Comments
 (0)