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

### Angular v20+ Bindings API

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

```typescript
import { render, screen, inputBinding, outputBinding } from '@testing-library/angular';
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);
});
});
```

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);
}
}
21 changes: 21 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,26 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
*/
on?: OutputRefKeysWithCallback<ComponentType>;

/**
* @description
* An array of bindings to apply to the component using Angular v20+'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 } from '@angular/core';
*
* await render(AppComponent, {
* bindings: [
* inputBinding('value', () => 'test value'),
* outputBinding('click', (event) => console.log(event))
* ]
* })
*/
bindings?: Binding[];

/**
* @description
* A collection of providers to inject dependencies of the component.
Expand Down
31 changes: 25 additions & 6 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,19 @@ export async function render<SutType, WrapperType = SutType>(
outputs: Partial<SutType>,
subscribeTo: OutputRefKeysWithCallback<SutType>,
): Promise<ComponentFixture<SutType>> => {
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer);
setComponentProperties(createdFixture, properties);
setComponentInputs(createdFixture, inputs);
setComponentOutputs(createdFixture, outputs);
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer, bindings);

// Only use traditional input/output setting if no bindings are provided
// When bindings are used, they handle inputs and outputs natively
if (!bindings || bindings.length === 0) {
setComponentProperties(createdFixture, properties);
setComponentInputs(createdFixture, inputs);
setComponentOutputs(createdFixture, outputs);
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
} else {
// With bindings, we still need to handle componentProperties for non-input properties
setComponentProperties(createdFixture, properties);
}

if (removeAngularAttributes) {
createdFixture.nativeElement.removeAttribute('ng-version');
Expand Down Expand Up @@ -335,9 +345,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
3 changes: 3 additions & 0 deletions projects/testing-library/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
export * from './lib/models';
export * from './lib/config';
export * from './lib/testing-library';

// Re-export Angular's binding functions for convenience
export { inputBinding, outputBinding, twoWayBinding, type Binding } from '@angular/core';
41 changes: 41 additions & 0 deletions projects/testing-library/tests/bindings-support.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Component, input, output } from '@angular/core';
import { render, screen, inputBinding, outputBinding } 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>();
}

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');
});
});
Loading