Skip to content

Commit 3095737

Browse files
authored
feat: add strongly typed inputs (#473)
Closes #464 Closes #474
1 parent 40fe4ea commit 3095737

File tree

8 files changed

+212
-44
lines changed

8 files changed

+212
-44
lines changed

README.md

+21-12
Original file line numberDiff line numberDiff line change
@@ -100,44 +100,53 @@ counter.component.ts
100100
@Component({
101101
selector: 'app-counter',
102102
template: `
103+
<span>{{ hello() }}</span>
103104
<button (click)="decrement()">-</button>
104-
<span>Current Count: {{ counter }}</span>
105+
<span>Current Count: {{ counter() }}</span>
105106
<button (click)="increment()">+</button>
106107
`,
107108
})
108109
export class CounterComponent {
109-
@Input() counter = 0;
110+
counter = model(0);
111+
hello = input('Hi', { alias: 'greeting' });
110112

111113
increment() {
112-
this.counter += 1;
114+
this.counter.set(this.counter() + 1);
113115
}
114116

115117
decrement() {
116-
this.counter -= 1;
118+
this.counter.set(this.counter() - 1);
117119
}
118120
}
119121
```
120122

121123
counter.component.spec.ts
122124

123125
```typescript
124-
import { render, screen, fireEvent } from '@testing-library/angular';
126+
import { render, screen, fireEvent, aliasedInput } from '@testing-library/angular';
125127
import { CounterComponent } from './counter.component';
126128

127129
describe('Counter', () => {
128-
test('should render counter', async () => {
129-
await render(CounterComponent, { componentProperties: { counter: 5 } });
130-
131-
expect(screen.getByText('Current Count: 5'));
130+
it('should render counter', async () => {
131+
await render(CounterComponent, {
132+
inputs: {
133+
counter: 5,
134+
// aliases need to be specified this way
135+
...aliasedInput('greeting', 'Hello Alias!'),
136+
},
137+
});
138+
139+
expect(screen.getByText('Current Count: 5')).toBeVisible();
140+
expect(screen.getByText('Hello Alias!')).toBeVisible();
132141
});
133142

134-
test('should increment the counter on click', async () => {
135-
await render(CounterComponent, { componentProperties: { counter: 5 } });
143+
it('should increment the counter on click', async () => {
144+
await render(CounterComponent, { inputs: { counter: 5 } });
136145

137146
const incrementButton = screen.getByRole('button', { name: '+' });
138147
fireEvent.click(incrementButton);
139148

140-
expect(screen.getByText('Current Count: 6'));
149+
expect(screen.getByText('Current Count: 6')).toBeVisible();
141150
});
142151
});
143152
```

apps/example-app/src/app/examples/02-input-output.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ test('is possible to set input and listen for output', async () => {
88
const sendValue = jest.fn();
99

1010
await render(InputOutputComponent, {
11-
componentInputs: {
11+
inputs: {
1212
value: 47,
1313
},
1414
on: {
@@ -64,7 +64,7 @@ test('is possible to set input and listen for output (deprecated)', async () =>
6464
const sendValue = jest.fn();
6565

6666
await render(InputOutputComponent, {
67-
componentInputs: {
67+
inputs: {
6868
value: 47,
6969
},
7070
componentOutputs: {

apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts

+15-15
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { render, screen, within } from '@testing-library/angular';
1+
import { aliasedInput, render, screen, within } from '@testing-library/angular';
22
import { SignalInputComponent } from './22-signal-inputs.component';
33
import userEvent from '@testing-library/user-event';
44

55
test('works with signal inputs', async () => {
66
await render(SignalInputComponent, {
7-
componentInputs: {
8-
greeting: 'Hello',
7+
inputs: {
8+
...aliasedInput('greeting', 'Hello'),
99
name: 'world',
1010
},
1111
});
@@ -16,8 +16,8 @@ test('works with signal inputs', async () => {
1616

1717
test('works with computed', async () => {
1818
await render(SignalInputComponent, {
19-
componentInputs: {
20-
greeting: 'Hello',
19+
inputs: {
20+
...aliasedInput('greeting', 'Hello'),
2121
name: 'world',
2222
},
2323
});
@@ -28,8 +28,8 @@ test('works with computed', async () => {
2828

2929
test('can update signal inputs', async () => {
3030
const { fixture } = await render(SignalInputComponent, {
31-
componentInputs: {
32-
greeting: 'Hello',
31+
inputs: {
32+
...aliasedInput('greeting', 'Hello'),
3333
name: 'world',
3434
},
3535
});
@@ -51,8 +51,8 @@ test('can update signal inputs', async () => {
5151
test('output emits a value', async () => {
5252
const submitFn = jest.fn();
5353
await render(SignalInputComponent, {
54-
componentInputs: {
55-
greeting: 'Hello',
54+
inputs: {
55+
...aliasedInput('greeting', 'Hello'),
5656
name: 'world',
5757
},
5858
on: {
@@ -67,8 +67,8 @@ test('output emits a value', async () => {
6767

6868
test('model update also updates the template', async () => {
6969
const { fixture } = await render(SignalInputComponent, {
70-
componentInputs: {
71-
greeting: 'Hello',
70+
inputs: {
71+
...aliasedInput('greeting', 'Hello'),
7272
name: 'initial',
7373
},
7474
});
@@ -97,8 +97,8 @@ test('model update also updates the template', async () => {
9797

9898
test('works with signal inputs, computed values, and rerenders', async () => {
9999
const view = await render(SignalInputComponent, {
100-
componentInputs: {
101-
greeting: 'Hello',
100+
inputs: {
101+
...aliasedInput('greeting', 'Hello'),
102102
name: 'world',
103103
},
104104
});
@@ -110,8 +110,8 @@ test('works with signal inputs, computed values, and rerenders', async () => {
110110
expect(computedValue.getByText(/hello world/i)).toBeInTheDocument();
111111

112112
await view.rerender({
113-
componentInputs: {
114-
greeting: 'bye',
113+
inputs: {
114+
...aliasedInput('greeting', 'bye'),
115115
name: 'test',
116116
},
117117
});

projects/testing-library/src/lib/models.ts

+42-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Type, DebugElement, OutputRef, EventEmitter } from '@angular/core';
1+
import { Type, DebugElement, OutputRef, EventEmitter, Signal } from '@angular/core';
22
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
33
import { Routes } from '@angular/router';
44
import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom';
@@ -68,7 +68,7 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
6868
rerender: (
6969
properties?: Pick<
7070
RenderTemplateOptions<ComponentType>,
71-
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
71+
'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
7272
> & { partialUpdate?: boolean },
7373
) => Promise<void>;
7474
/**
@@ -78,6 +78,27 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
7878
renderDeferBlock: (deferBlockState: DeferBlockState, deferBlockIndex?: number) => Promise<void>;
7979
}
8080

81+
declare const ALIASED_INPUT_BRAND: unique symbol;
82+
export type AliasedInput<T> = T & {
83+
[ALIASED_INPUT_BRAND]: T;
84+
};
85+
export type AliasedInputs = Record<string, AliasedInput<unknown>>;
86+
87+
export type ComponentInput<T> =
88+
| {
89+
[P in keyof T]?: T[P] extends Signal<infer U> ? U : T[P];
90+
}
91+
| AliasedInputs;
92+
93+
/**
94+
* @description
95+
* Creates an aliased input branded type with a value
96+
*
97+
*/
98+
export function aliasedInput<TAlias extends string, T>(alias: TAlias, value: T): Record<TAlias, AliasedInput<T>> {
99+
return { [alias]: value } as Record<TAlias, AliasedInput<T>>;
100+
}
101+
81102
export interface RenderComponentOptions<ComponentType, Q extends Queries = typeof queries> {
82103
/**
83104
* @description
@@ -199,6 +220,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
199220
* @description
200221
* An object to set `@Input` properties of the component
201222
*
223+
* @deprecated use the `inputs` option instead. When you need to use aliases, use the `aliasedInput(...)` helper function.
202224
* @default
203225
* {}
204226
*
@@ -210,6 +232,24 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
210232
* })
211233
*/
212234
componentInputs?: Partial<ComponentType> | { [alias: string]: unknown };
235+
236+
/**
237+
* @description
238+
* An object to set `@Input` or `input()` properties of the component
239+
*
240+
* @default
241+
* {}
242+
*
243+
* @example
244+
* await render(AppComponent, {
245+
* inputs: {
246+
* counterValue: 10,
247+
* // explicitly define aliases this way:
248+
* ...aliasedInput('someAlias', 'someValue')
249+
* })
250+
*/
251+
inputs?: ComponentInput<ComponentType>;
252+
213253
/**
214254
* @description
215255
* An object to set `@Output` properties of the component

projects/testing-library/src/lib/testing-library.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export async function render<SutType, WrapperType = SutType>(
6767
componentProperties = {},
6868
componentInputs = {},
6969
componentOutputs = {},
70+
inputs: newInputs = {},
7071
on = {},
7172
componentProviders = [],
7273
childComponentOverrides = [],
@@ -176,8 +177,10 @@ export async function render<SutType, WrapperType = SutType>(
176177

177178
let detectChanges: () => void;
178179

180+
const allInputs = { ...componentInputs, ...newInputs };
181+
179182
let renderedPropKeys = Object.keys(componentProperties);
180-
let renderedInputKeys = Object.keys(componentInputs);
183+
let renderedInputKeys = Object.keys(allInputs);
181184
let renderedOutputKeys = Object.keys(componentOutputs);
182185
let subscribedOutputs: SubscribedOutput<SutType>[] = [];
183186

@@ -224,7 +227,7 @@ export async function render<SutType, WrapperType = SutType>(
224227
return createdFixture;
225228
};
226229

227-
const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, on);
230+
const fixture = await renderFixture(componentProperties, allInputs, componentOutputs, on);
228231

229232
if (deferBlockStates) {
230233
if (Array.isArray(deferBlockStates)) {
@@ -239,10 +242,10 @@ export async function render<SutType, WrapperType = SutType>(
239242
const rerender = async (
240243
properties?: Pick<
241244
RenderTemplateOptions<SutType>,
242-
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
245+
'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
243246
> & { partialUpdate?: boolean },
244247
) => {
245-
const newComponentInputs = properties?.componentInputs ?? {};
248+
const newComponentInputs = { ...properties?.componentInputs, ...properties?.inputs };
246249
const changesInComponentInput = update(
247250
fixture,
248251
renderedInputKeys,

projects/testing-library/tests/integrations/ng-mocks.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { NgIf } from '@angular/common';
88
test('sends the correct value to the child input', async () => {
99
const utils = await render(TargetComponent, {
1010
imports: [MockComponent(ChildComponent)],
11-
componentInputs: { value: 'foo' },
11+
inputs: { value: 'foo' },
1212
});
1313

1414
const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent));
@@ -21,7 +21,7 @@ test('sends the correct value to the child input', async () => {
2121
test('sends the correct value to the child input 2', async () => {
2222
const utils = await render(TargetComponent, {
2323
imports: [MockComponent(ChildComponent)],
24-
componentInputs: { value: 'bar' },
24+
inputs: { value: 'bar' },
2525
});
2626

2727
const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent));

0 commit comments

Comments
 (0)