diff --git a/.all-contributorsrc b/.all-contributorsrc
index c1c0709..f79ae1c 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -380,6 +380,17 @@
"code",
"test"
]
+ },
+ {
+ "login": "andreialecu",
+ "name": "Andrei Alecu",
+ "avatar_url": "https://avatars.githubusercontent.com/u/697707?v=4",
+ "profile": "https://lets.poker/",
+ "contributions": [
+ "code",
+ "ideas",
+ "doc"
+ ]
}
],
"contributorsPerLine": 7,
diff --git a/README.md b/README.md
index 883c9dc..dc11c45 100644
--- a/README.md
+++ b/README.md
@@ -100,20 +100,22 @@ counter.component.ts
@Component({
selector: 'app-counter',
template: `
+ {{ hello() }}
- Current Count: {{ counter }}
+ Current Count: {{ counter() }}
`,
})
export class CounterComponent {
- @Input() counter = 0;
+ counter = model(0);
+ hello = input('Hi', { alias: 'greeting' });
increment() {
- this.counter += 1;
+ this.counter.set(this.counter() + 1);
}
decrement() {
- this.counter -= 1;
+ this.counter.set(this.counter() - 1);
}
}
```
@@ -121,23 +123,30 @@ export class CounterComponent {
counter.component.spec.ts
```typescript
-import { render, screen, fireEvent } from '@testing-library/angular';
+import { render, screen, fireEvent, aliasedInput } from '@testing-library/angular';
import { CounterComponent } from './counter.component';
describe('Counter', () => {
- test('should render counter', async () => {
- await render(CounterComponent, { componentProperties: { counter: 5 } });
-
- expect(screen.getByText('Current Count: 5'));
+ it('should render counter', async () => {
+ await render(CounterComponent, {
+ inputs: {
+ counter: 5,
+ // aliases need to be specified this way
+ ...aliasedInput('greeting', 'Hello Alias!'),
+ },
+ });
+
+ expect(screen.getByText('Current Count: 5')).toBeVisible();
+ expect(screen.getByText('Hello Alias!')).toBeVisible();
});
- test('should increment the counter on click', async () => {
- await render(CounterComponent, { componentProperties: { counter: 5 } });
+ it('should increment the counter on click', async () => {
+ await render(CounterComponent, { inputs: { counter: 5 } });
const incrementButton = screen.getByRole('button', { name: '+' });
fireEvent.click(incrementButton);
- expect(screen.getByText('Current Count: 6'));
+ expect(screen.getByText('Current Count: 6')).toBeVisible();
});
});
```
@@ -257,6 +266,7 @@ Thanks goes to these people ([emoji key][emojis]):
 Mark Goho 🚧 📖 |
 Jan-Willem Baart 💻 ⚠️ |
 S. Mumenthaler 💻 ⚠️ |
+  Andrei Alecu 💻 🤔 📖 |
diff --git a/apps/example-app/src/app/examples/02-input-output.spec.ts b/apps/example-app/src/app/examples/02-input-output.spec.ts
index c193d3e..847f6e1 100644
--- a/apps/example-app/src/app/examples/02-input-output.spec.ts
+++ b/apps/example-app/src/app/examples/02-input-output.spec.ts
@@ -8,7 +8,63 @@ test('is possible to set input and listen for output', async () => {
const sendValue = jest.fn();
await render(InputOutputComponent, {
- componentInputs: {
+ inputs: {
+ value: 47,
+ },
+ on: {
+ sendValue,
+ },
+ });
+
+ const incrementControl = screen.getByRole('button', { name: /increment/i });
+ const sendControl = screen.getByRole('button', { name: /send/i });
+ const valueControl = screen.getByTestId('value');
+
+ expect(valueControl).toHaveTextContent('47');
+
+ await user.click(incrementControl);
+ await user.click(incrementControl);
+ await user.click(incrementControl);
+ expect(valueControl).toHaveTextContent('50');
+
+ await user.click(sendControl);
+ expect(sendValue).toHaveBeenCalledTimes(1);
+ expect(sendValue).toHaveBeenCalledWith(50);
+});
+
+test.skip('is possible to set input and listen for output with the template syntax', async () => {
+ const user = userEvent.setup();
+ const sendSpy = jest.fn();
+
+ await render('', {
+ imports: [InputOutputComponent],
+ on: {
+ sendValue: sendSpy,
+ },
+ });
+
+ const incrementControl = screen.getByRole('button', { name: /increment/i });
+ const sendControl = screen.getByRole('button', { name: /send/i });
+ const valueControl = screen.getByTestId('value');
+
+ expect(valueControl).toHaveTextContent('47');
+
+ await user.click(incrementControl);
+ await user.click(incrementControl);
+ await user.click(incrementControl);
+ expect(valueControl).toHaveTextContent('50');
+
+ await user.click(sendControl);
+ expect(sendSpy).toHaveBeenCalledTimes(1);
+ expect(sendSpy).toHaveBeenCalledWith(50);
+});
+
+test('is possible to set input and listen for output (deprecated)', async () => {
+ const user = userEvent.setup();
+ const sendValue = jest.fn();
+
+ await render(InputOutputComponent, {
+ inputs: {
value: 47,
},
componentOutputs: {
@@ -34,7 +90,7 @@ test('is possible to set input and listen for output', async () => {
expect(sendValue).toHaveBeenCalledWith(50);
});
-test('is possible to set input and listen for output with the template syntax', async () => {
+test('is possible to set input and listen for output with the template syntax (deprecated)', async () => {
const user = userEvent.setup();
const sendSpy = jest.fn();
diff --git a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts
index 113d330..cb22ba6 100644
--- a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts
+++ b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts
@@ -1,11 +1,11 @@
-import { render, screen, within } from '@testing-library/angular';
+import { aliasedInput, render, screen, within } from '@testing-library/angular';
import { SignalInputComponent } from './22-signal-inputs.component';
import userEvent from '@testing-library/user-event';
test('works with signal inputs', async () => {
await render(SignalInputComponent, {
- componentInputs: {
- greeting: 'Hello',
+ inputs: {
+ ...aliasedInput('greeting', 'Hello'),
name: 'world',
},
});
@@ -16,8 +16,8 @@ test('works with signal inputs', async () => {
test('works with computed', async () => {
await render(SignalInputComponent, {
- componentInputs: {
- greeting: 'Hello',
+ inputs: {
+ ...aliasedInput('greeting', 'Hello'),
name: 'world',
},
});
@@ -28,8 +28,8 @@ test('works with computed', async () => {
test('can update signal inputs', async () => {
const { fixture } = await render(SignalInputComponent, {
- componentInputs: {
- greeting: 'Hello',
+ inputs: {
+ ...aliasedInput('greeting', 'Hello'),
name: 'world',
},
});
@@ -51,12 +51,12 @@ test('can update signal inputs', async () => {
test('output emits a value', async () => {
const submitFn = jest.fn();
await render(SignalInputComponent, {
- componentInputs: {
- greeting: 'Hello',
+ inputs: {
+ ...aliasedInput('greeting', 'Hello'),
name: 'world',
},
- componentOutputs: {
- submit: { emit: submitFn } as any,
+ on: {
+ submit: submitFn,
},
});
@@ -67,8 +67,8 @@ test('output emits a value', async () => {
test('model update also updates the template', async () => {
const { fixture } = await render(SignalInputComponent, {
- componentInputs: {
- greeting: 'Hello',
+ inputs: {
+ ...aliasedInput('greeting', 'Hello'),
name: 'initial',
},
});
@@ -97,8 +97,8 @@ test('model update also updates the template', async () => {
test('works with signal inputs, computed values, and rerenders', async () => {
const view = await render(SignalInputComponent, {
- componentInputs: {
- greeting: 'Hello',
+ inputs: {
+ ...aliasedInput('greeting', 'Hello'),
name: 'world',
},
});
@@ -110,8 +110,8 @@ test('works with signal inputs, computed values, and rerenders', async () => {
expect(computedValue.getByText(/hello world/i)).toBeInTheDocument();
await view.rerender({
- componentInputs: {
- greeting: 'bye',
+ inputs: {
+ ...aliasedInput('greeting', 'bye'),
name: 'test',
},
});
diff --git a/projects/testing-library/schematics/ng-add/index.ts b/projects/testing-library/schematics/ng-add/index.ts
index 24a0a3d..d961e15 100644
--- a/projects/testing-library/schematics/ng-add/index.ts
+++ b/projects/testing-library/schematics/ng-add/index.ts
@@ -1,27 +1,44 @@
-import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
+import { chain, noop, Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import {
addPackageJsonDependency,
getPackageJsonDependency,
NodeDependencyType,
} from '@schematics/angular/utility/dependencies';
+import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
+import { Schema } from './schema';
-const dtl = '@testing-library/dom';
+export default function ({ installJestDom, installUserEvent }: Schema): Rule {
+ return () => {
+ return chain([
+ addDependency('@testing-library/dom', '^10.0.0', NodeDependencyType.Dev),
+ installJestDom ? addDependency('@testing-library/jest-dom', '^6.4.8', NodeDependencyType.Dev) : noop(),
+ installUserEvent ? addDependency('@testing-library/user-event', '^14.5.2', NodeDependencyType.Dev) : noop(),
+ installDependencies(),
+ ]);
+ };
+}
-export default function (): Rule {
+function addDependency(packageName: string, version: string, dependencyType: NodeDependencyType) {
return (tree: Tree, context: SchematicContext) => {
- const dtlDep = getPackageJsonDependency(tree, dtl);
+ const dtlDep = getPackageJsonDependency(tree, packageName);
if (dtlDep) {
- context.logger.info(`Skipping installation of '@testing-library/dom' because it's already installed.`);
+ context.logger.info(`Skipping installation of '${packageName}' because it's already installed.`);
} else {
- context.logger.info(`Adding '@testing-library/dom' as a dev dependency.`);
- addPackageJsonDependency(tree, { name: dtl, type: NodeDependencyType.Dev, overwrite: false, version: '^10.0.0' });
+ context.logger.info(`Adding '${packageName}' as a dev dependency.`);
+ addPackageJsonDependency(tree, { name: packageName, type: dependencyType, overwrite: false, version });
}
+ return tree;
+ };
+}
+
+export function installDependencies(packageManager = 'npm') {
+ return (_tree: Tree, context: SchematicContext) => {
+ context.addTask(new NodePackageInstallTask({ packageManager }));
+
context.logger.info(
`Correctly installed @testing-library/angular.
See our docs at https://testing-library.com/docs/angular-testing-library/intro/ to get started.`,
);
-
- return tree;
};
}
diff --git a/projects/testing-library/schematics/ng-add/schema.json b/projects/testing-library/schematics/ng-add/schema.json
index 3f35a9a..30cc97d 100644
--- a/projects/testing-library/schematics/ng-add/schema.json
+++ b/projects/testing-library/schematics/ng-add/schema.json
@@ -3,6 +3,28 @@
"$id": "SchematicsTestingLibraryAngular",
"title": "testing-library-angular",
"type": "object",
- "properties": {},
+ "properties": {
+ "installJestDom": {
+ "type": "boolean",
+ "description": "Install jest-dom as a dependency.",
+ "$default": {
+ "$source": "argv",
+ "index": 0
+ },
+ "default": false,
+ "x-prompt": "Would you like to install jest-dom?"
+ },
+ "installUserEvent": {
+ "type": "boolean",
+ "description": "Install user-event as a dependency.",
+ "$default": {
+ "$source": "argv",
+ "index": 1
+ },
+ "default": false,
+ "x-prompt": "Would you like to install user-event?"
+ }
+ },
+ "additionalProperties": false,
"required": []
}
diff --git a/projects/testing-library/schematics/ng-add/schema.ts b/projects/testing-library/schematics/ng-add/schema.ts
index 02bea61..dc14633 100644
--- a/projects/testing-library/schematics/ng-add/schema.ts
+++ b/projects/testing-library/schematics/ng-add/schema.ts
@@ -1,2 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-empty-interface
-export interface Schema {}
+export interface Schema {
+ installJestDom: boolean;
+ installUserEvent: boolean;
+}
diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts
index 3cf053a..8e0e57f 100644
--- a/projects/testing-library/src/lib/models.ts
+++ b/projects/testing-library/src/lib/models.ts
@@ -1,4 +1,4 @@
-import { Type, DebugElement, OutputRef, EventEmitter } from '@angular/core';
+import { Type, DebugElement, OutputRef, EventEmitter, Signal } from '@angular/core';
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
import { Routes } from '@angular/router';
import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom';
@@ -68,7 +68,7 @@ export interface RenderResult extend
rerender: (
properties?: Pick<
RenderTemplateOptions,
- 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
+ 'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
> & { partialUpdate?: boolean },
) => Promise;
/**
@@ -78,6 +78,27 @@ export interface RenderResult extend
renderDeferBlock: (deferBlockState: DeferBlockState, deferBlockIndex?: number) => Promise;
}
+declare const ALIASED_INPUT_BRAND: unique symbol;
+export type AliasedInput = T & {
+ [ALIASED_INPUT_BRAND]: T;
+};
+export type AliasedInputs = Record>;
+
+export type ComponentInput =
+ | {
+ [P in keyof T]?: T[P] extends Signal ? U : T[P];
+ }
+ | AliasedInputs;
+
+/**
+ * @description
+ * Creates an aliased input branded type with a value
+ *
+ */
+export function aliasedInput(alias: TAlias, value: T): Record> {
+ return { [alias]: value } as Record>;
+}
+
export interface RenderComponentOptions {
/**
* @description
@@ -199,6 +220,7 @@ export interface RenderComponentOptions | { [alias: string]: unknown };
+
+ /**
+ * @description
+ * An object to set `@Input` or `input()` properties of the component
+ *
+ * @default
+ * {}
+ *
+ * @example
+ * await render(AppComponent, {
+ * inputs: {
+ * counterValue: 10,
+ * // explicitly define aliases this way:
+ * ...aliasedInput('someAlias', 'someValue')
+ * })
+ */
+ inputs?: ComponentInput;
+
/**
* @description
* An object to set `@Output` properties of the component
diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts
index 0ceda24..fbe94f2 100644
--- a/projects/testing-library/src/lib/testing-library.ts
+++ b/projects/testing-library/src/lib/testing-library.ts
@@ -67,6 +67,7 @@ export async function render(
componentProperties = {},
componentInputs = {},
componentOutputs = {},
+ inputs: newInputs = {},
on = {},
componentProviders = [],
childComponentOverrides = [],
@@ -176,8 +177,10 @@ export async function render(
let detectChanges: () => void;
+ const allInputs = { ...componentInputs, ...newInputs };
+
let renderedPropKeys = Object.keys(componentProperties);
- let renderedInputKeys = Object.keys(componentInputs);
+ let renderedInputKeys = Object.keys(allInputs);
let renderedOutputKeys = Object.keys(componentOutputs);
let subscribedOutputs: SubscribedOutput[] = [];
@@ -224,7 +227,7 @@ export async function render(
return createdFixture;
};
- const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, on);
+ const fixture = await renderFixture(componentProperties, allInputs, componentOutputs, on);
if (deferBlockStates) {
if (Array.isArray(deferBlockStates)) {
@@ -239,10 +242,10 @@ export async function render(
const rerender = async (
properties?: Pick<
RenderTemplateOptions,
- 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
+ 'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
> & { partialUpdate?: boolean },
) => {
- const newComponentInputs = properties?.componentInputs ?? {};
+ const newComponentInputs = { ...properties?.componentInputs, ...properties?.inputs };
const changesInComponentInput = update(
fixture,
renderedInputKeys,
diff --git a/projects/testing-library/tests/integrations/ng-mocks.spec.ts b/projects/testing-library/tests/integrations/ng-mocks.spec.ts
index 6358485..8886fb3 100644
--- a/projects/testing-library/tests/integrations/ng-mocks.spec.ts
+++ b/projects/testing-library/tests/integrations/ng-mocks.spec.ts
@@ -8,7 +8,7 @@ import { NgIf } from '@angular/common';
test('sends the correct value to the child input', async () => {
const utils = await render(TargetComponent, {
imports: [MockComponent(ChildComponent)],
- componentInputs: { value: 'foo' },
+ inputs: { value: 'foo' },
});
const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent));
@@ -21,7 +21,7 @@ test('sends the correct value to the child input', async () => {
test('sends the correct value to the child input 2', async () => {
const utils = await render(TargetComponent, {
imports: [MockComponent(ChildComponent)],
- componentInputs: { value: 'bar' },
+ inputs: { value: 'bar' },
});
const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent));
diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts
index b73c9c7..59e0f75 100644
--- a/projects/testing-library/tests/render.spec.ts
+++ b/projects/testing-library/tests/render.spec.ts
@@ -13,11 +13,13 @@ import {
ElementRef,
inject,
output,
+ input,
+ model,
} from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TestBed } from '@angular/core/testing';
-import { render, fireEvent, screen, OutputRefKeysWithCallback } from '../src/public_api';
+import { render, fireEvent, screen, OutputRefKeysWithCallback, aliasedInput } from '../src/public_api';
import { ActivatedRoute, Resolve, RouterModule } from '@angular/router';
import { fromEvent, map } from 'rxjs';
import { AsyncPipe, NgIf } from '@angular/common';
@@ -533,3 +535,117 @@ describe('configureTestBed', () => {
expect(configureTestBedFn).toHaveBeenCalledTimes(1);
});
});
+
+describe('inputs and signals', () => {
+ @Component({
+ selector: 'atl-fixture',
+ template: `{{ myName() }} {{ myJob() }}`,
+ })
+ class InputComponent {
+ myName = input('foo');
+
+ myJob = input('bar', { alias: 'job' });
+ }
+
+ it('should set the input component', async () => {
+ await render(InputComponent, {
+ inputs: {
+ myName: 'Bob',
+ ...aliasedInput('job', 'Builder'),
+ },
+ });
+
+ expect(screen.getByText('Bob')).toBeInTheDocument();
+ expect(screen.getByText('Builder')).toBeInTheDocument();
+ });
+
+ it('should typecheck correctly', async () => {
+ // we only want to check the types here
+ // so we are purposely not calling render
+
+ const typeTests = [
+ async () => {
+ // OK:
+ await render(InputComponent, {
+ inputs: {
+ myName: 'OK',
+ },
+ });
+ },
+ async () => {
+ // @ts-expect-error - myName is a string
+ await render(InputComponent, {
+ inputs: {
+ myName: 123,
+ },
+ });
+ },
+ async () => {
+ // OK:
+ await render(InputComponent, {
+ inputs: {
+ ...aliasedInput('job', 'OK'),
+ },
+ });
+ },
+ async () => {
+ // @ts-expect-error - job is not using aliasedInput
+ await render(InputComponent, {
+ inputs: {
+ job: 'not used with aliasedInput',
+ },
+ });
+ },
+ ];
+
+ // add a statement so the test succeeds
+ expect(typeTests).toBeTruthy();
+ });
+});
+
+describe('README examples', () => {
+ describe('Counter', () => {
+ @Component({
+ selector: 'atl-counter',
+ template: `
+ {{ hello() }}
+
+ Current Count: {{ counter() }}
+
+ `,
+ })
+ class CounterComponent {
+ counter = model(0);
+ hello = input('Hi', { alias: 'greeting' });
+
+ increment() {
+ this.counter.set(this.counter() + 1);
+ }
+
+ decrement() {
+ this.counter.set(this.counter() - 1);
+ }
+ }
+
+ it('should render counter', async () => {
+ await render(CounterComponent, {
+ inputs: {
+ counter: 5,
+ ...aliasedInput('greeting', 'Hello Alias!'),
+ },
+ });
+
+ expect(screen.getByText('Current Count: 5')).toBeVisible();
+ expect(screen.getByText('Hello Alias!')).toBeVisible();
+ });
+
+ it('should increment the counter on click', async () => {
+ await render(CounterComponent, { inputs: { counter: 5 } });
+
+ const incrementButton = screen.getByRole('button', { name: '+' });
+ fireEvent.click(incrementButton);
+
+ expect(screen.getByText('Current Count: 6')).toBeVisible();
+ });
+ });
+});
diff --git a/projects/testing-library/tests/rerender.spec.ts b/projects/testing-library/tests/rerender.spec.ts
index 571d642..04b8185 100644
--- a/projects/testing-library/tests/rerender.spec.ts
+++ b/projects/testing-library/tests/rerender.spec.ts
@@ -43,7 +43,7 @@ test('rerenders the component with updated inputs', async () => {
expect(screen.getByText('Sarah')).toBeInTheDocument();
const firstName = 'Mark';
- await rerender({ componentInputs: { firstName } });
+ await rerender({ inputs: { firstName } });
expect(screen.getByText(firstName)).toBeInTheDocument();
});
@@ -52,7 +52,7 @@ test('rerenders the component with updated inputs and resets other props', async
const firstName = 'Mark';
const lastName = 'Peeters';
const { rerender } = await render(FixtureComponent, {
- componentInputs: {
+ inputs: {
firstName,
lastName,
},
@@ -61,7 +61,7 @@ test('rerenders the component with updated inputs and resets other props', async
expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument();
const firstName2 = 'Chris';
- await rerender({ componentInputs: { firstName: firstName2 } });
+ await rerender({ inputs: { firstName: firstName2 } });
expect(screen.getByText(firstName2)).toBeInTheDocument();
expect(screen.queryByText(firstName)).not.toBeInTheDocument();
@@ -87,7 +87,7 @@ test('rerenders the component with updated inputs and keeps other props when par
const firstName = 'Mark';
const lastName = 'Peeters';
const { rerender } = await render(FixtureComponent, {
- componentInputs: {
+ inputs: {
firstName,
lastName,
},
@@ -96,7 +96,7 @@ test('rerenders the component with updated inputs and keeps other props when par
expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument();
const firstName2 = 'Chris';
- await rerender({ componentInputs: { firstName: firstName2 }, partialUpdate: true });
+ await rerender({ inputs: { firstName: firstName2 }, partialUpdate: true });
expect(screen.queryByText(firstName)).not.toBeInTheDocument();
expect(screen.getByText(`${firstName2} ${lastName}`)).toBeInTheDocument();
@@ -181,7 +181,7 @@ test('change detection gets not called if `detectChangesOnRender` is set to fals
expect(screen.getByText('Sarah')).toBeInTheDocument();
const firstName = 'Mark';
- await rerender({ componentInputs: { firstName }, detectChangesOnRender: false });
+ await rerender({ inputs: { firstName }, detectChangesOnRender: false });
expect(screen.getByText('Sarah')).toBeInTheDocument();
expect(screen.queryByText(firstName)).not.toBeInTheDocument();