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 740d8184..b6ac5204 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 @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/angular'; -it('https://github.com/testing-library/angular-testing-library/issues/222', async () => { +it('https://github.com/testing-library/angular-testing-library/issues/222 with rerender', async () => { const { rerender } = await render(`
Hello {{ name}}
`, { componentProperties: { name: 'Sarah', @@ -8,7 +8,21 @@ it('https://github.com/testing-library/angular-testing-library/issues/222', asyn }); expect(screen.getByText('Hello Sarah')).toBeTruthy(); - rerender({ name: 'Mark' }); + + await rerender({ name: 'Mark' }); + + expect(screen.getByText('Hello Mark')).toBeTruthy(); +}); + +it('https://github.com/testing-library/angular-testing-library/issues/222 with change', async () => { + const { change } = await render(`
Hello {{ name}}
`, { + componentProperties: { + name: 'Sarah', + }, + }); + + expect(screen.getByText('Hello Sarah')).toBeTruthy(); + await change({ 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 28d736c6..1d87edf9 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 @@ -10,16 +10,29 @@ test('should run logic in the input setter and getter', async () => { expect(getterValueControl).toHaveTextContent('I am value from getter Angular'); }); -test('should run logic in the input setter and getter while re-rendering', async () => { - const { rerender } = await render(InputGetterSetter, { componentProperties: { value: 'Angular' } }); +test('should run logic in the input setter and getter while changing', async () => { + const { change } = await render(InputGetterSetter, { componentProperties: { value: 'Angular' } }); const valueControl = screen.getByTestId('value'); const getterValueControl = screen.getByTestId('value-getter'); expect(valueControl).toHaveTextContent('I am value from setter Angular'); expect(getterValueControl).toHaveTextContent('I am value from getter Angular'); - await rerender({ value: 'React' }); + await change({ value: 'React' }); expect(valueControl).toHaveTextContent('I am value from setter React'); expect(getterValueControl).toHaveTextContent('I am value from getter React'); }); + +test('should run logic in the input setter and getter while re-rendering', async () => { + const { rerender } = await render(InputGetterSetter, { componentProperties: { value: 'Angular' } }); + + 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' }); + + // 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'); + expect(screen.getByTestId('value-getter')).toHaveTextContent('I am value from getter React'); +}); diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 54b5f2ee..bda39543 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -54,9 +54,16 @@ export interface RenderResult extend navigate: (elementOrPath: Element | string, basePath?: string) => Promise; /** * @description - * Re-render the same component with different props. + * Re-render the same component with different properties. + * This creates a new instance of the component. */ - rerender: (componentProperties: Partial) => void; + rerender: (rerenderedProperties: Partial) => void; + + /** + * @description + * Keeps the current fixture intact and invokes ngOnChanges with the updated properties. + */ + change: (changedProperties: Partial) => void; } export interface RenderComponentOptions { diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 36ee871b..bfbe7f93 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -97,53 +97,30 @@ export async function render( schemas: [...schemas], }); - if (componentProviders) { - componentProviders - .reduce((acc, provider) => acc.concat(provider), []) - .forEach((p) => { - const { provide, ...provider } = p; - TestBed.overrideProvider(provide, provider); - }); - } - - const fixture = await createComponentFixture(sut, { template, wrapper }); - setComponentProperties(fixture, { componentProperties }); - - if (removeAngularAttributes) { - fixture.nativeElement.removeAttribute('ng-version'); - const idAttribute = fixture.nativeElement.getAttribute('id'); - if (idAttribute && idAttribute.startsWith('root')) { - fixture.nativeElement.removeAttribute('id'); - } - } - - mountedFixtures.add(fixture); - await TestBed.compileComponents(); - let isAlive = true; - fixture.componentRef.onDestroy(() => (isAlive = false)); + componentProviders + .reduce((acc, provider) => acc.concat(provider), []) + .forEach((p) => { + const { provide, ...provider } = p; + TestBed.overrideProvider(provide, provider); + }); - function detectChanges() { - if (isAlive) { - fixture.detectChanges(); - } - } + const componentContainer = createComponentFixture(sut, { template, wrapper }); - // Call ngOnChanges on initial render - if (hasOnChangesHook(fixture.componentInstance)) { - const changes = getChangesObj(null, componentProperties); - fixture.componentInstance.ngOnChanges(changes); - } + let fixture: ComponentFixture; + let detectChanges: () => void; - if (detectChangesOnRender) { - detectChanges(); - } + await renderFixture(componentProperties); + + const rerender = async (rerenderedProperties: Partial) => { + await renderFixture(rerenderedProperties); + }; - const rerender = (rerenderedProperties: Partial) => { - const changes = getChangesObj(fixture.componentInstance, rerenderedProperties); + const change = (changedProperties: Partial) => { + const changes = getChangesObj(fixture.componentInstance, changedProperties); - setComponentProperties(fixture, { componentProperties: rerenderedProperties }); + setComponentProperties(fixture, { componentProperties: changedProperties }); if (hasOnChangesHook(fixture.componentInstance)) { fixture.componentInstance.ngOnChanges(changes); @@ -192,9 +169,10 @@ export async function render( return { fixture, - detectChanges, + detectChanges: () => detectChanges(), navigate, rerender, + change, debugElement: typeof sut === 'string' ? fixture.debugElement : fixture.debugElement.query(By.directive(sut)), container: fixture.nativeElement, debug: (element = fixture.nativeElement, maxLength, options) => @@ -203,6 +181,42 @@ export async function render( : console.log(dtlPrettyDOM(element, maxLength, options)), ...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)), }; + + async function renderFixture(properties: Partial) { + if (fixture) { + cleanupAtFixture(fixture); + } + + fixture = await createComponent(componentContainer); + setComponentProperties(fixture, { componentProperties: properties }); + + if (removeAngularAttributes) { + fixture.nativeElement.removeAttribute('ng-version'); + const idAttribute = fixture.nativeElement.getAttribute('id'); + if (idAttribute && idAttribute.startsWith('root')) { + fixture.nativeElement.removeAttribute('id'); + } + } + mountedFixtures.add(fixture); + + let isAlive = true; + fixture.componentRef.onDestroy(() => (isAlive = false)); + + if (hasOnChangesHook(fixture.componentInstance)) { + const changes = getChangesObj(null, componentProperties); + fixture.componentInstance.ngOnChanges(changes); + } + + detectChanges = () => { + if (isAlive) { + fixture.detectChanges(); + } + }; + + if (detectChangesOnRender) { + detectChanges(); + } + } } async function createComponent(component: Type): Promise> { @@ -211,19 +225,19 @@ async function createComponent(component: Type): Promise( +function createComponentFixture( sut: Type | string, { template, wrapper }: Pick, 'template' | 'wrapper'>, -): Promise> { +): Type { if (typeof sut === 'string') { TestBed.overrideTemplate(wrapper, sut); - return createComponent(wrapper); + return wrapper; } if (template) { TestBed.overrideTemplate(wrapper, template); - return createComponent(wrapper); + return wrapper; } - return createComponent(sut); + return sut; } function setComponentProperties( diff --git a/projects/testing-library/tests/change.spec.ts b/projects/testing-library/tests/change.spec.ts new file mode 100644 index 00000000..85cc3667 --- /dev/null +++ b/projects/testing-library/tests/change.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; +} + +test('changes the component with updated props', async () => { + const { change } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + const firstName = 'Mark'; + change({ 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 { change } = await render(FixtureComponent, { + componentProperties: { + firstName, + lastName, + }, + }); + + expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); + + const firstName2 = 'Chris'; + change({ 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 componentProperties = { nameChanged }; + const { change } = await render(FixtureWithNgOnChangesComponent, { componentProperties }); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + const name = 'Mark'; + change({ name }); + + expect(screen.getByText(name)).toBeInTheDocument(); + expect(nameChanged).toHaveBeenCalledWith(name, false); +}); + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'atl-fixture', + template: `
Number
`, +}) +class FixtureWithOnPushComponent { + @Input() activeField: string; +} + +test('update properties on change', async () => { + const { change } = await render(FixtureWithOnPushComponent); + const numberHtmlElementRef = screen.queryByTestId('number'); + + expect(numberHtmlElementRef).not.toHaveClass('active'); + change({ 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 c98bd1bc..443560a2 100644 --- a/projects/testing-library/tests/rerender.spec.ts +++ b/projects/testing-library/tests/rerender.spec.ts @@ -1,66 +1,40 @@ -import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { render, screen } from '../src/public_api'; @Component({ selector: 'atl-fixture', - template: ` {{ name }} `, + template: ` {{ firstName }} {{ lastName }} `, }) class FixtureComponent { - @Input() name = 'Sarah'; + @Input() firstName = 'Sarah'; + @Input() lastName; } -test('will rerender the component with updated props', async () => { +test('rerenders the component with updated props', async () => { const { rerender } = await render(FixtureComponent); expect(screen.getByText('Sarah')).toBeInTheDocument(); - const name = 'Mark'; - rerender({ name }); + const firstName = 'Mark'; + await rerender({ firstName }); - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(firstName)).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 rerender', async () => { - const nameChanged = jest.fn(); - const componentProperties = { nameChanged }; - const { rerender } = await render(FixtureWithNgOnChangesComponent, { componentProperties }); - expect(screen.getByText('Sarah')).toBeInTheDocument(); +test('rerenders the component with updated props and resets other props', async () => { + const firstName = 'Mark'; + const lastName = 'Peeters'; + const { rerender } = await render(FixtureComponent, { + componentProperties: { + firstName, + lastName, + }, + }); - const name = 'Mark'; - rerender({ name }); - - expect(screen.getByText(name)).toBeInTheDocument(); - expect(nameChanged).toHaveBeenCalledWith(name, false); -}); - -@Component({ - changeDetection: ChangeDetectionStrategy.OnPush, - selector: 'atl-fixture', - template: `
Number
`, -}) -class FixtureWithOnPushComponent { - @Input() activeField: string; -} + expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); -test('update properties on rerender', async () => { - const { rerender } = await render(FixtureWithOnPushComponent); - const numberHtmlElementRef = screen.queryByTestId('number'); + const firstName2 = 'Chris'; + rerender({ firstName: firstName2 }); - expect(numberHtmlElementRef).not.toHaveClass('active'); - rerender({ activeField: 'number' }); - expect(numberHtmlElementRef).toHaveClass('active'); + expect(screen.queryByText(`${firstName2} ${lastName}`)).not.toBeInTheDocument(); + expect(screen.queryByText(firstName2)).not.toBeInTheDocument(); });