Testing Angular Signals: Unit Testing Change Detection and Reactive Primitives
Angular Signals, introduced in Angular 17 and stabilized in Angular 18, replace RxJS-heavy state management with fine-grained reactivity. Testing signals requires understanding when Angular flushes effects, how computed values are lazy, and how to assert on DOM updates triggered by signal changes. This guide covers all of it.
Key Takeaways
Signals are synchronous reads. mySignal() returns the current value immediately — no subscribe, no async. In unit tests you read the value directly after mutation.
Effects run asynchronously. An effect() doesn't run immediately on signal change — Angular schedules it. Wrap your test in fakeAsync and call TestBed.flushEffects() (Angular 18+) or tick() to drain the effect queue.
computed() is lazy and cached. A computed signal only recalculates when you read it after a dependency changed. Tests that never read the computed after mutation will pass even if the computation is wrong.
TestBed.flushEffects() is the key. This replaces ad-hoc tick(0) patterns for signal effects. Always call it before asserting on side-effects.
Signal-based components skip ChangeDetectionStrategy.OnPush boilerplate. When a component uses signal() for all state, Angular auto-schedules change detection. fixture.detectChanges() still flushes the initial render.
Why Signals Change How You Test
Angular's traditional testing model relied on fixture.detectChanges() to push @Input() changes into the template, and zone.js to detect async mutations. Signals bypass zones entirely — they notify Angular's scheduler directly.
This means:
- Signal reads in templates are tracked automatically
- You don't need
ChangeDetectionStrategy.OnPushto avoid unnecessary re-renders - Effects need explicit flushing in tests (no zone.js to do it for you)
Setup
Angular Signals work in any Angular 17+ project with no extra packages. For testing, the standard @angular/core/testing plus @angular/common/http/testing covers everything.
npm install --save-dev @angular/core @angular/common
# TestBed, fakeAsync, tick, flushEffects are all in @angular/core/testingTesting a Basic Signal
import { signal, computed } from '@angular/core';
import { TestBed } from '@angular/core/testing';
describe('signal basics', () => {
it('reads the current value synchronously', () => {
const count = signal(0);
expect(count()).toBe(0);
count.set(5);
expect(count()).toBe(5);
count.update(v => v + 1);
expect(count()).toBe(6);
});
it('computed recalculates after dependency changes', () => {
const price = signal(100);
const tax = signal(0.1);
const total = computed(() => price() * (1 + tax()));
expect(total()).toBe(110);
price.set(200);
expect(total()).toBe(220); // lazy recalc on read
tax.set(0.2);
expect(total()).toBe(240);
});
});Testing Signal-Based Components
Given this component:
// counter.component.ts
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p data-testid="count">{{ count() }}</p>
<p data-testid="doubled">{{ doubled() }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
increment() {
this.count.update(v => v + 1);
}
}Test it like this:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
let component: CounterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent] // standalone: import directly
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // initial render
});
it('shows initial count of 0', () => {
const countEl = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(countEl.textContent.trim()).toBe('0');
});
it('updates count and computed when button is clicked', () => {
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges(); // flush signal-triggered CD
const countEl = fixture.nativeElement.querySelector('[data-testid="count"]');
const doubledEl = fixture.nativeElement.querySelector('[data-testid="doubled"]');
expect(countEl.textContent.trim()).toBe('1');
expect(doubledEl.textContent.trim()).toBe('2');
});
it('directly mutating the signal updates the view', () => {
component.count.set(10);
fixture.detectChanges();
const countEl = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(countEl.textContent.trim()).toBe('10');
});
});Testing Effects
Effects are Angular's way to run side-effects when signals change. They run asynchronously.
// notification.service.ts
import { Injectable, signal, effect } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class NotificationService {
message = signal<string | null>(null);
log: string[] = [];
constructor() {
effect(() => {
const msg = this.message();
if (msg) this.log.push(msg);
});
}
}import { TestBed, fakeAsync } from '@angular/core/testing';
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
let service: NotificationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(NotificationService);
});
it('logs messages when signal changes', fakeAsync(() => {
service.message.set('Hello');
TestBed.flushEffects(); // Angular 18+ — flushes pending effects
expect(service.log).toContain('Hello');
}));
it('does not log null messages', fakeAsync(() => {
service.message.set(null);
TestBed.flushEffects();
expect(service.log).toHaveLength(0);
}));
it('logs multiple messages in order', fakeAsync(() => {
service.message.set('First');
TestBed.flushEffects();
service.message.set('Second');
TestBed.flushEffects();
expect(service.log).toEqual(['First', 'Second']);
}));
});Angular 17 note:TestBed.flushEffects()was added in Angular 18. In Angular 17, usetick(0)insidefakeAsyncto flush the microtask queue where effects are scheduled.
Testing Signal Inputs (Angular 17.1+)
Angular 17.1 introduced signal-based input() as an alternative to @Input().
// card.component.ts
import { Component, input } from '@angular/core';
@Component({
selector: 'app-card',
standalone: true,
template: `<h2>{{ title() }}</h2><p>{{ subtitle() }}</p>`
})
export class CardComponent {
title = input.required<string>();
subtitle = input('No subtitle'); // with default
}import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CardComponent } from './card.component';
describe('CardComponent signal inputs', () => {
let fixture: ComponentFixture<CardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CardComponent]
}).compileComponents();
fixture = TestBed.createComponent(CardComponent);
});
it('renders required title input', () => {
fixture.componentRef.setInput('title', 'My Card');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h2').textContent).toBe('My Card');
});
it('uses default subtitle when not provided', () => {
fixture.componentRef.setInput('title', 'My Card');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p').textContent).toBe('No subtitle');
});
it('updates view when input changes', () => {
fixture.componentRef.setInput('title', 'First');
fixture.detectChanges();
fixture.componentRef.setInput('title', 'Second');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h2').textContent).toBe('Second');
});
});Key API: Use fixture.componentRef.setInput('inputName', value) to set signal inputs in tests. Direct property assignment won't trigger signal input updates.Testing Signal Outputs (Angular 17.3+)
Signal outputs use output() instead of EventEmitter.
// toggle.component.ts
import { Component, output, signal } from '@angular/core';
@Component({
selector: 'app-toggle',
standalone: true,
template: `<button (click)="toggle()">{{ label() }}</button>`
})
export class ToggleComponent {
toggled = output<boolean>();
private active = signal(false);
label = computed(() => this.active() ? 'ON' : 'OFF');
toggle() {
this.active.update(v => !v);
this.toggled.emit(this.active());
}
}describe('ToggleComponent outputs', () => {
it('emits true when toggled on', () => {
const fixture = TestBed.createComponent(ToggleComponent);
fixture.detectChanges();
const emitted: boolean[] = [];
fixture.componentInstance.toggled.subscribe(v => emitted.push(v));
fixture.nativeElement.querySelector('button').click();
fixture.detectChanges();
expect(emitted).toEqual([true]);
expect(fixture.nativeElement.querySelector('button').textContent).toBe('ON');
});
});Testing toSignal() and fromObservable
When converting between RxJS and signals:
import { toSignal } from '@angular/core/rxjs-interop';
import { of, BehaviorSubject } from 'rxjs';
import { TestBed } from '@angular/core/testing';
describe('toSignal', () => {
it('reads observable values as a signal', () => {
TestBed.runInInjectionContext(() => {
const subject = new BehaviorSubject(42);
const sig = toSignal(subject.asObservable());
expect(sig()).toBe(42);
subject.next(100);
expect(sig()).toBe(100);
});
});
});toSignal()requires an injection context. UseTestBed.runInInjectionContext()when calling it outside a constructor or field initializer.
Signal Testing with HelpMeTest
Unit tests cover logic, but signal-based components need E2E coverage too — especially effects that trigger API calls or route navigation.
HelpMeTest tests signal-driven UIs the same as any other Angular app. Write your test in plain English describing the user interaction:
When the user clicks the increment button three times
Then the count display shows 3
And the doubled display shows 6HelpMeTest runs these against your live app, no TestBed required.
Common Pitfalls
Forgetting fixture.detectChanges() after signal mutation Signal changes schedule a CD cycle but don't run it immediately. Always call detectChanges() before asserting on the DOM.
Reading computed before setting dependency A computed signal is lazy — if you read it once, cache the reference, then change a dependency but read the cached (stale) value, you'll get the old result. Always call computed() as a function to get the fresh value.
Missing injection context for toSignal() toSignal() must be called inside a constructor, field initializer, or TestBed.runInInjectionContext(). Calling it at the top of a test file throws NG0203.
Not flushing effects before asserting side-effects effect() schedules work after the current microtask. Without TestBed.flushEffects() (or tick() in Angular 17), your assertion runs before the effect fires.
Summary
Angular Signals make state management simpler but require test adjustments:
- Read signals directly with
signal()— synchronous, no subscribe - Call
TestBed.flushEffects()after signal changes that trigger effects - Use
fixture.componentRef.setInput()for signal inputs (not direct property assignment) - Use
fixture.detectChanges()after mutations to flush the DOM - Wrap effect-dependent tests in
fakeAsync
Signal-based Angular components are actually easier to unit test than zone-heavy predecessors — the reactivity is explicit, and there's no async/await needed for basic signal reads.