How do I test Signals (signal, computed, effect)?

rainerhahnekamp

Rainer Hahnekamp

Posted on March 7, 2024

How do I test Signals (signal, computed, effect)?

Signals as lightweight "reactive primitive" will shape the future of Angular applications. At the time of this writing, signal() and computed() are stable, and effect() is in developer preview.

Developer preview doesn't mean we have an unstable feature but that the Angular team can introduce breaking changes within a major version. The feature is stable; otherwise, it would have the experimental label.

Especially with Signal Inputs, Signals find more and more usage in everyday tasks. It is time to consider integrating Signals into our codebases.

And that requires us to be able to write tests as well.

If you are more of a visual learner, here's a video for you:


Signals & Reactivity in a nutshell

With computed() and effect(), we create derived Signals or run side effects if a Signal's value changes. That is the reactive nature of Signals.

Compared to RxJs, reactivity does not require a manual subscription. Angular does that for you.

Signals, i.e., created by signal() or computed(), need to run inside a so-called Reactive Context. In Angular, there are two of them:

  1. A Component's template
  2. The effect() function

In other words, a Signal becomes reactive whenever we call a Signal inside a template or an effect().

When a "reactive Signal" changes, the Signal will notify consumers who are computed(), effect() or a template. computed() could have further consumers that could also have consumers on their end, and so on...

Depending on the Reactive Context, a DOM update (template) or a side effect (effect()) runs.

The Reactive Context runs during the Change Detection: Only the last value gets through. Even if a Signal changes multiple times between Change Detection runs. That means a computed(), an effect(), and the template processes the last value, not the intermediary ones.

This behavior makes total sense when looking at Signals from the perspective of a frontend framework. If we have three synchronous (intermediate) changes, why do we want to update the DOM three times? End users wouldn't see the new value if it were technically possible because it would change in the following rendering frame. It is much better to wait until the Signal reaches its "stable state" and then start updating the DOM.

This behavior is known under the term "Glitch-free effect" or "Push/Pull"

Maybe this animation might help you understand the "Glitch-free effect" as well.

Component under Test

Our Component is a basket in an online shop where users can increase or decrease the amount of products:

@Component({
  selector: 'app-basket',
  template: `<h2>Basket</h2>
    <div class="w-[640px]">
      <div class="grid grid-cols-4 gap-4 auto-cols-fr">
        <div class="font-bold">Name</div>
        <div class="font-bold">Price</div>
        <div class="font-bold">Amount</div>
        <div>&nbsp;</div>
        @for (product of products(); track product.id) {
          <div>{{ product.name }}</div>
          <div>{{ product.price }}</div>
          <div>{{ product.amount }}</div>
          <div>
            <button
              mat-raised-button
              (click)="decrease(product.id)"
              data-testid="btn-decrease"
            >
              <mat-icon>remove</mat-icon>
            </button>
            <button
              mat-raised-button
              (click)="increase(product.id)"
              data-testid="btn-increase"
            >
              <mat-icon>add</mat-icon>
            </button>
          </div>
        }

        <div class="font-bold">Total</div>
        <div class="font-bold" data-testid="total">{{ totalPrice() }}</div>
      </div>
    </div>`,
  standalone: true,
  imports: [MatButton, MatIcon],
})
export default class BasketComponent {
  products = signal([
    { id: 1, name: 'Coffee', price: 3, amount: 1 },
    { id: 2, name: 'Schnitzel', price: 15, amount: 1 },
  ]);

  syncService = inject(SyncService);

  constructor() {
    effect(() => this.syncService.sync(this.products()));
  }

  totalPrice = computed(() =>
    this.products().reduce(
      (total, product) => total + product.price * product.amount,
      0,
    ),
  );

