Skip to the solution
MediumFrontend9 min read

Explain how to perform unit testing on components with Signals using TestBed.

1.1k views

The problem

Question presented to candidate: "Our new components use input.required(), model(), output() and computed(), and a cart service uses effect(). Walk me through unit testing these with TestBed: how do you set inputs, observe outputs, and make effects run?"

What a strong answer should cover:

  • Signal inputs are read-only from outside: set them with fixture.componentRef.setInput('name', value) (or inputBinding() at creation), never by assignment.
  • A required input that was never set throws NG0950 on the first render; set it before rendering.
  • output() and model() both expose subscribe(); collect emitted values in an array and assert.
  • computed() is synchronous and pull-based: read it directly, no flushing needed.
  • Root effects run on Angular's schedule; in a test call TestBed.tick() (v20+, replaces the deprecated TestBed.flushEffects()).
  • After changes, await fixture.whenStable() (zoneless default) or fixture.detectChanges() to render.
  • A host test component is the clean way to test [(model)] two-way binding through a template.

Clarifying questions expected:

  • "Are we testing the component's DOM, its public API, or both?"
  • "Is the project zoneless (the default since v21) or Zone-based?"

Code / implementation expected: Yes β€” a spec that sets signal inputs, observes output()/model(), asserts a computed() and flushes an effect().

  • testing
  • signals

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 9 sections

Read the code

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'); // computed() followed
    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'); // the binding is live: no setInput() needed
    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([]); // the effect has not run yet
    TestBed.tick();
    expect(cart.log).toEqual(['total=0']);

    cart.items.set([5, 7]);
    expect(cart.total()).toBe(12); // no flushing needed for computed
    expect(cart.log).toEqual(['total=0']);
    TestBed.tick();
    expect(cart.log).toEqual(['total=0', 'total=12']);
  });
});

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 16 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

How do you handle SSR hydration mismatch errors using ngSkipHydration?

Continue