Skip to content

Commit

Permalink
feat: add rerender method (#257)
Browse files Browse the repository at this point in the history
BREAKING CHANGE:

`rerender` has been renamed to `change`.
The `change` method keeps the current fixture intact and invokes `ngOnChanges`.

The new `rerender` method destroys the current component and creates a new instance with the updated properties.

BEFORE:

```ts
const { rerender } = render(...)
rerender({...})
```

AFTER:

```ts
const { change } = render(...)
change({...})
```
  • Loading branch information
timdeschryver authored Nov 24, 2021
1 parent 5b4270c commit 0e5e3c7
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 101 deletions.
18 changes: 16 additions & 2 deletions apps/example-app-karma/src/app/issues/issue-222.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
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(`<div>Hello {{ name}}</div>`, {
componentProperties: {
name: 'Sarah',
},
});

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(`<div>Hello {{ name}}</div>`, {
componentProperties: {
name: 'Sarah',
},
});

expect(screen.getByText('Hello Sarah')).toBeTruthy();
await change({ name: 'Mark' });

expect(screen.getByText('Hello Mark')).toBeTruthy();
});
19 changes: 16 additions & 3 deletions apps/example-app/src/app/examples/16-input-getter-setter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
11 changes: 9 additions & 2 deletions projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,16 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
navigate: (elementOrPath: Element | string, basePath?: string) => Promise<boolean>;
/**
* @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<ComponentType>) => void;
rerender: (rerenderedProperties: Partial<ComponentType>) => void;

/**
* @description
* Keeps the current fixture intact and invokes ngOnChanges with the updated properties.
*/
change: (changedProperties: Partial<ComponentType>) => void;
}

export interface RenderComponentOptions<ComponentType, Q extends Queries = typeof queries> {
Expand Down
106 changes: 60 additions & 46 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,53 +97,30 @@ export async function render<SutType, WrapperType = SutType>(
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<SutType>;
let detectChanges: () => void;

if (detectChangesOnRender) {
detectChanges();
}
await renderFixture(componentProperties);

const rerender = async (rerenderedProperties: Partial<SutType>) => {
await renderFixture(rerenderedProperties);
};

const rerender = (rerenderedProperties: Partial<SutType>) => {
const changes = getChangesObj(fixture.componentInstance, rerenderedProperties);
const change = (changedProperties: Partial<SutType>) => {
const changes = getChangesObj(fixture.componentInstance, changedProperties);

setComponentProperties(fixture, { componentProperties: rerenderedProperties });
setComponentProperties(fixture, { componentProperties: changedProperties });

if (hasOnChangesHook(fixture.componentInstance)) {
fixture.componentInstance.ngOnChanges(changes);
Expand Down Expand Up @@ -192,9 +169,10 @@ export async function render<SutType, WrapperType = SutType>(

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) =>
Expand All @@ -203,6 +181,42 @@ export async function render<SutType, WrapperType = SutType>(
: console.log(dtlPrettyDOM(element, maxLength, options)),
...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)),
};

async function renderFixture(properties: Partial<SutType>) {
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<SutType>(component: Type<SutType>): Promise<ComponentFixture<SutType>> {
Expand All @@ -211,19 +225,19 @@ async function createComponent<SutType>(component: Type<SutType>): Promise<Compo
return TestBed.createComponent(component);
}

async function createComponentFixture<SutType>(
function createComponentFixture<SutType>(
sut: Type<SutType> | string,
{ template, wrapper }: Pick<RenderDirectiveOptions<any>, 'template' | 'wrapper'>,
): Promise<ComponentFixture<SutType>> {
): Type<any> {
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<SutType>(
Expand Down
85 changes: 85 additions & 0 deletions projects/testing-library/tests/change.spec.ts
Original file line number Diff line number Diff line change
@@ -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: ` <div data-testid="number" [class.active]="activeField === 'number'">Number</div> `,
})
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');
});
Loading

0 comments on commit 0e5e3c7

Please sign in to comment.