How do I test Signals (signal, computed, effect)?
Tags: testing, webdev, angular, frontend
Published: 2024-03-07 16:00:00

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:

{% embed https://youtu.be/kBKx3tHnzAg %}

---

## 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"](https://github.com/angular/angular/tree/3e703f55e0f8a8ba72b1ea687ad3815c064d3ab4/packages/core/primitives/signals#glitch-free-property)

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

{% embed https://youtu.be/a1iRId4oMrA %}

## Component under Test

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

```typescript
@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
    );
  }
}
```

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:

```typescript
@Injectable({ providedIn: 'root' })
export class SyncService {
  sync(products: Product[]) {
    console.log(products);
  }
}
```

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:

```typescript
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');
});

```

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:

```typescript
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);
});
```

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

The following two tests have to fail:

```typescript
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); 
})
```

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:

```typescript
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);
});
```

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`:

```typescript
@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);
  }
}

```

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:

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

  basketService.increase(1);
  expect(basketService.totalPrice()).toBe(21);
});
```

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:

```typescript
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);
});
```

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:

```typescript
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);
});
``` 

## 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](https://www.linkedin.com/in/rainerhahnekamp), [X](https://twitter.com/rainerhahnekamp), and explore our website for workshops and consulting services on testing.

{% embed https://www.angulararchitects.io/en/training/professional-angular-testing-playwright-edition/ %}