AngularsetInput, output()/model() subscriptions, inputBinding and TestBed.tick() (Vitest, ran with ng test on Angular 22.2.3)
import { Component, Injectable, computed, effect, input, inputBinding, model, output, outputBinding, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
@Component({
selector: 'app-stepper',
template: `
<span class="label">{{ label() }}</span>
<span class="value">{{ value() }}</span>
<span class="double">{{ doubled() }}</span>
<button (click)="step()">+{{ by() }}</button>
`,
})
class Stepper {
label = input.required<string>();
by = input(1);
value = model(0);
changed = output<number>();
doubled = computed(() => this.value() * 2);
step() {
this.value.update((v) => v + this.by());
this.changed.emit(this.value());
}
}
@Injectable({ providedIn: 'root' })
class Cart {
items = signal<number[]>([]);
total = computed(() => this.items().reduce((a, b) => a + b, 0));
log: string[] = [];
constructor() {
effect(() => this.log.push(`total=${this.total()}`));
}
}
const q = (f: { nativeElement: HTMLElement }, css: string) => f.nativeElement.querySelector(css)?.textContent?.trim();
describe('signal components with TestBed', () => {
it('a required input that was never set throws NG0950', () => {
const fixture = TestBed.createComponent(Stepper);
expect(() => fixture.detectChanges()).toThrow(/NG0950: Input "label" is required/);
});
it('setInput() for inputs, subscribe() for output() and model()', async () => {
const fixture = TestBed.createComponent(Stepper);
fixture.componentRef.setInput('label', 'Qty');
fixture.componentRef.setInput('by', 5);
await fixture.whenStable();
expect([q(fixture, '.label'), q(fixture, '.value'), q(fixture, 'button')]).toEqual(['Qty', '0', '+5']);
const emitted: number[] = [];
const modelChanges: number[] = [];
fixture.componentInstance.changed.subscribe((v) => emitted.push(v));
fixture.componentInstance.value.subscribe((v) => modelChanges.push(v));
fixture.nativeElement.querySelector('button').click();
await fixture.whenStable();
expect(q(fixture, '.value')).toBe('5');
expect(q(fixture, '.double')).toBe('10');
expect(emitted).toEqual([5]);
expect(modelChanges).toEqual([5]);
});
it('or bind at creation with inputBinding / outputBinding', async () => {
const label = signal('Bound');
const seen: number[] = [];
const fixture = TestBed.createComponent(Stepper, {
bindings: [
inputBinding('label', label),
inputBinding('by', () => 3),
outputBinding<number>('changed', (v) => seen.push(v)),
],
});
await fixture.whenStable();
expect(q(fixture, '.label')).toBe('Bound');
label.set('Rebound');
fixture.nativeElement.querySelector('button').click();
await fixture.whenStable();
expect(q(fixture, '.label')).toBe('Rebound');
expect(seen).toEqual([3]);
});
it('a signal service: computed is synchronous, effects wait for TestBed.tick()', () => {
const cart = TestBed.inject(Cart);
expect(cart.log).toEqual([]);
TestBed.tick();
expect(cart.log).toEqual(['total=0']);
cart.items.set([5, 7]);
expect(cart.total()).toBe(12);
expect(cart.log).toEqual(['total=0']);
TestBed.tick();
expect(cart.log).toEqual(['total=0', 'total=12']);
});
});