Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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)

AltStyle によって変換されたページ (->オリジナル) /