Angular course ยท Module 11: Testing

Testing Time and Signals

6 min read ยท
In this lesson4

Some samurai techniques land late - the blow arrives a second after the swing. How do you test that without actually waiting? In this lesson you will learn the single most useful trick in asynchronous testing: inside a test, you command time. You stop the clock, push it forward by exactly as much as you need, and check the result - all in a fraction of a second.

fakeAsync and tick - stop the clock and push it forward

fakeAsync creates a zone in which time stands still until you push it. tick(ms) moves it forward by hand, by the number of milliseconds you name. Thanks to that, a test of a one-second delay takes a blink instead of a second.

import { fakeAsync, tick, flush } from '@angular/core/testing';

it('should update after delay', fakeAsync(() => {
  const component = new AsyncComponent();

  component.loadData();  // sets a setTimeout of 1000ms
  expect(component.data).toBeNull();

  tick(500);             // move the clock forward by 500ms
  expect(component.data).toBeNull();   // too early

  tick(500);             // another 500ms - 1000 in total
  expect(component.data).toBeTruthy(); // ready now
}));

Follow the logic of time here: right after loadData the data is still empty, because the timer has not fired yet. After the first tick(500) it is still empty - only half a second of virtual time has passed. Only the second tick(500) completes the full second and the timer goes off. Notice what tick does not do: it never waits one real second, it does not run any loop iterations, and it does not cap how long the test may take. It only simulates the passage of virtual time, which is why the test is instant and completely predictable. When a component starts many timers at once and you do not want to count milliseconds, flush() runs every pending timer without you naming a time; for a Promise that is waiting to resolve you reach for flushMicrotasks() instead.

Almost every fakeAsync test repeats the same four steps, and a search field with a 300 ms debounce is the classic example. First you create the spy: const spy = spyOn(component, 'performSearch'). Then you trigger the action: component.onSearchChange('samurai'). Then you move the clock with tick(300). And only then do you assert with expect(spy).toHaveBeenCalledWith('samurai'). The order matters as much as the katana draw - assert before the tick and nothing has happened yet, so the test fails on a debounce that works perfectly well.

waitForAsync - wait for real asynchrony

fakeAsync fakes time. Sometimes, though, you are testing something genuinely asynchronous - a request, for instance - and instead of fast-forwarding you want to wait until everything settles. That is the job of waitForAsync together with whenStable.

import { waitForAsync } from '@angular/core/testing';

it('should fetch data asynchronously', waitForAsync(() => {
  const fixture = TestBed.createComponent(AsyncHttpComponent);
  const component = fixture.componentInstance;

  component.fetchData();

  fixture.whenStable().then(() => {
    fixture.detectChanges();
    expect(component.data).toBeTruthy();
  });
}));

The difference is one of attitude: fakeAsync says "wind the clock forward", while waitForAsync says "wait until every task running in the background has finished" - that is what whenStable reports. Keep the rule simple: a known setTimeout calls for fakeAsync, undefined background work calls for waitForAsync. There is a third, optional technique worth knowing about: RxJS ships marble testing with TestScheduler from rxjs/testing, where you describe a whole stream of emissions as a short string such as 'a-b-c|'. For everyday component tests the two tools above will carry you.

Testing signals - the easiest thing in Angular

After the intricacies of time you can breathe out: signals are trivial to test. A signal is a box holding a value - you set it and you read it, with no asynchrony anywhere in sight.

import { signal, computed } from '@angular/core';

it('should update and derive signal values', () => {
  const count = signal(0);

  count.set(5);
  expect(count()).toBe(5);          // read: count()

  const doubled = computed(() => count() * 2);
  expect(doubled()).toBe(10);       // derived automatically

  count.set(3);
  expect(doubled()).toBe(6);        // recomputed by itself
});

The whole craft is set (write) and () (read). Look closely at the read, because this is where beginners slip: you call the signal like a function. There is no .value property and no .get() method, and passing the signal itself into expect compares the function, not the number - count() is the only correct way to reach the current value in an assertion. When the new value depends on the old one, count.update(v => v + 1) does the same work as reading and setting. computed is nicer still: you never set it by hand, it recalculates from the signals it depends on. The test proves it - you change count and doubled already carries the new value. No tick, no waiting.

Inside a component there is one extra step - after changing a signal you call detectChanges so the template refreshes:

it('should update template when signal changes', () => {
  component.count.set(42);
  fixture.detectChanges();  // refresh the view
  expect(fixture.nativeElement.querySelector('.count').textContent).toContain('42');
});

it('should react to input signal changes', () => {
  fixture.componentRef.setInput('name', 'Musashi');  // for input() we use setInput
  fixture.detectChanges();
  expect(fixture.nativeElement.textContent).toContain('Musashi');
});

Remember two differences here. A plain signal you set with .set(), but an input signal created with input() you set through fixture.componentRef.setInput(...), because that value comes from the parent - the component never hands it to itself. And after every change detectChanges copies the value into the template, so only then does it make sense to check the rendered text.

Testing effects

effect runs a piece of code on every change of the signals it reads. To test one you need an injection context and a manual run of the pending effects through TestBed.flushEffects.

it('should trigger effect on signal change', () => {
  let effectCount = 0;

  TestBed.runInInjectionContext(() => {
    const count = signal(0);
    effect(() => { count(); effectCount++; });  // the effect reads count

    TestBed.flushEffects();
    expect(effectCount).toBe(1);   // first run

    count.set(1);
    TestBed.flushEffects();
    expect(effectCount).toBe(2);   // the effect reacted to the change
  });
});

An effect does not fire the instant a signal changes - Angular batches effects and runs them to its own rhythm, which is why a test forces the run with TestBed.flushEffects(). The effectCount counter then proves the thing that matters most: the effect ran once on start and a second time after the signal changed. Had the number stayed at one, it would mean the effect never read count and therefore does not track it at all.

Take this away from the lesson: in a test you are the master of time - fakeAsync with tick for timers you know about, waitForAsync for background work you do not. And signals are the easy part of the dojo: set the value, call detectChanges, check the result.

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does the tick(1000) function do inside fakeAsync?

  2. 2. When do we use flush() instead of tick() in fakeAsync?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Complete the asynchronous test for a component with debounce.

  • Vertical ordering

    Arrange the steps of a debounce test in fakeAsync (top to bottom):

  • Horizontal ordering

    Arrange the call that sets an input signal value in a test (one line):

  • Click in order

    Arrange the line that sets a new signal value: count.set(42)

Useful articles