diff --git a/apps/example-app-karma/src/app/issues/issue-222.spec.ts b/apps/example-app-karma/src/app/issues/issue-222.spec.ts index b6ac520..17e9a02 100644 --- a/apps/example-app-karma/src/app/issues/issue-222.spec.ts +++ b/apps/example-app-karma/src/app/issues/issue-222.spec.ts @@ -9,7 +9,7 @@ it('https://github.com/testing-library/angular-testing-library/issues/222 with r expect(screen.getByText('Hello Sarah')).toBeTruthy(); - await rerender({ name: 'Mark' }); + await rerender({ componentProperties: { name: 'Mark' } }); expect(screen.getByText('Hello Mark')).toBeTruthy(); }); diff --git a/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts b/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts index 1d87edf..53dee01 100644 --- a/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts +++ b/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts @@ -30,7 +30,7 @@ test('should run logic in the input setter and getter while re-rendering', async expect(screen.getByTestId('value')).toHaveTextContent('I am value from setter Angular'); expect(screen.getByTestId('value-getter')).toHaveTextContent('I am value from getter Angular'); - await rerender({ value: 'React' }); + await rerender({ componentProperties: { value: 'React' } }); // note we have to re-query because the elements are not the same anymore expect(screen.getByTestId('value')).toHaveTextContent('I am value from setter React'); diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index c1d931c..d9106f6 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -57,13 +57,22 @@ export interface RenderResult extend * Re-render the same component with different properties. * This creates a new instance of the component. */ - rerender: (rerenderedProperties: Partial) => Promise; - + rerender: ( + properties?: Pick< + RenderTemplateOptions, + 'componentProperties' | 'componentInputs' | 'componentOutputs' + >, + ) => Promise; /** * @description * Keeps the current fixture intact and invokes ngOnChanges with the updated properties. */ change: (changedProperties: Partial) => void; + /** + * @description + * Keeps the current fixture intact, update the @Input properties and invoke ngOnChanges with the updated properties. + */ + changeInput: (changedInputProperties: Partial) => void; } export interface RenderComponentOptions { @@ -155,7 +164,7 @@ export interface RenderComponentOptions; + /** + * @description + * An object to set `@Input` properties of the component + * + * @default + * {} + * + * @example + * const component = await render(AppComponent, { + * componentInputs: { + * counterValue: 10 + * } + * }) + */ + componentInputs?: Partial; + /** + * @description + * An object to set `@Output` properties of the component + * + * @default + * {} + * + * @example + * const component = await render(AppComponent, { + * componentOutputs: { + * send: (value) => { ... } + * } + * }) + */ + componentOutputs?: Partial; /** * @description * A collection of providers to inject dependencies of the component. diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index dde9751..9ca1f61 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -54,6 +54,8 @@ export async function render( queries, wrapper = WrapperComponent as Type, componentProperties = {}, + componentInputs = {}, + componentOutputs = {}, componentProviders = [], childComponentOverrides = [], componentImports: componentImports, @@ -104,25 +106,51 @@ export async function render( if (typeof router?.initialNavigation === 'function') { if (zone) { - zone.run(() => router?.initialNavigation()); + zone.run(() => router.initialNavigation()); } else { - router?.initialNavigation(); + router.initialNavigation(); } } let fixture: ComponentFixture; let detectChanges: () => void; - await renderFixture(componentProperties); + await renderFixture(componentProperties, componentInputs, componentOutputs); - const rerender = async (rerenderedProperties: Partial) => { - await renderFixture(rerenderedProperties); + const rerender = async ( + properties?: Pick, 'componentProperties' | 'componentInputs' | 'componentOutputs'>, + ) => { + await renderFixture( + properties?.componentProperties ?? {}, + properties?.componentInputs ?? {}, + properties?.componentOutputs ?? {}, + ); + }; + + const changeInput = (changedInputProperties: Partial) => { + if (Object.keys(changedInputProperties).length === 0) { + return; + } + + const changes = getChangesObj(fixture.componentInstance as Record, changedInputProperties); + + setComponentInputs(fixture, changedInputProperties); + + if (hasOnChangesHook(fixture.componentInstance)) { + fixture.componentInstance.ngOnChanges(changes); + } + + fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges(); }; const change = (changedProperties: Partial) => { + if (Object.keys(changedProperties).length === 0) { + return; + } + const changes = getChangesObj(fixture.componentInstance as Record, changedProperties); - setComponentProperties(fixture, { componentProperties: changedProperties }); + setComponentProperties(fixture, changedProperties); if (hasOnChangesHook(fixture.componentInstance)) { fixture.componentInstance.ngOnChanges(changes); @@ -178,6 +206,7 @@ export async function render( navigate, rerender, change, + changeInput, // @ts-ignore: fixture assigned debugElement: fixture.debugElement, // @ts-ignore: fixture assigned @@ -190,13 +219,16 @@ export async function render( ...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)), }; - async function renderFixture(properties: Partial) { + async function renderFixture(properties: Partial, inputs: Partial, outputs: Partial) { if (fixture) { cleanupAtFixture(fixture); } fixture = await createComponent(componentContainer); - setComponentProperties(fixture, { componentProperties: properties }); + + setComponentProperties(fixture, properties); + setComponentInputs(fixture, inputs); + setComponentOutputs(fixture, outputs); if (removeAngularAttributes) { fixture.nativeElement.removeAttribute('ng-version'); @@ -246,7 +278,7 @@ function createComponentFixture( function setComponentProperties( fixture: ComponentFixture, - { componentProperties = {} }: Pick, 'componentProperties'>, + componentProperties: RenderTemplateOptions['componentProperties'] = {}, ) { for (const key of Object.keys(componentProperties)) { const descriptor = Object.getOwnPropertyDescriptor((fixture.componentInstance as any).constructor.prototype, key); @@ -272,6 +304,24 @@ function setComponentProperties( return fixture; } +function setComponentOutputs( + fixture: ComponentFixture, + componentOutputs: RenderTemplateOptions['componentOutputs'] = {}, +) { + for (const [name, value] of Object.entries(componentOutputs)) { + (fixture.componentInstance as any)[name] = value; + } +} + +function setComponentInputs( + fixture: ComponentFixture, + componentInputs: RenderTemplateOptions['componentInputs'] = {}, +) { + for (const [name, value] of Object.entries(componentInputs)) { + fixture.componentRef.setInput(name, value); + } +} + function overrideComponentImports(sut: Type | string, imports: (Type | any[])[] | undefined) { if (imports) { if (typeof sut === 'function' && ɵisStandalone(sut)) { diff --git a/projects/testing-library/tests/changeInputs.spec.ts b/projects/testing-library/tests/changeInputs.spec.ts new file mode 100644 index 0000000..962d7ad --- /dev/null +++ b/projects/testing-library/tests/changeInputs.spec.ts @@ -0,0 +1,85 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { render, screen } from '../src/public_api'; + +@Component({ + selector: 'atl-fixture', + template: ` {{ firstName }} {{ lastName }} `, +}) +class FixtureComponent { + @Input() firstName = 'Sarah'; + @Input() lastName?: string; +} + +test('changes the component with updated props', async () => { + const { changeInput } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + const firstName = 'Mark'; + changeInput({ firstName }); + + expect(screen.getByText(firstName)).toBeInTheDocument(); +}); + +test('changes the component with updated props while keeping other props untouched', async () => { + const firstName = 'Mark'; + const lastName = 'Peeters'; + const { changeInput } = await render(FixtureComponent, { + componentInputs: { + firstName, + lastName, + }, + }); + + expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); + + const firstName2 = 'Chris'; + changeInput({ firstName: firstName2 }); + + expect(screen.getByText(`${firstName2} ${lastName}`)).toBeInTheDocument(); +}); + +@Component({ + selector: 'atl-fixture', + template: ` {{ name }} `, +}) +class FixtureWithNgOnChangesComponent implements OnChanges { + @Input() name = 'Sarah'; + @Input() nameChanged?: (name: string, isFirstChange: boolean) => void; + + ngOnChanges(changes: SimpleChanges) { + if (changes.name && this.nameChanged) { + this.nameChanged(changes.name.currentValue, changes.name.isFirstChange()); + } + } +} + +test('will call ngOnChanges on change', async () => { + const nameChanged = jest.fn(); + const componentInputs = { nameChanged }; + const { changeInput } = await render(FixtureWithNgOnChangesComponent, { componentInputs }); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + const name = 'Mark'; + changeInput({ name }); + + expect(screen.getByText(name)).toBeInTheDocument(); + expect(nameChanged).toHaveBeenCalledWith(name, false); +}); + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'atl-fixture', + template: `
Number
`, +}) +class FixtureWithOnPushComponent { + @Input() activeField = ''; +} + +test('update properties on change', async () => { + const { changeInput } = await render(FixtureWithOnPushComponent); + const numberHtmlElementRef = screen.queryByTestId('number'); + + expect(numberHtmlElementRef).not.toHaveClass('active'); + changeInput({ activeField: 'number' }); + expect(numberHtmlElementRef).toHaveClass('active'); +}); diff --git a/projects/testing-library/tests/rerender.spec.ts b/projects/testing-library/tests/rerender.spec.ts index 0edf69e..d0ee43b 100644 --- a/projects/testing-library/tests/rerender.spec.ts +++ b/projects/testing-library/tests/rerender.spec.ts @@ -15,7 +15,26 @@ test('rerenders the component with updated props', async () => { expect(screen.getByText('Sarah')).toBeInTheDocument(); const firstName = 'Mark'; - await rerender({ firstName }); + await rerender({ componentProperties: { firstName } }); + + expect(screen.getByText(firstName)).toBeInTheDocument(); +}); + +test('rerenders without props', async () => { + const { rerender } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + await rerender(); + + expect(screen.getByText('Sarah')).toBeInTheDocument(); +}); + +test('rerenders the component with updated inputs', async () => { + const { rerender } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + const firstName = 'Mark'; + await rerender({ componentInputs: { firstName } }); expect(screen.getByText(firstName)).toBeInTheDocument(); }); @@ -33,8 +52,8 @@ test('rerenders the component with updated props and resets other props', async expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); const firstName2 = 'Chris'; - rerender({ firstName: firstName2 }); + await rerender({ componentProperties: { firstName: firstName2 } }); expect(screen.queryByText(`${firstName2} ${lastName}`)).not.toBeInTheDocument(); - expect(screen.queryByText(firstName2)).not.toBeInTheDocument(); + expect(screen.queryByText(`${firstName} ${lastName}`)).not.toBeInTheDocument(); });