Skip to content

Commit f6d107d

Browse files
authored
feat: add support for Angular bindings API (#547)
Closes #546
1 parent 5da958b commit f6d107d

File tree

5 files changed

+389
-5
lines changed

5 files changed

+389
-5
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { signal, inputBinding, outputBinding, twoWayBinding } from '@angular/core';
2+
import { render, screen } from '@testing-library/angular';
3+
import { BindingsApiExampleComponent } from './24-bindings-api.component';
4+
5+
test('displays computed greeting message with input values', async () => {
6+
await render(BindingsApiExampleComponent, {
7+
bindings: [
8+
inputBinding('greeting', () => 'Hello'),
9+
inputBinding('age', () => 25),
10+
twoWayBinding('name', signal('John')),
11+
],
12+
});
13+
14+
expect(screen.getByTestId('input-value')).toHaveTextContent('Hello John of 25 years old');
15+
expect(screen.getByTestId('computed-value')).toHaveTextContent('Hello John of 25 years old');
16+
expect(screen.getByTestId('current-age')).toHaveTextContent('Current age: 25');
17+
});
18+
19+
test('emits submitValue output when submit button is clicked', async () => {
20+
const submitHandler = jest.fn();
21+
const nameSignal = signal('Alice');
22+
23+
await render(BindingsApiExampleComponent, {
24+
bindings: [
25+
inputBinding('greeting', () => 'Good morning'),
26+
inputBinding('age', () => 28),
27+
twoWayBinding('name', nameSignal),
28+
outputBinding('submitValue', submitHandler),
29+
],
30+
});
31+
32+
const submitButton = screen.getByTestId('submit-button');
33+
submitButton.click();
34+
expect(submitHandler).toHaveBeenCalledWith('Alice');
35+
});
36+
37+
test('emits ageChanged output when increment button is clicked', async () => {
38+
const ageChangedHandler = jest.fn();
39+
40+
await render(BindingsApiExampleComponent, {
41+
bindings: [
42+
inputBinding('greeting', () => 'Hi'),
43+
inputBinding('age', () => 20),
44+
twoWayBinding('name', signal('Charlie')),
45+
outputBinding('ageChanged', ageChangedHandler),
46+
],
47+
});
48+
49+
const incrementButton = screen.getByTestId('increment-button');
50+
incrementButton.click();
51+
52+
expect(ageChangedHandler).toHaveBeenCalledWith(21);
53+
});
54+
55+
test('updates name through two-way binding when input changes', async () => {
56+
const nameSignal = signal('Initial Name');
57+
58+
await render(BindingsApiExampleComponent, {
59+
bindings: [
60+
inputBinding('greeting', () => 'Hello'),
61+
inputBinding('age', () => 25),
62+
twoWayBinding('name', nameSignal),
63+
],
64+
});
65+
66+
const nameInput = screen.getByTestId('name-input') as HTMLInputElement;
67+
68+
// Verify initial value
69+
expect(nameInput.value).toBe('Initial Name');
70+
expect(screen.getByTestId('input-value')).toHaveTextContent('Hello Initial Name of 25 years old');
71+
72+
// Update the signal externally
73+
nameSignal.set('Updated Name');
74+
75+
// Verify the input and display update
76+
expect(await screen.findByDisplayValue('Updated Name')).toBeInTheDocument();
77+
expect(screen.getByTestId('input-value')).toHaveTextContent('Hello Updated Name of 25 years old');
78+
expect(screen.getByTestId('computed-value')).toHaveTextContent('Hello Updated Name of 25 years old');
79+
});
80+
81+
test('updates computed value when inputs change', async () => {
82+
const greetingSignal = signal('Good day');
83+
const nameSignal = signal('David');
84+
const ageSignal = signal(35);
85+
86+
const { fixture } = await render(BindingsApiExampleComponent, {
87+
bindings: [
88+
inputBinding('greeting', greetingSignal),
89+
inputBinding('age', ageSignal),
90+
twoWayBinding('name', nameSignal),
91+
],
92+
});
93+
94+
// Initial state
95+
expect(screen.getByTestId('computed-value')).toHaveTextContent('Good day David of 35 years old');
96+
97+
// Update greeting
98+
greetingSignal.set('Good evening');
99+
fixture.detectChanges();
100+
expect(screen.getByTestId('computed-value')).toHaveTextContent('Good evening David of 35 years old');
101+
102+
// Update age
103+
ageSignal.set(36);
104+
fixture.detectChanges();
105+
expect(screen.getByTestId('computed-value')).toHaveTextContent('Good evening David of 36 years old');
106+
107+
// Update name
108+
nameSignal.set('Daniel');
109+
fixture.detectChanges();
110+
expect(screen.getByTestId('computed-value')).toHaveTextContent('Good evening Daniel of 36 years old');
111+
});
112+
113+
test('handles multiple output emissions correctly', async () => {
114+
const submitHandler = jest.fn();
115+
const ageChangedHandler = jest.fn();
116+
const nameSignal = signal('Emma');
117+
118+
await render(BindingsApiExampleComponent, {
119+
bindings: [
120+
inputBinding('greeting', () => 'Hey'),
121+
inputBinding('age', () => 22),
122+
twoWayBinding('name', nameSignal),
123+
outputBinding('submitValue', submitHandler),
124+
outputBinding('ageChanged', ageChangedHandler),
125+
],
126+
});
127+
128+
// Click submit button multiple times
129+
const submitButton = screen.getByTestId('submit-button');
130+
submitButton.click();
131+
submitButton.click();
132+
133+
expect(submitHandler).toHaveBeenCalledTimes(2);
134+
expect(submitHandler).toHaveBeenNthCalledWith(1, 'Emma');
135+
expect(submitHandler).toHaveBeenNthCalledWith(2, 'Emma');
136+
137+
// Click increment button multiple times
138+
const incrementButton = screen.getByTestId('increment-button');
139+
incrementButton.click();
140+
incrementButton.click();
141+
incrementButton.click();
142+
143+
expect(ageChangedHandler).toHaveBeenCalledTimes(3);
144+
expect(ageChangedHandler).toHaveBeenNthCalledWith(1, 23);
145+
expect(ageChangedHandler).toHaveBeenNthCalledWith(2, 23); // Still 23 because age input doesn't change
146+
expect(ageChangedHandler).toHaveBeenNthCalledWith(3, 23);
147+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Component, computed, input, model, numberAttribute, output } from '@angular/core';
2+
import { FormsModule } from '@angular/forms';
3+
4+
@Component({
5+
selector: 'atl-bindings-api-example',
6+
template: `
7+
<div data-testid="input-value">{{ greetings() }} {{ name() }} of {{ age() }} years old</div>
8+
<div data-testid="computed-value">{{ greetingMessage() }}</div>
9+
<button data-testid="submit-button" (click)="submitName()">Submit</button>
10+
<button data-testid="increment-button" (click)="incrementAge()">Increment Age</button>
11+
<input type="text" data-testid="name-input" [(ngModel)]="name" />
12+
<div data-testid="current-age">Current age: {{ age() }}</div>
13+
`,
14+
standalone: true,
15+
imports: [FormsModule],
16+
})
17+
export class BindingsApiExampleComponent {
18+
greetings = input<string>('', {
19+
alias: 'greeting',
20+
});
21+
age = input.required<number, string>({ transform: numberAttribute });
22+
name = model.required<string>();
23+
submitValue = output<string>();
24+
ageChanged = output<number>();
25+
26+
greetingMessage = computed(() => `${this.greetings()} ${this.name()} of ${this.age()} years old`);
27+
28+
submitName() {
29+
this.submitValue.emit(this.name());
30+
}
31+
32+
incrementAge() {
33+
const newAge = this.age() + 1;
34+
this.ageChanged.emit(newAge);
35+
}
36+
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Provider,
88
Signal,
99
InputSignalWithTransform,
10+
Binding,
1011
} from '@angular/core';
1112
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
1213
import { Routes } from '@angular/router';
@@ -307,6 +308,28 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
307308
*/
308309
on?: OutputRefKeysWithCallback<ComponentType>;
309310

311+
/**
312+
* @description
313+
* An array of bindings to apply to the component using Angular's native bindings API.
314+
* This provides a more direct way to bind inputs and outputs compared to the `inputs` and `on` options.
315+
*
316+
* @default
317+
* []
318+
*
319+
* @example
320+
* import { inputBinding, outputBinding, twoWayBinding } from '@angular/core';
321+
* import { signal } from '@angular/core';
322+
*
323+
* await render(AppComponent, {
324+
* bindings: [
325+
* inputBinding('value', () => 'test value'),
326+
* outputBinding('click', (event) => console.log(event)),
327+
* twoWayBinding('name', signal('initial value'))
328+
* ]
329+
* })
330+
*/
331+
bindings?: Binding[];
332+
310333
/**
311334
* @description
312335
* A collection of providers to inject dependencies of the component.

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

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SimpleChanges,
1212
Type,
1313
isStandalone,
14+
Binding,
1415
} from '@angular/core';
1516
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing';
1617
import { NavigationExtras, Router } from '@angular/router';
@@ -69,6 +70,7 @@ export async function render<SutType, WrapperType = SutType>(
6970
componentOutputs = {},
7071
inputs: newInputs = {},
7172
on = {},
73+
bindings = [],
7274
componentProviders = [],
7375
childComponentOverrides = [],
7476
componentImports,
@@ -192,11 +194,37 @@ export async function render<SutType, WrapperType = SutType>(
192194
outputs: Partial<SutType>,
193195
subscribeTo: OutputRefKeysWithCallback<SutType>,
194196
): Promise<ComponentFixture<SutType>> => {
195-
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer);
197+
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer, bindings);
198+
199+
// Always apply componentProperties (non-input properties)
196200
setComponentProperties(createdFixture, properties);
197-
setComponentInputs(createdFixture, inputs);
198-
setComponentOutputs(createdFixture, outputs);
199-
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
201+
202+
// Angular doesn't allow mixing setInput with bindings
203+
// So we use bindings OR traditional approach, but not both for inputs
204+
if (bindings && bindings.length > 0) {
205+
// When bindings are used, warn if traditional inputs/outputs are also specified
206+
if (Object.keys(inputs).length > 0) {
207+
console.warn(
208+
'[@testing-library/angular]: You specified both bindings and traditional inputs. ' +
209+
'Only bindings will be used for inputs. Use bindings for all inputs to avoid this warning.',
210+
);
211+
}
212+
if (Object.keys(subscribeTo).length > 0) {
213+
console.warn(
214+
'[@testing-library/angular]: You specified both bindings and traditional output listeners. ' +
215+
'Consider using outputBinding() for all outputs for consistency.',
216+
);
217+
}
218+
219+
// Only apply traditional outputs, as bindings handle inputs
220+
setComponentOutputs(createdFixture, outputs);
221+
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
222+
} else {
223+
// Use traditional approach when no bindings
224+
setComponentInputs(createdFixture, inputs);
225+
setComponentOutputs(createdFixture, outputs);
226+
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
227+
}
200228

201229
if (removeAngularAttributes) {
202230
createdFixture.nativeElement.removeAttribute('ng-version');
@@ -335,9 +363,18 @@ export async function render<SutType, WrapperType = SutType>(
335363
};
336364
}
337365

338-
async function createComponent<SutType>(component: Type<SutType>): Promise<ComponentFixture<SutType>> {
366+
async function createComponent<SutType>(
367+
component: Type<SutType>,
368+
bindings?: Binding[],
369+
): Promise<ComponentFixture<SutType>> {
339370
/* Make sure angular application is initialized before creating component */
340371
await TestBed.inject(ApplicationInitStatus).donePromise;
372+
373+
// Use the new bindings API if available and bindings are provided
374+
if (bindings && bindings.length > 0) {
375+
return TestBed.createComponent(component, { bindings });
376+
}
377+
341378
return TestBed.createComponent(component);
342379
}
343380

0 commit comments

Comments
 (0)