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
Mark Goho

🚧 📖 Jan-Willem Baart
Jan-Willem Baart

💻 ⚠️ S. Mumenthaler
S. Mumenthaler

💻 ⚠️ + Andrei Alecu
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();