Skip to content

Commit

Permalink
feat: add more fine-grained control over inputs and outputs (#328)
Browse files Browse the repository at this point in the history
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' 
  }
});
```
  • Loading branch information
timdeschryver committed Dec 8, 2022
1 parent 9384230 commit 881cc83
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 17 deletions.
2 changes: 1 addition & 1 deletion apps/example-app-karma/src/app/issues/issue-222.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
45 changes: 42 additions & 3 deletions projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,22 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
* Re-render the same component with different properties.
* This creates a new instance of the component.
*/
rerender: (rerenderedProperties: Partial<ComponentType>) => Promise<void>;

rerender: (
properties?: Pick<
RenderTemplateOptions<ComponentType>,
'componentProperties' | 'componentInputs' | 'componentOutputs'
>,
) => Promise<void>;
/**
* @description
* Keeps the current fixture intact and invokes ngOnChanges with the updated properties.
*/
change: (changedProperties: Partial<ComponentType>) => void;
/**
* @description
* Keeps the current fixture intact, update the @Input properties and invoke ngOnChanges with the updated properties.
*/
changeInput: (changedInputProperties: Partial<ComponentType>) => void;
}

export interface RenderComponentOptions<ComponentType, Q extends Queries = typeof queries> {
Expand Down Expand Up @@ -155,7 +164,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
schemas?: any[];
/**
* @description
* An object to set `@Input` and `@Output` properties of the component
* An object to set properties of the component
*
* @default
* {}
Expand All @@ -169,6 +178,36 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
* })
*/
componentProperties?: Partial<ComponentType>;
/**
* @description
* An object to set `@Input` properties of the component
*
* @default
* {}
*
* @example
* const component = await render(AppComponent, {
* componentInputs: {
* counterValue: 10
* }
* })
*/
componentInputs?: Partial<ComponentType>;
/**
* @description
* An object to set `@Output` properties of the component
*
* @default
* {}
*
* @example
* const component = await render(AppComponent, {
* componentOutputs: {
* send: (value) => { ... }
* }
* })
*/
componentOutputs?: Partial<ComponentType>;
/**
* @description
* A collection of providers to inject dependencies of the component.
Expand Down
68 changes: 59 additions & 9 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export async function render<SutType, WrapperType = SutType>(
queries,
wrapper = WrapperComponent as Type<WrapperType>,
componentProperties = {},
componentInputs = {},
componentOutputs = {},
componentProviders = [],
childComponentOverrides = [],
componentImports: componentImports,
Expand Down Expand Up @@ -104,25 +106,51 @@ export async function render<SutType, WrapperType = SutType>(

if (typeof router?.initialNavigation === 'function') {
if (zone) {
zone.run(() => router?.initialNavigation());
zone.run(() => router.initialNavigation());
} else {
router?.initialNavigation();
router.initialNavigation();
}
}

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

await renderFixture(componentProperties);
await renderFixture(componentProperties, componentInputs, componentOutputs);

const rerender = async (rerenderedProperties: Partial<SutType>) => {
await renderFixture(rerenderedProperties);
const rerender = async (
properties?: Pick<RenderTemplateOptions<SutType>, 'componentProperties' | 'componentInputs' | 'componentOutputs'>,
) => {
await renderFixture(
properties?.componentProperties ?? {},
properties?.componentInputs ?? {},
properties?.componentOutputs ?? {},
);
};

const changeInput = (changedInputProperties: Partial<SutType>) => {
if (Object.keys(changedInputProperties).length === 0) {
return;
}

const changes = getChangesObj(fixture.componentInstance as Record<string, any>, changedInputProperties);

setComponentInputs(fixture, changedInputProperties);

if (hasOnChangesHook(fixture.componentInstance)) {
fixture.componentInstance.ngOnChanges(changes);
}

fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges();
};

const change = (changedProperties: Partial<SutType>) => {
if (Object.keys(changedProperties).length === 0) {
return;
}

const changes = getChangesObj(fixture.componentInstance as Record<string, any>, changedProperties);

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

if (hasOnChangesHook(fixture.componentInstance)) {
fixture.componentInstance.ngOnChanges(changes);
Expand Down Expand Up @@ -178,6 +206,7 @@ export async function render<SutType, WrapperType = SutType>(
navigate,
rerender,
change,
changeInput,
// @ts-ignore: fixture assigned
debugElement: fixture.debugElement,
// @ts-ignore: fixture assigned
Expand All @@ -190,13 +219,16 @@ export async function render<SutType, WrapperType = SutType>(
...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)),
};

async function renderFixture(properties: Partial<SutType>) {
async function renderFixture(properties: Partial<SutType>, inputs: Partial<SutType>, outputs: Partial<SutType>) {
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');
Expand Down Expand Up @@ -246,7 +278,7 @@ function createComponentFixture<SutType, WrapperType>(

function setComponentProperties<SutType>(
fixture: ComponentFixture<SutType>,
{ componentProperties = {} }: Pick<RenderTemplateOptions<SutType, any>, 'componentProperties'>,
componentProperties: RenderTemplateOptions<SutType, any>['componentProperties'] = {},
) {
for (const key of Object.keys(componentProperties)) {
const descriptor = Object.getOwnPropertyDescriptor((fixture.componentInstance as any).constructor.prototype, key);
Expand All @@ -272,6 +304,24 @@ function setComponentProperties<SutType>(
return fixture;
}

function setComponentOutputs<SutType>(
fixture: ComponentFixture<SutType>,
componentOutputs: RenderTemplateOptions<SutType, any>['componentOutputs'] = {},
) {
for (const [name, value] of Object.entries(componentOutputs)) {
(fixture.componentInstance as any)[name] = value;
}
}

function setComponentInputs<SutType>(
fixture: ComponentFixture<SutType>,
componentInputs: RenderTemplateOptions<SutType>['componentInputs'] = {},
) {
for (const [name, value] of Object.entries(componentInputs)) {
fixture.componentRef.setInput(name, value);
}
}

function overrideComponentImports<SutType>(sut: Type<SutType> | string, imports: (Type<any> | any[])[] | undefined) {
if (imports) {
if (typeof sut === 'function' && ɵisStandalone(sut)) {
Expand Down
85 changes: 85 additions & 0 deletions projects/testing-library/tests/changeInputs.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?: 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: ` <div data-testid="number" [class.active]="activeField === 'number'">Number</div> `,
})
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');
});
25 changes: 22 additions & 3 deletions projects/testing-library/tests/rerender.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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();
});

0 comments on commit 881cc83

Please sign in to comment.