How do I test Signal & Model Inputs?

rainerhahnekamp

Rainer Hahnekamp

Posted on March 8, 2024

How do I test Signal & Model Inputs?

This article explains how to test Signal and Model Inputs, which are the input() and the model() functions.


Signal Inputs

Signal Inputs arrived in Angular 17.1. They fulfill the same tasks as the @Input decorator: Property Binding. A Signal Input is a simple function named input(). The new 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 property is of type Signal, which makes it reactive by nature. Instead of ngOnChanges() and ngOnInit(), we can consume changes with effect() or computed().

The second addition is the additional required() function. It fixes the issue with @Input({required: true}), which always results in the union type including undefined and 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

How do we test that? We can set input properties via the componentInstance or the setInput() like this:

// 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 also another option that goes under different names. I tend to call it the "Wrapper Component" pattern. Another name is the Host Component. We create a Component for the test that uses the HolidayComponent and apply Property Binding.

The "Wrapper Component"/"Host Component" pattern

The test communicates with the "Wrapper Component" and passes over the task of Property Binding 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"?

We don't know how Property Binding internally works. By directly setting a property via the componentInstance, we take a shortcut and remove Angular's part from the equation.

A common argument for that approach is that we don't want to involve Angular in our tests because we don't want to test Angular.

That's not true. Our code runs with Angular. By removing Angular, we are taking high risks that the test won't reflect the runtime behavior and thus will produce wrong results.

A simple example: You set the property before you run fixture.detectChanges(). Is this also the way how Angular does it? Does it set the property in the constructor, in ngOnInit(), between the instantiation and ngOnInit()? What about ngOnChanges() and the afterNextRender(), afterRender()?

We would have to call all these hooks manually and know when to do that. In that sense, we're re-implementing parts of the Angular framework.

Wouldn't it be better if we just let Angular do its things? We do ours, and the test runs both. Much better!

We should always try to run our tests in an environment as close to the applications as possible. If that doesn't come with additional costs, like slower tests or significantly more code, then do that.

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

ComponentRef::setInput() should work in most cases as long as you test via the DOM. setInput() is part of the Angular API, and the Angular Team designed it for that use case.

When you use Signal Inputs, directly setting properties on the component instance is not possible anymore.

The same also applies to testing the old @Input(). Testing tools, like the "Testing Library" or "Cypress Component Test Runner", support the "Wrapper Component" pattern out of the box.

Model Inputs

Where input() is one-way, model() is two-way binding. As a result, the Signal becomes writable, and we also have the required() function available.

The "Wrapper Component" pattern makes testing the model() a no-brainer.

Let's say HolidayComponent wants to introduce a rating feature for the holiday and emits an event whenever the rating changes. We must replace the input() with the model().

The parent component can still apply Property Binding but gets a new event named holidayChange. This allows the parent to use the classic two-binding via 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

Again, we use the "WrapperComponent", which does two-way binding on 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 shows the current rating and passes it on to the HolidayComponent. Since we use the "banana box" syntax, any change to the HolidayComponent's rating would also happen to the wrapper's rating.

Summary

Testing Signal and Model Inputs is very easy as long as we communicate with the Component via the DOM and let Angular the Property Binding.

The "Wrapper Component" pattern or ComponentRef::setInput() are the right approaches to that common testing use case.


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.

💖 💪 🙅 🚩
rainerhahnekamp
Rainer Hahnekamp

Posted on March 8, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related