  decrease(id: number) {
    this.#change(id, (product) =>
      product.amount > 0 ? { ...product, amount: product.amount - 1 } : product,
    );
  }

  increase(id: number) {
    this.#change(id, (product) => ({ ...product, amount: product.amount + 1 }));
  }

  #change(id: number, callback: (product: Product) => Product) {
    this.products.update((products) =>
      // some logic to update the products
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The total price is a computed value. It updates on any change of the products Signal.

The same is true for SyncService. It has the following implementation:

@Injectable({ providedIn: 'root' })
export class SyncService {
  sync(products: Product[]) {
    console.log(products);
  }
}
Enter fullscreen mode Exit fullscreen mode

Not that much 😀, but we'd like to keep the example as short as possible.

Testing with Change Detection

Given the central role of Change Detection for Signals, it should be evident that testing them becomes much easier if the Change Detection is part of the test.

That's always the case when our tests communicate with the Component via the DOM and create the Component via TestBed.createComponent.

Testing computed()

This is a test that verifies the total price via the DOM:

it('should increase the quantity of the product', () => {
  const fixture = TestBed.configureTestingModule({
    imports: [BasketComponent],
  }).createComponent(BasketComponent);
  fixture.detectChanges();

  const total: HTMLDivElement = fixture.debugElement.query(
    By.css('[data-testid="total"]'),
  ).nativeElement;
  expect(total.textContent).toBe('18');

  fixture.debugElement
    .query(By.css('[data-testid="btn-increase"]'))
    .nativeElement.click();
  fixture.detectChanges();

  expect(total.textContent).toBe('21');
});

Enter fullscreen mode Exit fullscreen mode

That test works as expected. No surprises!

Everything is alright if we run the Change Detection in the right places. That right place is after an event, like a click, and at the beginning to initialize the Component.

Testing effect()

It becomes slightly trickier if we include the effect() in our test.

Since effect() runs the SyncService, we want to count its calls.

During every Change Detection, the effect() checks if the value of products has changed. If yes, the effect() runs the SyncService.

We cannot assert the execution of the SyncService via the DOM. That's why we have to apply a spy on the SyncService instance. For that we require access to the componentInstance.

The updated test:

it('should run the SyncService', () => {
  const fixture = TestBed.configureTestingModule({
    imports: [BasketComponent],
  }).createComponent(BasketComponent);
  const syncService = TestBed.inject(SyncService);
  const spy = spyOn(syncService, 'sync');
  fixture.detectChanges();

  expect(spy).toHaveBeenCalledTimes(1);
});
Enter fullscreen mode Exit fullscreen mode

We can verify that the effect() only runs when products change and the Change Detection is executed.

The following two tests have to fail:

it('should run the SyncService', () => {
  const fixture = TestBed.configureTestingModule({
    imports: [BasketComponent],
  }).createComponent(BasketComponent);
  const syncService = TestBed.inject(SyncService);
  const spy = spyOn(syncService, 'sync');

  // Change Detection did not run 
  expect(spy).toHaveBeenCalledTimes(1); 
})

it('should run the SyncService', () => {
  const fixture = TestBed.configureTestingModule({
    imports: [BasketComponent],
  }).createComponent(BasketComponent);
  const syncService = TestBed.inject(SyncService);
  const spy = spyOn(syncService, 'sync');
  fixture.detectChanges();

  expect(spy).toHaveBeenCalledTimes(1);

  // no change to total, so no effect
  fixture.detectChanges();
  expect(spy).toHaveBeenCalledTimes(2); 
})
Enter fullscreen mode Exit fullscreen mode

We can increase the amount in our basket, and we should see that after another run of the Change Detection, the SyncService should have run two times:

it('should run the SyncService', () => {
  const fixture = TestBed.configureTestingModule({
    imports: [BasketComponent],
  }).createComponent(BasketComponent);
  const syncService = TestBed.inject(SyncService);
  const spy = spyOn(syncService, 'sync');
  fixture.detectChanges();

  expect(spy).toHaveBeenCalledTimes(1);

  const total: HTMLDivElement = fixture.debugElement.query(
    By.css('[data-testid="total"]'),
  ).nativeElement;
  expect(total.textContent).toBe('18');

  fixture.debugElement
    .query(By.css('[data-testid="btn-increase"]'))
    .nativeElement.click();

  fixture.detectChanges();
  expect(spy).toHaveBeenCalledTimes(2);
});
Enter fullscreen mode Exit fullscreen mode

Let's move to another testing type where the Change Detection is unavailable.

Testing without Change Detection

We extract the logic of the BasketComponent into a new Service, BasketService:

@Injectable({ providedIn: 'root' })
export class BasketService {
  products = signal([
    {
      id: 1,
      name: 'Coffee',
      price: 3,
      amount: 1,
    },
    { id: 2, name: 'Schnitzel', price: 15, amount: 1 },
  ]);

  syncService = inject(SyncService);

  constructor() {
    effect(() => this.syncService.sync(this.products()));
  }

  totalPrice = computed(() =>
    this.products().reduce(
      (total, product) => total + product.price * product.amount,
      0,
    ),
  );

  decrease(id: number) {
    this.#change(id, (product) =>
      product.amount > 0 ? { ...product, amount: product.amount - 1 } : product,
    );
  }

  increase(id: number) {
    this.#change(id, (product) => ({ ...product, amount: product.amount + 1 }));
  }

  #change(id: number, callback: (product: Product) => Product) {
    this.products.update((products) =>
      products.map((product) => {
        if (product.id === id && product.amount > 0) {
          return callback(product);
        } else {
          return product;
        }
      }),
    );
  }
}

