diff --git a/README.md b/README.md index 883c9dc..22f42f6 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(); }); }); ``` 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 abc0066..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,7 @@ test('is possible to set input and listen for output', async () => { const sendValue = jest.fn(); await render(InputOutputComponent, { - componentInputs: { + inputs: { value: 47, }, on: { @@ -64,7 +64,7 @@ test('is possible to set input and listen for output (deprecated)', async () => const sendValue = jest.fn(); await render(InputOutputComponent, { - componentInputs: { + inputs: { value: 47, }, componentOutputs: { 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 a05ea5b..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,8 +51,8 @@ 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', }, on: { @@ -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/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();