Logo
Published on

A Practical Guide to Testing Custom Angular Signals

Authors
  • Name
    Twitter

An abstract, black and white image of a screen depicting a geometric image surrounded by geometric shapes. Obviously AI generated.

Previously I wrote about crafting custom signals and thought it would be a good idea to follow that up with a guide on how to test them. I’ve spent a lot of time writing tests for custom signals in the library Angular Signal Generators, and I’ve gotten pretty good at writing quick and effective tests. Hopefully the tips below will help you write tests faster, as well as cover important cases you may overlook.

You Don’t Need to Create a Component Fixture

The standard setup for tests usually involves configuring a testing module with TestBed and instantiating a component fixture. While signals are primarily utilized in components, there is usually no need for any of that test setup when testing signals. This is because basic signal primitives like writeable and computed signals don’t interact with anything beyond the signal graph.

The primary exception to this rule is when effect is involved. Effects are tied to the renderer and will only update when an update has been scheduled such as when change detection occurs. They also must be created in an injection context which must come from TestBed or a component fixture.

TestBed Methods to Assist With Effects

You might think you need a component fixture once effects are incorporated into your signals or tests, but this can be avoided most of the time with the help of a couple built in static TestBed methods.

  • flushEffects — This will call flush() on the current effect scheduler. For the default scheduler this just means it should run all effects that have an updated dependency or haven’t had their initial run.
  • runInInjectorContext — Executes a function passed in as in argument inside the EnvironmentInjector. Note that the EnvironmentInjector is going to be at a higher level than an Injector associated with a componentRef.

There might be some cases where you need a component fixture. For example, TestBed.flushEffects is not a method in Angular 16, so the only way to get effects to run is by calling fixture.detectChanges. If you find that you need a component fixture then ngMocks MockRender function can reduce some pain by shortening the the length of test setup:

describe("mySignal", () => {
  let fixture: MockedComponentFixture<void, void>;

  beforeEach(() => (fixture = MockRender()));

  /* tests */
});

Testing Utilities

Writing tests can be a chore. Here are a few utility functions to help speed things up.

Automating calls to flushEffects

Tests involving effects will often involve repeated calls to flushEffects. Here’s a utility to help save time by calling flushEffects after every method call.

export  function autoFlushSignal<S extends  Signal<unknown>>(source: S): S;
export  function autoFlushSignal<T>(source: T): WritableSignal<T>;
export  function autoFlushSignal<T, S extends  Signal<T>>(
source: S | T
): S | WritableSignal<T> {
const output = isSignal(source) ? source : signal(source);
const proxy = new  Proxy(output, {
get(target, propName: keyof typeof output, receiver) {
const propVal = Reflect.get(target, propName, receiver);
if (typeof propVal === 'function') {
return  new  Proxy(propVal, {
apply: (targetInner, thisArg, argumentsList) => {
const res = Reflect.apply(targetInner, thisArg, argumentsList);
TestBed.flushEffects();
return res;
}
});
}
return propVal;
}
});

return proxy;
}

A few things to note about this function:

  • I originally monkey patched the source signal by reassigning the methods with a wrapper function. This actually resulted in side-effects, which could only be corrected by using Proxy.
  • This method can be rewritten to accept ComponentFixture and call detectChanges instead of flushEffects. This might be useful for broader tests or maintaining backwards compatibility to Angular 16.

Tracking Updates

A computed signal will only execute its computation function when its called and its never been called before or one of its dependencies have changed. So a good way to check if a change to a signal is causing updates is by creating a computed signal that tracks the number of times it executes.

The computedSpy function wraps the computation function with a function that increments a counter each time it executes. This counter is accessible via a property added to the returned signal and can be used in assertions. A similar function can be written for effects.

type  ComputedSpy<T> = Signal<T> & { timesUpdated: number };

/** Creates a computed signal that monitors the number of times it is updated. */
export  function computedSpy<T>(computation: () => T, options?: CreateComputedOptions<T>): ComputedSpy<T> {
let timesUpdated = 0;
const output = computed(() => {
timesUpdated++;
return  computation();
}, options);
Object.defineProperty(output, 'timesUpdated', { get: () => timesUpdated });
return output as  ComputedSpy<T>;
}

Standard Test Specs

A custom signal that is unit tested exhaustively can still have issues. This is because signals can have a lot interactions that are obscured by their neat design. Because of this, it’s a good idea to have a standard set of tests you can run on every signal.