@Component({
  selector: 'app-basket',
  template: '<!-- template as before -->',
  standalone: true,
  imports: [MatButton, MatIcon],
})
export default class BasketComponent {
  basketService = inject(BasketService);

  products = this.basketService.products;
  totalPrice = this.basketService.totalPrice;

  decrease(id: number) {
    this.basketService.decrease(id);
  }

  increase(id: number) {
    this.basketService.increase(id);
  }
}

Enter fullscreen mode Exit fullscreen mode

We keep the old tests. It now covers the Component and the two Services.

How would a test look like that only covers the BasketService?

We cannot run fixture.detectChanges() anymore because there is no Component and therefore no ComponentFixture.

Testing computed()

A test for SignalService that asserts the totalPrice Signal would look like this:

it('should test the BasketService', () => {
  const basketService = TestBed.inject(BasketService);
  expect(basketService.totalPrice()).toBe(18);

  basketService.increase(1);
  expect(basketService.totalPrice()).toBe(21);
});
Enter fullscreen mode Exit fullscreen mode

The test above works. Why? Doesn't the totalPrice require a Change Detection run to become reactive?

Yes, that's true. In our case, though, we don't use totalPrice() in a reactive way. We call it directly.

At any given time, a Signal based on a computed() knows if its dependencies have changed (i.e., it is dirty) and would have to do a re-processing.

To start the re-processing, it waits for somebody to request its value.

In the Component test, the "caller" was the Change Detection. In our test, it was us.

Testing effect()

Whereas computed() Signals are simple to test without Change Detection, the real challenge is the effect().

As computed(), the effect() knows internally if it is dirty (one of its dependencies changed its value). Unfortunately, we can't access it like a computed() Signal.

In Angular 16, the only way to test an effect() was to wrap the Service into a "Helper Component". Since Angular 17, we have a new function, which "calls" an effect: TestBed.flushEffects().

Let's see that one in action:

it('should test the BasketService', () => {
  const syncService = TestBed.inject(SyncService);
  const spy = spyOn(syncService, 'sync');

  const basketService = TestBed.inject(BasketService);
  TestBed.flushEffects();

  basketService.increase(1);
  TestBed.flushEffects();
  expect(spy).toHaveBeenCalledTimes(2);
});
Enter fullscreen mode Exit fullscreen mode

Again, we could observe the same behavior as with the Component test. For an effect() to run, two things are necessary:

  1. The effect() needs to be dirty and
  2. there has to be an "active access".

These tests will fail again:

it('should test the BasketService', () => {
  const syncService = TestBed.inject(SyncService);
  const spy = spyOn(syncService, 'sync');
  TestBed.inject(BasketService);

  // effect didn't run
  expect(spy).toHaveBeenCalledTimes(1);
});

it('should test the BasketService', () => {
  const syncService = TestBed.inject(SyncService);
  const spy = spyOn(syncService, 'sync');
  TestBed.inject(BasketService);

  TestBed.flushEffects();
  expect(spy).toHaveBeenCalledTimes(1);

  // effect not dirty
  TestBed.flushEffects();
  expect(spy).toHaveBeenCalledTimes(2);
});
Enter fullscreen mode Exit fullscreen mode

Summary

We must know the "Glitch-free" effect if we test code that uses Signals.

Whenever Change Detection is part of the test, the test behaves in the same way as our application.

For those testing types where the Change Detection is not included, we have to call Signals directly and run TestBed.flushEffects() to execute "dirty side effects".


You can access the repository at https://github.com/rainerhahnekamp/how-do-i-test

If you encounter a testing challenge you'd like me to address here, please get in touch with me!

For additional updates, connect with me on LinkedIn, X, and explore our website for workshops and consulting services on testing.

💖 💪 🙅 🚩
rainerhahnekamp
Rainer Hahnekamp

Posted on March 7, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related