Signal Inputs

Signal Inputs were introduced with Angular 17.1. They serve the same purpose as the @Input decorator: enabling Property Binding. A Signal Input is created using the input() function. The modern Property Binding syntax looks like this:

// @Input-Style (old)
class HolidayComponent {
  @Input() username = '';
  @Input({required: true}) holiday: Holiday | undefined;
}

// Signal Input (new)
class HolidayComponent {
  username = input(''); // Signal<string>
  holiday = input<Holiday>(); // Signal<Holiday | undefined>
} 
Enter fullscreen mode Exit fullscreen mode

The resulting property is of type Signal, giving it inherent reactivity. Rather than relying on ngOnChanges() and ngOnInit(), you can observe changes using effect() or derive values with computed().

Angular also shipped the required() function alongside Signal Inputs. It addresses the drawback of @Input({required: true}), which always produces a union type that includes undefined along with the actual type:

// required input
class HolidayComponent {
  username = input(''); // Signal<string>
  holiday = input.required<Holiday>(); // Signal<Holiday>
} 

@Component({
  tempate: `<app-holiday [username]="username" [holiday]="holiday" />`
})
class HolidayContainerComponent {
  username = 'Konrad Weber';
  holiday = createHoliday();
}
Enter fullscreen mode Exit fullscreen mode

Testing Signal Inputs

What's the proper way to test this? One option is to assign input properties directly via the componentInstance property, or to use the setInput() method:

// Input Signal via componentInstance
it('should show username and holiday', () => {
  const fixture = TestBed.configureTestingModule({
    imports: [HolidayComponent],
  }).createComponent(HolidayComponent);

  const holiday = signal(createHoliday({ title: "'London' }));"

  fixture.componentInstance.holiday =
    holiday as unknown as typeof fixture.componentInstance.holiday;

  fixture.detectChanges();

  const body: HTMLParagraphElement = fixture.debugElement.query(
    By.css('[data-testid=txt-body]'),
  ).nativeElement;

  expect(body.textContent).toContain('Are you interested in visiting London?');

  holiday.update((value) => ({ ...value, title: "'Vienna' }));"
  fixture.detectChanges();

  expect(body.textContent).toContain('Are you interested in visiting Vienna?');
});

// Input Signal via componentRef
it('should show username and holiday', () => {
  const fixture = TestBed.configureTestingModule({
    imports: [HolidayComponent],
  }).createComponent(HolidayComponent);

  fixture.componentRef.setInput('holiday', createHoliday({ title: "'London' }));"
  fixture.detectChanges();

  const body: HTMLParagraphElement = fixture.debugElement.query(
    By.css('[data-testid=txt-body]'),
  ).nativeElement;
  expect(body.textContent).toContain('Are you interested in visiting London?');

  fixture.componentRef.setInput('holiday', createHoliday({ title: "'Vienna' }));"
  fixture.detectChanges();

  expect(body.textContent).toContain('Are you interested in visiting Vienna?');
});
Enter fullscreen mode Exit fullscreen mode

Try to avoid that!

There is another approach that goes by several names. I usually refer to it as the "Wrapper Component" pattern, though some call it the Host Component pattern. It involves creating a test-specific component that incorporates the HolidayComponent and handles Property Binding itself.

The "Wrapper Component"/"Host Component" pattern

The test interacts with the "Wrapper Component" and delegates Property Binding duties to Angular:

@Component({
  template: ` <app-holiday [holiday]="holiday" />`,
  standalone: true,
  imports: [HolidayComponent],
})
class HolidayWrapperComponent {
  holiday = createHoliday({ title: "'London' });"
}
it('should show username and holiday', () => {
  const fixture = TestBed.configureTestingModule({
    imports: [HolidayWrapperComponent],
  }).createComponent(HolidayWrapperComponent);

  const { componentInstance } = fixture;
  fixture.detectChanges();

  const body: HTMLParagraphElement = fixture.debugElement.query(
    By.css('[data-testid=txt-body]'),
  ).nativeElement;

  expect(body.textContent).toContain('Are you interested in visiting London?');
  componentInstance.holiday = createHoliday({ title: "'Vienna' }); "
  fixture.detectChanges();

  expect(body.textContent).toContain('Are you interested in visiting Vienna?');
});
Enter fullscreen mode Exit fullscreen mode

Why the "Wrapper Component"?

The internals of Property Binding are opaque to us. Setting a property directly through componentInstance bypasses Angular's processing entirely, taking a shortcut that removes Angular from the equation.

A common justification for this shortcut is avoiding Angular involvement in tests since we don't intend to test Angular itself.

That reasoning doesn't hold up. Our application code depends on Angular; by removing it, we risk tests failing to reflect runtime behavior and delivering misleading results.

Consider a basic scenario: assigning a property before invoking fixture.detectChanges(). Is that how Angular operates? Does it set values in the constructor, during ngOnInit(), or in the window between instantiation and ngOnInit()? What about the timing of ngOnChanges() alongside afterNextRender() and afterRender()?

Manually triggering each lifecycle hook in the correct sequence means effectively recreating portions of the Angular framework.

Wouldn't it be preferable to let Angular handle its own responsibilities? We focus on our code; the test exercises both together. That's a far better setup!

Tests should consistently run in an environment that mirrors the real application as closely as possible. As long as that doesn't incur significant trade-offs—such as slower execution or substantially more boilerplate—prefer that approach.

Testing is not easy; don't make it harder for yourself as it already is ;).

ComponentRef::setInput() should suffice for most scenarios, provided you interact through the DOM. Angular's team designed setInput() as part of the public API specifically for testing purposes.

Signal Inputs, however, eliminate the possibility of directly assigning properties on the component instance.

The same principle applies when testing the older @Input() decorator. Testing libraries like "Testing Library" and "Cypress Component Test Runner" natively support the "Wrapper Component" pattern.

Model Inputs

While input() supports one-way binding, model() enables two-way binding. Consequently, the Signal becomes writable, and the required() function remains accessible.

The "Wrapper Component" pattern simplifies testing model() considerably.

Suppose HolidayComponent introduces a rating feature for holidays, emitting an event whenever the rating changes. We would replace the input() with model().

The parent component can still apply Property Binding, but a new event, named holidayChange, becomes available. This facilitates the classic two-way binding using the "banana box" syntax:

@Component({
  selector: 'app-holiday',
  template: `<p data-testid="txt-greeting">Hello {{ username() }}</p>
    <p data-testid="txt-body">
      Are you interested in visiting {{ holiday().title }}?
    </p>
    <p> Rate your Holiday </p>
    <button mat-raised-button data-testid="btn-up" (click)="rating.set('👍')"
      >👍</button
    >
    <button mat-raised-button data-testid="btn-down" (click)="rating.set('👎')"
      >👎</button
    >`,
  standalone: true,
  imports: [MatButton],
})
export class HolidayComponent {
  username = input('');
  holiday = input.required<Holiday>();
  rating = model.required<'👍' | '👎'>();
}
Enter fullscreen mode Exit fullscreen mode

We again employ the "WrapperComponent", which handles two-way binding on the rating:

@Component({
  template: ` <app-holiday [holiday]="holiday" [(rating)]="rating" />
    <p data-testid="txt-rating">{{ rating }}</p>`,
  standalone: true,
  imports: [HolidayComponent],
})

class HolidayWrapperComponent {
  holiday = createHoliday({ title: "'London' });"
  rating = '👎';
}

describe('Holiday Component', () => {
  it('should apply two-way-binding on rating', () => {
    const fixture = TestBed.configureTestingModule({
      imports: [HolidayWrapperComponent],
    }).createComponent(HolidayWrapperComponent);
    fixture.detectChanges();
    const rating: HTMLParagraphElement = fixture.debugElement.query(
      By.css('[data-testid=txt-rating]'),
    ).nativeElement;
    expect(rating.textContent).toBe('👎');
    fixture.debugElement
      .query(By.css('[data-testid=btn-up]'))
      .nativeElement.click();
    fixture.detectChanges();
    expect(rating.textContent).toBe('👍');
  });
});
Enter fullscreen mode Exit fullscreen mode

HolidaysWrapperComponent displays the current rating and forwards it to the HolidayComponent. Due to the "banana box" syntax, any rating change within HolidayComponent propagates to the wrapper's rating as well.

Summary

Testing Signal and Model Inputs becomes straightforward when we interact with the component through the DOM and allow Angular to manage Property Binding.

The "Wrapper Component" pattern, along with ComponentRef::setInput(), are the suitable strategies for this frequent testing situation.


You can access the repository at https://github.com/rainerhahnekamp/how-do-i-test

If you encounter a testing challenge you'd like me to address here, please get in touch with me!

For additional updates, connect with me on LinkedIn, X, and explore our website for workshops and consulting services on testing.