Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add more fine-grained control over inputs and outputs #328

Merged
merged 1 commit into from
Nov 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = [],
componentImports: componentImports,
excludeComponentDeclaration = false,
Expand Down Expand Up @@ -102,25 +104,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 @@ -176,6 +204,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 @@ -188,13 +217,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 @@ -244,7 +276,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 @@ -270,6 +302,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();
});