Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions apps/example-app/src/app/examples/24-bindings-api.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { signal, inputBinding, outputBinding, twoWayBinding } from '@angular/core';
import { render, screen } from '@testing-library/angular';
import { BindingsApiExampleComponent } from './24-bindings-api.component';

test('displays computed greeting message with input values', async () => {
await render(BindingsApiExampleComponent, {
bindings: [
inputBinding('greeting', () => 'Hello'),
inputBinding('age', () => 25),
twoWayBinding('name', signal('John')),
],
});

expect(screen.getByTestId('input-value')).toHaveTextContent('Hello John of 25 years old');
expect(screen.getByTestId('computed-value')).toHaveTextContent('Hello John of 25 years old');
expect(screen.getByTestId('current-age')).toHaveTextContent('Current age: 25');
});

test('emits submitValue output when submit button is clicked', async () => {
const submitHandler = jest.fn();
const nameSignal = signal('Alice');

await render(BindingsApiExampleComponent, {
bindings: [
inputBinding('greeting', () => 'Good morning'),
inputBinding('age', () => 28),
twoWayBinding('name', nameSignal),
outputBinding('submitValue', submitHandler),
],
});

const submitButton = screen.getByTestId('submit-button');
submitButton.click();
expect(submitHandler).toHaveBeenCalledWith('Alice');
});

test('emits ageChanged output when increment button is clicked', async () => {
const ageChangedHandler = jest.fn();

await render(BindingsApiExampleComponent, {
bindings: [
inputBinding('greeting', () => 'Hi'),
inputBinding('age', () => 20),
twoWayBinding('name', signal('Charlie')),
outputBinding('ageChanged', ageChangedHandler),
],
});

const incrementButton = screen.getByTestId('increment-button');
incrementButton.click();

expect(ageChangedHandler).toHaveBeenCalledWith(21);
});

test('updates name through two-way binding when input changes', async () => {
const nameSignal = signal('Initial Name');

await render(BindingsApiExampleComponent, {
bindings: [
inputBinding('greeting', () => 'Hello'),
inputBinding('age', () => 25),
twoWayBinding('name', nameSignal),
],
});

const nameInput = screen.getByTestId('name-input') as HTMLInputElement;

// Verify initial value
expect(nameInput.value).toBe('Initial Name');
expect(screen.getByTestId('input-value')).toHaveTextContent('Hello Initial Name of 25 years old');

// Update the signal externally
nameSignal.set('Updated Name');

// Verify the input and display update
expect(await screen.findByDisplayValue('Updated Name')).toBeInTheDocument();
expect(screen.getByTestId('input-value')).toHaveTextContent('Hello Updated Name of 25 years old');
expect(screen.getByTestId('computed-value')).toHaveTextContent('Hello Updated Name of 25 years old');
});

test('updates computed value when inputs change', async () => {
const greetingSignal = signal('Good day');
const nameSignal = signal('David');
const ageSignal = signal(35);

const { fixture } = await render(BindingsApiExampleComponent, {
bindings: [
inputBinding('greeting', greetingSignal),
inputBinding('age', ageSignal),
twoWayBinding('name', nameSignal),
],
});

// Initial state
expect(screen.getByTestId('computed-value')).toHaveTextContent('Good day David of 35 years old');

// Update greeting
greetingSignal.set('Good evening');
fixture.detectChanges();
expect(screen.getByTestId('computed-value')).toHaveTextContent('Good evening David of 35 years old');

// Update age
ageSignal.set(36);
fixture.detectChanges();
expect(screen.getByTestId('computed-value')).toHaveTextContent('Good evening David of 36 years old');

// Update name
nameSignal.set('Daniel');
fixture.detectChanges();
expect(screen.getByTestId('computed-value')).toHaveTextContent('Good evening Daniel of 36 years old');
});

