Angular 103 🅰️ Testing — Async and Signals
The previous chapters covered the testing infrastructure and the component and service testing patterns. This chapter covers the two hardest parts of testing an Angular application: asynchronous behavior and signals. An async test that does not wait for the operation to complete will assert on stale data. A signal test that does not trigger change detection will check the DOM before it updates. The tools for both are specific to Angular, and using them correctly is the difference between a test that is reliable and one that is flaky.
Key point: Signals are synchronous to read but asynchronous to render. When you update a signal, the value changes immediately. When you want the DOM to reflect the change, you must trigger change detection with fixture.detectChanges(). Async operations — timers, promises, HTTP — are controlled with fakeAsync and tick, or with waitForAsync and whenStable. The tools are different because the problems are different. Mixing them up is the most common source of flaky tests.
Why async and signal tests matter
A synchronous test is simple. You call a method, you assert the result. An asynchronous test is harder. The method schedules work for later. The work completes at some point in the future. The test must wait for the work to complete before asserting.
The timing problem. A component fetches data from an API. The fetch is asynchronous. The test renders the component and immediately checks the DOM. The DOM shows the loading state, not the data. The assertion fails. The test must wait for the fetch to complete before checking the DOM.
The control problem. A component uses a setTimeout with a one-second delay. The test cannot wait one real second for every test. The test needs to control time. The fakeAsync zone provides a virtual clock that the test can advance with tick.
The signal problem. A signal updates the value. The template reads the signal. But the template does not re-render automatically in a test. The test must call fixture.detectChanges() to trigger the re-render. Without it, the DOM shows the old value.
The cleanup problem. A test with a pending timer or an unresolved promise fails with an error. The fakeAsync zone requires all timers to be flushed before the test ends. The flush function drains the queue. The discardPeriodicTasks function removes periodic timers.
The trade-off. Async tests are slower to write and more fragile than synchronous tests. The tools are necessary because the application is asynchronous. A test that does not handle the async behavior correctly is worse than no test — it fails intermittently, erodes trust in the test suite, and wastes time.
a. Testing Signals
A signal is a function. Reading it is calling it. Testing a signal is straightforward: read the value, assert it, update the signal, read again, assert again.
import { signal, computed } from '@angular/core';
describe('Signal logic', () => {
it('should update computed when signal changes', () => {
const count = signal(0);
const doubled = computed(() => count() * 2);
expect(doubled()).toBe(0);
count.set(5);
expect(doubled()).toBe(10);
count.update(c => c + 1);
expect(doubled()).toBe(12);
});
});
The computed signal recalculates automatically when its dependencies change. The test changes the source signal, then reads the computed signal. No manual recompute is needed .
For a component that uses signals, the test reads the signal from the component instance. The signal is updated directly, and the DOM is checked after fixture.detectChanges().
it('should display the count in the template', () => {
component.count.set(5);
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('.count');
expect(element.textContent).toContain('5');
});
The fixture.detectChanges() method is required. Without it, the DOM does not update. The signal has changed, but the template has not re-rendered .
For an OnPush component with signal inputs, the fixture.componentRef.setInput method sets the input and triggers the check.
it('should update when input signal changes', () => {
fixture.componentRef.setInput('data', { name: 'Initial' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Initial');
fixture.componentRef.setInput('data', { name: 'Updated' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Updated');
});
The setInput method works with signal inputs. It sets the value and marks the component for check. The detectChanges method renders the new value .
b. Testing Async Operations with fakeAsync
The fakeAsync function wraps the test in a virtual time zone. Timers and microtasks are queued instead of executed. The test advances the clock with tick or drains the queue with flush .
import { fakeAsync, tick } from '@angular/core/testing';
it('should fire after 1 second', fakeAsync(() => {
let done = false;
setTimeout(() => (done = true), 1000);
expect(done).toBe(false);
tick(1000);
expect(done).toBe(true);
}));
The tick(1000) function advances the virtual clock by 1000 milliseconds. The setTimeout callback runs. The done variable becomes true .
The flush function drains every pending macrotask until the queue is empty. It is used when the exact delay is not known.
it('should complete a debounced search', fakeAsync(() => {
component.search('ang');
flush();
expect(component.results().length).toBeGreaterThan(0);
}));
The flush() function runs all the timers, including the debounce timer. The search completes, and the results are available .
For promises, the flushMicrotasks function resolves the pending microtasks.
import { fakeAsync, flushMicrotasks } from '@angular/core/testing';
it('should resolve a promise', fakeAsync(() => {
let value: number | undefined;
Promise.resolve(42).then((n) => (value = n));
flushMicrotasks();
expect(value).toBe(42);
}));
The flushMicrotasks() function drains the microtask queue. The promise callback runs. The value is set .
The fakeAsync test fails if it ends with pending timers. The flush function drains them. The discardPeriodicTasks function removes periodic timers.
import { fakeAsync, discardPeriodicTasks } from '@angular/core/testing';
it('should handle an interval', fakeAsync(() => {
// schedule an interval
const intervalId = setInterval(() => {}, 1000);
// do something
clearInterval(intervalId);
discardPeriodicTasks();
}));
The discardPeriodicTasks() function removes the periodic timers from the queue. It is used when the test cannot wait for all the intervals to complete .
c. Testing with waitForAsync and whenStable
The waitForAsync function wraps the test in a zone that tracks the async operations. The test waits for the operations to complete before proceeding.
import { waitForAsync } from '@angular/core/testing';
it('should settle', waitForAsync(() => {
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.componentInstance.ready()).toBe(true);
});
}));
The whenStable method returns a promise that resolves when the component is stable. The test then asserts .
The async and await keywords are the modern approach. The await fixture.whenStable() statement waits for the stability.
it('should render after stable', async () => {
await fixture.whenStable();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('li');
expect(items.length).toBe(1);
});
The await keyword pauses the test until the promise resolves. The detectChanges method then renders the DOM .
There is a known issue with await fixture.whenStable() in some scenarios. The .then() form works when the await form does not. The workaround is to use the .then() form when the await form fails .
Complete Example Session
This session tests a counter component with signals, an async service with fakeAsync, and a component with an HTTP service.
// ============================================
// PART 1: THE COUNTER COMPONENT WITH SIGNALS
// ============================================
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p class="count">Count: {{ count() }}</p>
<p class="double">Double: {{ double() }}</p>
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
count = signal(0);
double = computed(() => this.count() * 2);
increment(): void {
this.count.update((v) => v + 1);
}
}
// ============================================
// PART 2: THE COUNTER COMPONENT TEST
// ============================================
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should start with count 0', () => {
expect(component.count()).toBe(0);
expect(component.double()).toBe(0);
});
it('should increment the count', () => {
component.increment();
expect(component.count()).toBe(1);
expect(component.double()).toBe(2);
});
it('should update the computed signal', () => {
component.count.set(5);
expect(component.double()).toBe(10);
});
it('should display the count in the template', () => {
component.count.set(5);
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('.count');
expect(element.textContent).toContain('Count: 5');
});
it('should display the double in the template', () => {
component.count.set(5);
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('.double');
expect(element.textContent).toContain('Double: 10');
});
});
// ============================================
// PART 3: THE ASYNC SERVICE
// ============================================
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class TimerService {
getDelayedValue(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => resolve('done'), 1000);
});
}
}
// ============================================
// PART 4: THE ASYNC SERVICE TEST
// ============================================
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { TimerService } from './timer.service';
describe('TimerService', () => {
let service: TimerService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(TimerService);
});
it('should resolve after a delay', fakeAsync(() => {
let result: string | undefined;
service.getDelayedValue().then((value) => (result = value));
expect(result).toBeUndefined();
tick(1000);
expect(result).toBe('done');
}));
it('should resolve with flush', fakeAsync(() => {
let result: string | undefined;
service.getDelayedValue().then((value) => (result = value));
flush();
expect(result).toBe('done');
}));
});
// ============================================
// PART 5: THE HTTP SERVICE
// ============================================
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface User {
id: number;
name: string;
}
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users');
}
}
// ============================================
// PART 6: THE HTTP SERVICE TEST
// ============================================
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { UserService, User } from './user.service';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should fetch users', () => {
const mockUsers: User[] = [{ id: 1, name: 'Alice' }];
service.getUsers().subscribe((users) => {
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
});
// ============================================
// PART 7: THE COMPONENT WITH AN ASYNC SERVICE
// ============================================
import { Component, inject, signal } from '@angular/core';
import { UserService, User } from './user.service';
@Component({
selector: 'app-user-list',
template: `
@if (loading()) {
<p class="loading">Loading...</p>
} @else {
<ul>
@for (user of users(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
}
`,
})
export class UserListComponent {
private userService = inject(UserService);
users = signal<User[]>([]);
loading = signal(true);
ngOnInit(): void {
this.userService.getUsers().subscribe((users) => {
this.users.set(users);
this.loading.set(false);
});
}
}
// ============================================
// PART 8: THE COMPONENT TEST WITH WHEN STABLE
// ============================================
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';
describe('UserListComponent', () => {
let fixture: ComponentFixture<UserListComponent>;
let userService: jasmine.SpyObj<UserService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('UserService', ['getUsers']);
spy.getUsers.and.returnValue(of([{ id: 1, name: 'Alice' }]));
await TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [{ provide: UserService, useValue: spy }],
}).compileComponents();
fixture = TestBed.createComponent(UserListComponent);
userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
});
it('should display the users after the async operation', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('li');
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('Alice');
});
});
// ============================================
// PART 9: THE SIGNAL WITH AN EFFECT
// ============================================
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-theme-toggle',
template: `<button (click)="toggle()">Toggle</button>`,
})
export class ThemeToggleComponent {
theme = signal<'light' | 'dark'>('light');
constructor() {
effect(() => {
document.body.classList.toggle('dark', this.theme() === 'dark');
});
}
toggle(): void {
this.theme.update((t) => (t === 'light' ? 'dark' : 'light'));
}
}
// ============================================
// PART 10: THE EFFECT TEST
// ============================================
import { TestBed, fakeAsync, flush } from '@angular/core/testing';
import { ThemeToggleComponent } from './theme-toggle.component';
describe('ThemeToggleComponent', () => {
it('should apply the dark class when the theme is dark', fakeAsync(() => {
const fixture = TestBed.createComponent(ThemeToggleComponent);
fixture.detectChanges();
flush();
expect(document.body.classList.contains('dark')).toBe(false);
fixture.componentInstance.toggle();
fixture.detectChanges();
flush();
expect(document.body.classList.contains('dark')).toBe(true);
}));
});
The ten parts cover the counter component with signals, the counter component test, the async service, the async service test, the HTTP service, the HTTP service test, the component with an async service, the component test with whenStable, the signal with an effect, and the effect test.
Quick Reference
The Signal Testing Tools
| Tool | Purpose |
|---|---|
signal() | Read the value |
set() | Set the value |
update() | Update the value |
computed() | Read the derived value |
fixture.detectChanges() | Render the template |
The fakeAsync Tools
| Tool | Purpose |
|---|---|
fakeAsync | Wrap the test in a virtual time zone |
tick(ms) | Advance the virtual clock |
flush() | Drain all pending timers |
flushMicrotasks() | Drain the microtask queue |
discardPeriodicTasks() | Remove periodic timers |
The waitForAsync Tools
| Tool | Purpose |
|---|---|
waitForAsync | Wrap the test in a zone |
whenStable() | Wait for the component to stabilize |
async/await | Wait for a promise |
The HTTP Testing Tools
| Tool | Purpose |
|---|---|
provideHttpClient() | Provide the HTTP client |
provideHttpClientTesting() | Provide the testing backend |
httpMock.expectOne() | Assert a request |
req.flush() | Provide a response |
httpMock.verify() | Assert no pending requests |
Best Practices
✅ Do This:
// Use detectChanges after signal updates
component.count.set(5);
fixture.detectChanges(); // ✅
// Use fakeAsync for timers
it('should resolve', fakeAsync(() => {
service.getValue().then(v => result = v);
tick(1000);
expect(result).toBe('done');
})); // ✅
// Use flush for unknown delays
it('should complete', fakeAsync(() => {
component.search('query');
flush();
expect(component.results().length).toBeGreaterThan(0);
})); // ✅
// Use whenStable for async operations
await fixture.whenStable();
fixture.detectChanges(); // ✅
// Use httpMock.verify in afterEach
afterEach(() => httpMock.verify()); // ✅
❌ Don’t Do This:
// Don't forget detectChanges after signal updates
component.count.set(5);
// expect(fixture.nativeElement.textContent).toContain('5'); // ❌ stale
// Don't leave timers pending in fakeAsync
it('should do something', fakeAsync(() => {
setTimeout(() => {}, 1000);
// no tick or flush → test fails // ❌
}));
// Don't use real timers in tests
setTimeout(() => {}, 5000); // waits 5 real seconds // ❌
// Don't forget to flush HTTP requests
httpMock.expectOne('/api/users'); // no flush → test hangs // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Stale DOM | detectChanges not called | Call fixture.detectChanges() |
| Test hangs | Timer not flushed | Use tick or flush |
| Test fails at end | Pending timer | Use flush or discardPeriodicTasks |
| HTTP request not flushed | No req.flush | Add req.flush(mockData) |
| Effect not running | Not flushed | Use flush or detectChanges |
whenStable not working | Known issue with await | Use .then() form |
Real-World Examples
1. Signal Read
expect(component.count()).toBe(0);
2. Signal Update
component.count.set(5);
3. Computed Signal
expect(component.double()).toBe(10);
4. Render Signal
component.count.set(5);
fixture.detectChanges();
5. fakeAsync
it('should resolve', fakeAsync(() => { }));
6. tick
tick(1000);
7. flush
flush();
8. flushMicrotasks
flushMicrotasks();
9. whenStable
await fixture.whenStable();
10. HTTP Flush
const req = httpMock.expectOne('/api/users');
req.flush(mockUsers);
Visual
The Signal Test Flow
┌──────────────────────────────────────────────┐
│ SIGNAL TEST FLOW │
│ │
│ 1. Read the signal │
│ └─ expect(component.count()).toBe(0) │
│ │
│ 2. Update the signal │
│ └─ component.count.set(5) │
│ │
│ 3. Trigger change detection │
│ └─ fixture.detectChanges() │
│ │
│ 4. Assert the DOM │
│ └─ expect(element.textContent).toContain('5')│
│ │
└──────────────────────────────────────────────┘
The fakeAsync Zone
┌──────────────────────────────────────────────┐
│ fakeAsync ZONE │
│ │
│ Real time Virtual time │
│ ──────── ────────── │
│ 0ms 0ms │
│ ... tick(1000) → 1000ms │
│ 1000ms flush() → all pending │
│ │
│ Timers are queued, not executed. │
│ tick advances the clock. │
│ flush drains the queue. │
│ │
└──────────────────────────────────────────────┘
The Async Test Decision
┌──────────────────────────────────────────────┐
│ WHICH TOOL? │
│ │
│ Timers (setTimeout, setInterval)? │
│ └─ fakeAsync + tick/flush │
│ │
│ Promises? │
│ └─ fakeAsync + flushMicrotasks │
│ │
│ HTTP? │
│ └─ HttpTestingController + req.flush │
│ │
│ Real async without control? │
│ └─ waitForAsync + whenStable │
│ │
└──────────────────────────────────────────────┘
The Effect Test Flow
┌──────────────────────────────────────────────┐
│ EFFECT TEST FLOW │
│ │
│ 1. Create the component │
│ └─ TestBed.createComponent() │
│ │
│ 2. Trigger the signal change │
│ └─ component.theme.set('dark') │
│ │
│ 3. Flush the effect │
│ └─ flush() or fixture.detectChanges() │
│ │
│ 4. Assert the side effect │
│ └─ expect(document.body.classList...) │
│ │
│ Effects run asynchronously. │
│ They must be flushed before asserting. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Signal | Read with (), update with set/update |
| Computed | Recalculates automatically |
| Render | fixture.detectChanges() |
fakeAsync | Virtual time zone |
tick(ms) | Advance the virtual clock |
flush() | Drain all pending timers |
flushMicrotasks() | Drain the microtask queue |
discardPeriodicTasks() | Remove periodic timers |
waitForAsync | Real async without time control |
whenStable() | Wait for stability |
| HTTP | HttpTestingController |
| Effect | Flush before asserting |
Key takeaways:
- Signals are synchronous to read but asynchronous to render. Reading a signal returns the current value. Updating a signal changes the value immediately. The DOM does not update until
fixture.detectChanges()is called . - Computed signals recalculate automatically. The test changes the source signal and reads the computed signal. No manual recompute is needed. The
computedfunction tracks its dependencies and invalidates the cached value when a dependency changes . fakeAsyncprovides a virtual time zone. Timers and microtasks are queued instead of executed. The test advances the clock withtickor drains the queue withflush.tick(ms)advances the virtual clock. It runs the timers scheduled within the specified time window. Theflushfunction drains every pending macrotask until the queue is empty .flushMicrotasksresolves pending promises. Promises resolve as microtasks. TheflushMicrotasksfunction drains the microtask queue before the assertion .waitForAsyncandwhenStablewait for real async operations. ThewaitForAsyncfunction wraps the test in a zone. ThewhenStablemethod returns a promise that resolves when the component is stable. There is a known issue withawait fixture.whenStable()in some scenarios; the.then()form is the workaround .- Effects run asynchronously. The effect runs after the signals it reads change. The test must flush the effect with
flushorfixture.detectChanges()before asserting the side effect.
Remember: Async and signal tests are the hardest part of Angular testing. Signals are synchronous to read and asynchronous to render. Async operations are controlled with fakeAsync and tick, or with waitForAsync and whenStable. The tools are different because the problems are different. Use the right tool for the right problem. Call detectChanges after signal updates. Use fakeAsync for timers. Use flush for unknown delays. Use whenStable for real async. Flush the effects before asserting.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!