Skip to content

Commit 6e31db5

Browse files
fix: invoke changeChanges once on input change (#339)
1 parent d184618 commit 6e31db5

File tree

3 files changed

+70
-47
lines changed

3 files changed

+70
-47
lines changed

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

+6-8
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,9 @@ export async function render<SutType, WrapperType = SutType>(
126126
return;
127127
}
128128

129-
const changes = getChangesObj(fixture.componentInstance as Record<string, any>, changedInputProperties);
130-
131129
setComponentInputs(fixture, changedInputProperties);
132130

133-
if (hasOnChangesHook(fixture.componentInstance)) {
134-
fixture.componentInstance.ngOnChanges(changes);
135-
}
136-
137-
fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges();
131+
fixture.detectChanges();
138132
};
139133

140134
const change = (changedProperties: Partial<SutType>) => {
@@ -229,6 +223,7 @@ export async function render<SutType, WrapperType = SutType>(
229223
fixture.nativeElement.removeAttribute('id');
230224
}
231225
}
226+
232227
mountedFixtures.add(fixture);
233228

234229
let isAlive = true;
@@ -338,7 +333,10 @@ function overrideChildComponentProviders(componentOverrides: ComponentOverride<a
338333

339334
function hasOnChangesHook<SutType>(componentInstance: SutType): componentInstance is SutType & OnChanges {
340335
return (
341-
'ngOnChanges' in componentInstance && typeof (componentInstance as SutType & OnChanges).ngOnChanges === 'function'
336+
componentInstance !== null &&
337+
typeof componentInstance === 'object' &&
338+
'ngOnChanges' in componentInstance &&
339+
typeof (componentInstance as SutType & OnChanges).ngOnChanges === 'function'
342340
);
343341
}
344342

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

+18-19
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
1+
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
22
import { render, screen } from '../src/public_api';
33

44
@Component({
@@ -40,30 +40,29 @@ test('changes the component with updated props while keeping other props untouch
4040

4141
@Component({
4242
selector: 'atl-fixture',
43-
template: ` {{ name }} `,
43+
template: ` {{ propOne }} {{ propTwo }}`,
4444
})
4545
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-
}
46+
@Input() propOne = 'Init';
47+
@Input() propTwo = '';
48+
49+
// eslint-disable-next-line @angular-eslint/no-empty-lifecycle-method, @typescript-eslint/no-empty-function
50+
ngOnChanges() {}
5451
}
5552

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();
53+
test('calls ngOnChanges on change', async () => {
54+
const componentInputs = { propOne: 'One', propTwo: 'Two' };
55+
const { changeInput, fixture } = await render(FixtureWithNgOnChangesComponent, { componentInputs });
56+
const spy = jest.spyOn(fixture.componentInstance, 'ngOnChanges');
57+
58+
expect(screen.getByText(`${componentInputs.propOne} ${componentInputs.propTwo}`)).toBeInTheDocument();
6159

62-
const name = 'Mark';
63-
changeInput({ name });
60+
const propOne = 'UpdatedOne';
61+
const propTwo = 'UpdatedTwo';
62+
changeInput({ propOne, propTwo });
6463

65-
expect(screen.getByText(name)).toBeInTheDocument();
66-
expect(nameChanged).toHaveBeenCalledWith(name, false);
64+
expect(spy).toHaveBeenCalledTimes(1);
65+
expect(screen.getByText(`${propOne} ${propTwo}`)).toBeInTheDocument();
6766
});
6867

6968
@Component({

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

+46-20
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ describe('Angular component life-cycle hooks', () => {
220220
expect(nameInitialized).toHaveBeenCalledWith('Initial');
221221
});
222222

223-
it('invokes ngOnChanges on initial render before ngOnInit', async () => {
223+
it('invokes ngOnChanges with componentProperties on initial render before ngOnInit', async () => {
224224
const nameInitialized = jest.fn();
225225
const nameChanged = jest.fn();
226226
const componentProperties = { nameInitialized, nameChanged, name: 'Sarah' };
@@ -233,6 +233,23 @@ describe('Angular component life-cycle hooks', () => {
233233
expect(nameChanged).toHaveBeenCalledWith('Sarah', true);
234234
/// expect `nameChanged` to be called before `nameInitialized`
235235
expect(nameChanged.mock.invocationCallOrder[0]).toBeLessThan(nameInitialized.mock.invocationCallOrder[0]);
236+
expect(nameChanged).toHaveBeenCalledTimes(1);
237+
});
238+
239+
it('invokes ngOnChanges with componentInputs on initial render before ngOnInit', async () => {
240+
const nameInitialized = jest.fn();
241+
const nameChanged = jest.fn();
242+
const componentInput = { nameInitialized, nameChanged, name: 'Sarah' };
243+
244+
const view = await render(FixtureWithNgOnChangesComponent, { componentInputs: componentInput });
245+
246+
/// We wish to test the utility function from `render` here.
247+
// eslint-disable-next-line testing-library/prefer-screen-queries
248+
expect(view.getByText('Sarah')).toBeInTheDocument();
249+
expect(nameChanged).toHaveBeenCalledWith('Sarah', true);
250+
/// expect `nameChanged` to be called before `nameInitialized`
251+
expect(nameChanged.mock.invocationCallOrder[0]).toBeLessThan(nameInitialized.mock.invocationCallOrder[0]);
252+
expect(nameChanged).toHaveBeenCalledTimes(1);
236253
});
237254

238255
it('does not invoke ngOnChanges when no properties are provided', async () => {
@@ -243,30 +260,39 @@ describe('Angular component life-cycle hooks', () => {
243260
}
244261
}
245262

246-
await render(TestFixtureComponent);
263+
const { fixture, detectChanges } = await render(TestFixtureComponent);
264+
const spy = jest.spyOn(fixture.componentInstance, 'ngOnChanges');
265+
266+
detectChanges();
267+
268+
expect(spy).not.toHaveBeenCalled();
247269
});
248270
});
249271

250-
test('waits for angular app initialization before rendering components', async () => {
251-
const mock = jest.fn();
252-
253-
await render(FixtureComponent, {
254-
providers: [
255-
{
256-
provide: APP_INITIALIZER,
257-
useFactory: () => mock,
258-
multi: true,
259-
},
260-
],
261-
});
272+
describe('initializer', () => {
273+
it('waits for angular app initialization before rendering components', async () => {
274+
const mock = jest.fn();
275+
276+
await render(FixtureComponent, {
277+
providers: [
278+
{
279+
provide: APP_INITIALIZER,
280+
useFactory: () => mock,
281+
multi: true,
282+
},
283+
],
284+
});
262285

263-
expect(TestBed.inject(ApplicationInitStatus).done).toBe(true);
264-
expect(mock).toHaveBeenCalled();
286+
expect(TestBed.inject(ApplicationInitStatus).done).toBe(true);
287+
expect(mock).toHaveBeenCalled();
288+
});
265289
});
266290

267-
test('gets the DebugElement', async () => {
268-
const view = await render(FixtureComponent);
291+
describe('DebugElement', () => {
292+
it('gets the DebugElement', async () => {
293+
const view = await render(FixtureComponent);
269294

270-
expect(view.debugElement).not.toBeNull();
271-
expect(view.debugElement.componentInstance).toBeInstanceOf(FixtureComponent);
295+
expect(view.debugElement).not.toBeNull();
296+
expect(view.debugElement.componentInstance).toBeInstanceOf(FixtureComponent);
297+
});
272298
});

0 commit comments

Comments
 (0)