test('handles multiple output emissions correctly', async () => {
const submitHandler = jest.fn();
const ageChangedHandler = jest.fn();
const nameSignal = signal('Emma');

await render(BindingsApiExampleComponent, {
bindings: [
inputBinding('greeting', () => 'Hey'),
inputBinding('age', () => 22),
twoWayBinding('name', nameSignal),
outputBinding('submitValue', submitHandler),
outputBinding('ageChanged', ageChangedHandler),
],
});

// Click submit button multiple times
const submitButton = screen.getByTestId('submit-button');
submitButton.click();
submitButton.click();

expect(submitHandler).toHaveBeenCalledTimes(2);
expect(submitHandler).toHaveBeenNthCalledWith(1, 'Emma');
expect(submitHandler).toHaveBeenNthCalledWith(2, 'Emma');

// Click increment button multiple times
const incrementButton = screen.getByTestId('increment-button');
incrementButton.click();
incrementButton.click();
incrementButton.click();

expect(ageChangedHandler).toHaveBeenCalledTimes(3);
expect(ageChangedHandler).toHaveBeenNthCalledWith(1, 23);
expect(ageChangedHandler).toHaveBeenNthCalledWith(2, 23); // Still 23 because age input doesn't change
expect(ageChangedHandler).toHaveBeenNthCalledWith(3, 23);
});
36 changes: 36 additions & 0 deletions apps/example-app/src/app/examples/24-bindings-api.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Component, computed, input, model, numberAttribute, output } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
selector: 'atl-bindings-api-example',
template: `
<div data-testid="input-value">{{ greetings() }} {{ name() }} of {{ age() }} years old</div>
<div data-testid="computed-value">{{ greetingMessage() }}</div>
<button data-testid="submit-button" (click)="submitName()">Submit</button>
<button data-testid="increment-button" (click)="incrementAge()">Increment Age</button>
<input type="text" data-testid="name-input" [(ngModel)]="name" />
<div data-testid="current-age">Current age: {{ age() }}</div>
`,
standalone: true,
imports: [FormsModule],
})
export class BindingsApiExampleComponent {
greetings = input<string>('', {
alias: 'greeting',
});
age = input.required<number, string>({ transform: numberAttribute });
name = model.required<string>();
submitValue = output<string>();
ageChanged = output<number>();

greetingMessage = computed(() => `${this.greetings()} ${this.name()} of ${this.age()} years old`);

submitName() {
this.submitValue.emit(this.name());
}

incrementAge() {
const newAge = this.age() + 1;
this.ageChanged.emit(newAge);
}
}
23 changes: 23 additions & 0 deletions projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Provider,
Signal,
InputSignalWithTransform,
Binding,
} from '@angular/core';
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
import { Routes } from '@angular/router';
Expand Down Expand Up @@ -307,6 +308,28 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
*/
on?: OutputRefKeysWithCallback<ComponentType>;

/**
* @description
* An array of bindings to apply to the component using Angular's native bindings API.
* This provides a more direct way to bind inputs and outputs compared to the `inputs` and `on` options.
*
* @default
* []
*
* @example
* import { inputBinding, outputBinding, twoWayBinding } from '@angular/core';
* import { signal } from '@angular/core';
*
* await render(AppComponent, {
* bindings: [
* inputBinding('value', () => 'test value'),
* outputBinding('click', (event) => console.log(event)),
* twoWayBinding('name', signal('initial value'))
* ]
* })
*/
bindings?: Binding[];

/**
* @description
* A collection of providers to inject dependencies of the component.
Expand Down
47 changes: 42 additions & 5 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SimpleChanges,
Type,
isStandalone,
Binding,
} from '@angular/core';
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing';
import { NavigationExtras, Router } from '@angular/router';
Expand Down Expand Up @@ -69,6 +70,7 @@ export async function render<SutType, WrapperType = SutType>(
componentOutputs = {},
inputs: newInputs = {},
on = {},
bindings = [],
componentProviders = [],
childComponentOverrides = [],
componentImports,
Expand Down Expand Up @@ -192,11 +194,37 @@ export async function render<SutType, WrapperType = SutType>(
outputs: Partial<SutType>,
subscribeTo: OutputRefKeysWithCallback<SutType>,
): Promise<ComponentFixture<SutType>> => {
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer);
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer, bindings);

// Always apply componentProperties (non-input properties)
setComponentProperties(createdFixture, properties);
setComponentInputs(createdFixture, inputs);
setComponentOutputs(createdFixture, outputs);
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);

// Angular doesn't allow mixing setInput with bindings
// So we use bindings OR traditional approach, but not both for inputs
if (bindings && bindings.length > 0) {
// When bindings are used, warn if traditional inputs/outputs are also specified
if (Object.keys(inputs).length > 0) {
console.warn(
'[@testing-library/angular]: You specified both bindings and traditional inputs. ' +
'Only bindings will be used for inputs. Use bindings for all inputs to avoid this warning.',
);
}
if (Object.keys(subscribeTo).length > 0) {
console.warn(
'[@testing-library/angular]: You specified both bindings and traditional output listeners. ' +
'Consider using outputBinding() for all outputs for consistency.',
);
}

// Only apply traditional outputs, as bindings handle inputs
setComponentOutputs(createdFixture, outputs);
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
} else {
// Use traditional approach when no bindings
setComponentInputs(createdFixture, inputs);
setComponentOutputs(createdFixture, outputs);
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
}

if (removeAngularAttributes) {
createdFixture.nativeElement.removeAttribute('ng-version');
Expand Down Expand Up @@ -335,9 +363,18 @@ export async function render<SutType, WrapperType = SutType>(
};
}

async function createComponent<SutType>(component: Type<SutType>): Promise<ComponentFixture<SutType>> {
async function createComponent<SutType>(
component: Type<SutType>,
bindings?: Binding[],
): Promise<ComponentFixture<SutType>> {
/* Make sure angular application is initialized before creating component */
await TestBed.inject(ApplicationInitStatus).donePromise;

// Use the new bindings API if available and bindings are provided
if (bindings && bindings.length > 0) {
return TestBed.createComponent(component, { bindings });
}

return TestBed.createComponent(component);
}

Expand Down
Loading
Loading