| |

Angular 103 🅰️ Testing — Async and Signals

⏱️ Estimated read: 15 min | |

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

ToolPurpose
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

ToolPurpose
fakeAsyncWrap 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

ToolPurpose
waitForAsyncWrap the test in a zone
whenStable()Wait for the component to stabilize
async/awaitWait for a promise

The HTTP Testing Tools

ToolPurpose
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

PitfallWhy It HappensFix
Stale DOMdetectChanges not calledCall fixture.detectChanges()
Test hangsTimer not flushedUse tick or flush
Test fails at endPending timerUse flush or discardPeriodicTasks
HTTP request not flushedNo req.flushAdd req.flush(mockData)
Effect not runningNot flushedUse flush or detectChanges
whenStable not workingKnown issue with awaitUse .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

ItemValue
SignalRead with (), update with set/update
ComputedRecalculates automatically
Renderfixture.detectChanges()
fakeAsyncVirtual time zone
tick(ms)Advance the virtual clock
flush()Drain all pending timers
flushMicrotasks()Drain the microtask queue
discardPeriodicTasks()Remove periodic timers
waitForAsyncReal async without time control
whenStable()Wait for stability
HTTPHttpTestingController
EffectFlush 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 computed function tracks its dependencies and invalidates the cached value when a dependency changes .
  • fakeAsync provides 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 .
  • tick(ms) advances the virtual clock. It runs the timers scheduled within the specified time window. The flush function drains every pending macrotask until the queue is empty .
  • flushMicrotasks resolves pending promises. Promises resolve as microtasks. The flushMicrotasks function drains the microtask queue before the assertion .
  • waitForAsync and whenStable wait for real async operations. The waitForAsync function wraps the test in a zone. The whenStable method returns a promise that resolves when the component is stable. There is a known issue with await 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 flush or fixture.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.

Was this helpful?


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!