angularjestjsangular-signalsangular-spectatorangular-jest

Only update signal if other signal value actually changed


After getting the answer for how to test an update triggered for a signal (my question here , I have another situation around testing signals.

I created a GitHub project.

in the old folder you can see my behaviorSubjects and how I test them. In the new folder I try to migrate to signals. But the change detection does not seem to work as I intend.

When I use a computed signal the signal gets updated on change. However I want to only update my signal and therefore propagate changes, if the value I am storing actually changed. So I tried to do that similar to before in the constructor.

  constructor() {
    const signalState = this.signalService.getValue();
    if (signalState.foo !== this.usingSignalState()) {
      this.usingSignalState.set(signalState.foo);
    }
  }

But there it does not seem that I signed up for the change detection, because the test is red.

What am I doing wrong?


Solution

  • The first thing to know is that signal has its own equals function, which determines when a signal has changed. I am using lodash isEqual function to determine the equality (atleast one property has to be changed for change to be registered).

    In your scenario, you can just check foo property and trigger change detection.

    export class SignalService {
        private signalState = signal<SignalState>({ foo: "", bar: undefined }, {
          equal: this.isChanged // <- notice!
        });
        public getValue = this.signalState.asReadonly();
    
        public updateFoo(foo: string) {
          this.updateSignal({ foo });
        }
    
        private isChanged(currentState: SignalState, updatedState: SignalState) {
          return isEqual(currentState, updatedState); // <- notice!
        }
    

    This eliminates a lot of if conditions in your code. But next thing you need to setup is a way to testing for changes happening on a signal. For achieving this, I am using a testing service that inherits the main service.

    The point of this is to expose an observable that is created from the signal using toObservable. The main point of this property is to trigger when change has happened on the signal.

    But signal is a ReplaySubject so it will fire on initial subscription also, to eliminate this initial emit (from the replay subject), I am using skip(1), now you can check for signal changes, since the observable emits a value.

    @Injectable()
    export class UsingSignalServiceTesting extends UsingSignalService {
      signalChange$ = toObservable(this['signalService'].getValue).pipe(
        skip(1),
      );
    }
    

    Finally, using this approach, we can emit a value, but signals internal equal, method will filter out the same value emissions.

    Also notice that I create a spy that gets called on subscribe, this is used for detecting that a change has happened on the signal.

    Since we using asynchronous subscribe, you need fakeAsync and flush to wait for the subscribe to complete.

    it("should update state when signalState changes", fakeAsync(() => {
        const signalService = spectator.inject(SignalService);
        const spyFn = jest.fn();
        service.signalChange$.subscribe(spyFn);
        signalService["signalState"].set({ foo: "123", bar: true });
        flush();
        expect(spyFn).toHaveBeenCalled();
    }));
    

    Full Code:

    signal.service.ts

    import { Injectable, signal } from "@angular/core";
    import { isEqual } from "lodash";
    
    export interface SignalState {
      foo: string;
      bar: boolean | undefined;
    }
    
    @Injectable({
      providedIn: "root",
    })
    export class SignalService {
        private signalState = signal<SignalState>({ foo: "", bar: undefined }, {
          equal: this.isChanged
        });
        public getValue = this.signalState.asReadonly();
    
        public updateFoo(foo: string) {
          this.updateSignal({ foo });
        }
    
        private isChanged(currentState: SignalState, updatedState: SignalState) {
          return isEqual(currentState, updatedState);
        }
    
      private updateSignal(newState: Partial<SignalState>) {
        const currentState = this.getValue();
        const updatedState: SignalState = { ...currentState, ...newState };
        this.signalState.set(updatedState);
      }
    }
    

    using-signal.service.ts

    import { computed, inject, Injectable, signal } from "@angular/core";
    import { SignalService } from "./signal.service";
    
    export interface SignalState {
      foo: string;
      bar: boolean | undefined;
    }
    
    @Injectable({
      providedIn: "root",
    })
    export class UsingSignalService {
      private signalService = inject(SignalService);
      public computedSignal = computed(() => this.signalService.getValue().foo);
    
      constructor() {
      }
    }
    

    using-signal.service.spec.ts

    import { SpectatorService } from "@ngneat/spectator";
    import { createServiceFactory } from "@ngneat/spectator/jest";
    import { SignalService } from "./signal.service";
    import { UsingSignalService } from "./using-signal.service";
    import { Injectable, inject } from "@angular/core";
    import {toObservable} from "@angular/core/rxjs-interop";
    import { fakeAsync, flush } from "@angular/core/testing";
    import { skip } from "rxjs";
    
    @Injectable()
    export class UsingSignalServiceTesting extends UsingSignalService {
      signalChange$ = toObservable(this['signalService'].getValue).pipe(
        skip(1),
      );
    }
    
    describe("SignalStateService", () => {
      let spectator: SpectatorService<UsingSignalServiceTesting>;
      let service: UsingSignalServiceTesting;
      const currentState = {
        foo: "",
        bar: false,
      };
    
      const createService = createServiceFactory({
        service: UsingSignalServiceTesting,
      });
    
      beforeEach(() => {
        spectator = createService();
        service = spectator.inject(UsingSignalServiceTesting);
      });
    
      it("should update computed signal when state changes", () => {
        const signalService = spectator.inject(SignalService);
        signalService["signalState"].set({ foo: "123", bar: true });
        expect(spectator.service.computedSignal()).toEqual("123");
      });
    
      it("should update state when signalState changes", fakeAsync(() => {
        const signalService = spectator.inject(SignalService);
        const spyFn = jest.fn();
        service.signalChange$.subscribe(spyFn);
        signalService["signalState"].set({ foo: "123", bar: true });
        flush();
        expect(spyFn).toHaveBeenCalled();
      }));
    
      it("should not update state when signalState is the same", fakeAsync(() => {
        const signalService = spectator.inject(SignalService);
        const spyFn = jest.fn();
        service.signalChange$.subscribe(spyFn);
        signalService["signalState"].set({ foo: "", bar: undefined });
        flush();
        expect(spyFn).not.toHaveBeenCalled();
      }));
    }); 
    

    Github Repo