Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,65 @@ describe('Counter', () => {
});
```

### Angular Bindings API

Angular Testing Library also supports Angular's native bindings API, which provides a more direct way to bind inputs and outputs:

```typescript
import { render, screen } from '@testing-library/angular';
import { inputBinding, outputBinding, twoWayBinding, signal } from '@angular/core';
import { CounterComponent } from './counter.component';

describe('Counter with Bindings API', () => {
it('should render counter using bindings', async () => {
await render(CounterComponent, {
bindings: [inputBinding('counter', () => 5), inputBinding('greeting', () => 'Hello Bindings!')],
});

expect(screen.getByText('Current Count: 5')).toBeVisible();
expect(screen.getByText('Hello Bindings!')).toBeVisible();
});

it('should handle outputs with bindings', async () => {
const clickHandler = jest.fn();

await render(CounterComponent, {
bindings: [inputBinding('counter', () => 0), outputBinding('counterChange', clickHandler)],
});

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

expect(clickHandler).toHaveBeenCalledWith(1);
});

it('should handle two-way binding with signals', async () => {
const counterSignal = signal(0);

await render(CounterComponent, {
bindings: [twoWayBinding('counter', counterSignal)],
});

expect(screen.getByText('Current Count: 0')).toBeVisible();

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

// Two-way binding updates the external signal
expect(counterSignal()).toBe(1);
expect(screen.getByText('Current Count: 1')).toBeVisible();
});
});
```

The new bindings API provides several benefits:

- **Native Angular Integration**: Uses Angular's official bindings API
- **Better Performance**: Bindings are handled natively by Angular
- **Improved Type Safety**: Leverages Angular's built-in type checking

Both approaches are supported and can be used interchangeably based on your preference and Angular version.

[See more examples](https://github.com/testing-library/angular-testing-library/tree/main/apps/example-app/src/app/examples)

## Installation
Expand Down
36 changes: 36 additions & 0 deletions apps/example-app/src/app/examples/23-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
48 changes: 43 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,38 @@ 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(
'ATL: You specified both bindings and traditional inputs. ' +
'Angular does not allow mixing setInput() with inputBinding(). ' +
'Only bindings will be used for inputs. Use bindings for all inputs to avoid this warning.',
);
}
if (Object.keys(subscribeTo).length > 0) {
console.warn(
'ATL: 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 +364,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
142 changes: 142 additions & 0 deletions projects/testing-library/tests/bindings-support.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Component, input, output, inputBinding, outputBinding, twoWayBinding, signal, model } from '@angular/core';
import { render, screen, aliasedInput } from '../src/public_api';

describe('ATL Bindings API Support', () => {
@Component({
selector: 'atl-bindings-test',
template: `
<div data-testid="value">{{ value() }}</div>
<div data-testid="greeting">{{ greeting() }}</div>
<button data-testid="emit-button" (click)="clicked.emit('clicked: ' + value())">Click me</button>
`,
standalone: true,
})
class BindingsTestComponent {
value = input<string>('default');
greeting = input<string>('hello', { alias: 'greet' });
clicked = output<string>();
}

@Component({
selector: 'atl-two-way-test',
template: `
<div data-testid="name-display">{{ name() }}</div>
<input data-testid="name-input" [value]="name()" (input)="name.set($any($event.target).value)" />
<button data-testid="update-button" (click)="updateName()">Update</button>
`,
standalone: true,
})
class TwoWayBindingTestComponent {
name = model<string>('default');

updateName() {
this.name.set('updated from component');
}
}

it('should support inputBinding for regular inputs', async () => {
await render(BindingsTestComponent, {
bindings: [inputBinding('value', () => 'test-value'), inputBinding('greet', () => 'hi there')],
});

expect(screen.getByTestId('value')).toHaveTextContent('test-value');
expect(screen.getByTestId('greeting')).toHaveTextContent('hi there');
});

it('should support outputBinding for outputs', async () => {
const clickHandler = jest.fn();

await render(BindingsTestComponent, {
bindings: [inputBinding('value', () => 'bound-value'), outputBinding('clicked', clickHandler)],
});

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

expect(clickHandler).toHaveBeenCalledWith('clicked: bound-value');
});

it('should support inputBinding with writable signal for re-rendering scenario', async () => {
const valueSignal = signal('initial-value');

await render(BindingsTestComponent, {
bindings: [inputBinding('value', valueSignal), inputBinding('greet', () => 'hi there')],
});

expect(screen.getByTestId('value')).toHaveTextContent('initial-value');
expect(screen.getByTestId('greeting')).toHaveTextContent('hi there');

// Update the signal and verify it reflects in the component
valueSignal.set('updated-value');

// The binding should automatically update the component
expect(await screen.findByText('updated-value')).toBeInTheDocument();
});

it('should support twoWayBinding for model signals', async () => {
const nameSignal = signal('initial name');

await render(TwoWayBindingTestComponent, {
bindings: [twoWayBinding('name', nameSignal)],
});

// Verify initial value
expect(screen.getByTestId('name-display')).toHaveTextContent('initial name');
expect(screen.getByTestId('name-input')).toHaveValue('initial name');

// Update from outside (signal change)
nameSignal.set('updated from signal');
expect(await screen.findByDisplayValue('updated from signal')).toBeInTheDocument();
expect(screen.getByTestId('name-display')).toHaveTextContent('updated from signal');

// Update from component - let's trigger change detection after the click
const updateButton = screen.getByTestId('update-button');
updateButton.click();

// Give Angular a chance to process the update and check both the signal and display
// The twoWayBinding should update the external signal
expect(await screen.findByText('updated from component')).toBeInTheDocument();
expect(nameSignal()).toBe('updated from component');
});

it('should warn when mixing bindings with traditional inputs but still work', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const clickHandler = jest.fn();
const bindingClickHandler = jest.fn();

await render(BindingsTestComponent, {
bindings: [inputBinding('value', () => 'binding-value'), outputBinding('clicked', bindingClickHandler)],
inputs: {
...aliasedInput('greet', 'traditional-greeting'), // This will be ignored due to bindings
},
on: {
clicked: clickHandler, // This should still work alongside bindings
},
});

// Only binding should work for inputs
expect(screen.getByTestId('value')).toHaveTextContent('binding-value');
expect(screen.getByTestId('greeting')).toHaveTextContent('hello'); // Default value, not traditional

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

// Both binding and traditional handlers should be called for outputs
expect(bindingClickHandler).toHaveBeenCalledWith('clicked: binding-value');
expect(clickHandler).toHaveBeenCalledWith('clicked: binding-value');

// Should show warning about mixed usage for inputs
expect(consoleSpy).toHaveBeenCalledWith(
'ATL: You specified both bindings and traditional inputs. ' +
'Angular does not allow mixing setInput() with inputBinding(). ' +
'Only bindings will be used for inputs. Use bindings for all inputs to avoid this warning.',
);

expect(consoleSpy).toHaveBeenCalledWith(
'ATL: You specified both bindings and traditional output listeners. ' +
'Consider using outputBinding() for all outputs for consistency.',
);

consoleSpy.mockRestore();
});
});
Loading