What follows are few examples of tests adapted from Angular Signal Generators. These specific tests were included because they represent times real-world issues were encountered and needed testing against. While I’m not completely comfortable with they state of these tests in the project, you might want to check them out to see how they were made to be reusable.

Passes isSignal function

This is the type of thing that some folks might consider over testing, but including it will save you from an embarrassing oversight.

it("gets a true result when it is passed to isSignal", () => {
  expect(isSignal(mySignalFn())).toEqual(true);
});

Testing if a change to a signal causes an update

It’s easy to assume when the state of a signal changes the state of a computed signal returning that signal’s state should also change. But there are cases when this might not be such as when returning a signal returns an object reference.

it("should cause a computed signal to update when updated", () => {
  const sut = mySignalFn();
  const computedSignal = computed(() => sut());
  computedSignal(); // initial execution
  expect(computedSignal.timesUpdated).toEqual(1);
  sut.performSomeUpdate(); // should change value of function
  computedSignal(); // The computed still shouldn't get recalcualted.
  expect(computedSignal.timesUpdated).toEqual(2);
});

The signal creation function doesn’t cause extra invocations

This test checks that there is not some internal signal being used inside of the custom signal that causes a computed signal or effect to update. If it were to fail, then there is likely a signal that needs to be wrapped in an untracked function.

Note that this only tests the signal factory function. A signal’s methods may also be relying on signals internally and could cause a chain reaction of dependency updates.

it("does not cause depedency updates when invoked inside computed.", () => {
  const computedSignal = computedSpy(() => ({ sut: mySignalFn() }));
  computedSignal(); // initial execution
  computedSignal().sut.executeSomeFunction(); // cause sut's value to change.
  computedSignal(); // The computed still shouldn't get recalcualted.
  expect(computedSignal.timesUpdated).toEqual(1);
});

Putting it All Together

Let’s put together a simple test suite so we can see everything discussed in action. At the end of Crafting Custom Angular Signals I wrote about a simple, asynchronous signal called debounced. This signal relies on an effect internally so it should force us to use everything from above.

describe("debounced", () => {
  standardTestSetup(
    () => debounced("q", 500),
    (sut) => {
      sut.set("zzz");
      tick(500);
    }
  );

  it("should return initial value from resulting signal", () => {
    const sut = TestBed.runInInjectionContext(() => debounced("x", 500));
    expect(sut()).toBe("x");
  });

  it("#set should be debounced", fakeAsync(() => {
    const sut = TestBed.runInInjectionContext(() =>
      autoFlushSignal(debounced("x", 500))
    );
    sut.set("z");
    tick(499);
    expect(sut()).toBe("x");
    tick(1);
    expect(sut()).toBe("z");
  }));

  it("#update should be debounced", fakeAsync(() => {
    const sut = TestBed.runInInjectionContext(() =>
      autoFlushSignal(debounced("x", 500))
    );
    sut.update((x) => x + "z");
    tick(499);
    expect(sut()).toBe("x");
    tick(1);
    expect(sut()).toBe("xz");
  }));

  it("should use injector when passed into options", fakeAsync(() => {
    const injector = TestBed.inject(Injector);
    const sut = autoFlushSignal(debounced("x", 500, { injector }));
    sut.set("q");
    tick(500);
    expect(sut()).toBe("q");
  }));
});

Some notes about this test suite:

  • For the sake of brevity, let’s assume that all of the standard tests have been rolled into their own function and have been adapted to work with asynchronous signals.
  • In case you’re not familiar with it, running a test inside fakeAsync allows the test to simulate the passage of time. The debounced signal relies on setTimeout internally and calling tick causes it to execute its callback after the appropriate amount of simulated time has elapsed.
  • Because effect is used within debounced, initialization of the signal in most tests is wrapped inside runInInjectionContext. It would’ve been just as easy to wrap the entire test spec instead and may make the test clearer.
  • The signal initialization is also wrapped in autoFlushSignal in most tests. Again the is because the usage of effects and it saves us from having to write TestBed.flushEffects after each call to set or update.
  • The signal can accept an injector reference as an option. This is the one case where we don’t use runInInjectionContext as the function should rely on the passed value when creating an effect and do consider adding tests that interact with other .

Conclusion

As you can see, tests for custom signals don’t have to be complicated. You can usually avoid any shared setup and verbose test specifications. Just remember to take extra care when effects are involved, and do consider writing tests to ensure proper integration with other signal components like computed and effect.