angularjestjsangular-signalsangular-spectatorangular-jest

How to test a signal with jest and Angular 18?


I am currently trying to migrate from rxjs behaviorObject to signals.

We do have some tests that test the update of a behaviorObject.

But when I try to migrate our jest tests to the new signals, I get problems.

example:

I have this service:

import { Injectable, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { BehaviorSubject } from 'rxjs';

export interface SignalState {
  foo: string;
  bar: boolean | undefined;
}

@Injectable({
  providedIn: 'root',
})
export class SignalService {
  // private signalState$ = new BehaviorSubject<SignalState>({ foo: '', bar: undefined }); //signalState bevor
  private signaltState = signal<SignalState>({ foo: '', bar: undefined });
  private signalState$ = toObservable(this.signaltState);
  public getValue = this.signaltState.asReadonly();

  public getSignalState() {
    return this.signalState$;
  }

  public updateFoo(foo: string) {
    this.updateSignal({ foo });
  }


  private isChanged(currentState: SignalState, updatedState: SignalState) {
    return JSON.stringify(currentState) !== JSON.stringify(updatedState);
  }

  private updateSignal(newState: Partial<SignalState>) {
    const currentState = this.getValue();
    const updatedState: SignalState = { ...currentState, ...newState };

    if (this.isChanged(currentState, updatedState)) {
      this.signaltState.set(updatedState);
    }
  }
}

I would like to test the change detection. So basically if this.signaltState.set(updatedState); is NOT called, so change detection is not triggered.

before I had the following test


import { SpectatorService } from '@ngneat/spectator';
import { createServiceFactory } from '@ngneat/spectator/jest';
import { SignalService, SignalState } from './lead-request-state.service';

describe('SignalService', () => {
  let spectator: SpectatorService<SignalService>;
  let service: SignalService;
  const currentState: SignalState = {
    foo: 'someString',
    bar: false
  };

  const createService = createServiceFactory({
    service: SignalService,
  });

  beforeEach(() => {
    spectator = createService();
    service = spectator.inject(SignalService);
    service.getSignalState().next(currentState);
  });

  it('should created service', () => {
    expect(service).toBeTruthy();
  });

  it('should not update state if value is not changed', () => {
    const nextSpy = jest.spyOn(service.getSignalState(), 'next');
    service.updateFoo(currentState.foo);
    expect(nextSpy).not.toHaveBeenCalled();
  });
});

How do I do that using signals?


Solution

  • Actually toObservable just converts the signal to an observable, not a BehaviourSubject so there is no next method to use.

    Instead directly access the signal using the dot accessor method of JavaScript (can access private properties) and update the value using the set method.

    Also JSON.stringify compare is not reliable since the order of the properties are not guaranteed, instead use isEqual method of lodash.

    Basically we check for the set method of the signal and the test cases passes.

    Spec.ts

    import { SpectatorService } from '@ngneat/spectator';
    import { createServiceFactory } from '@ngneat/spectator/jest';
    import { SignalService, SignalState } from './test.service';
    
    describe('LeadRequestStateService', () => {
      let spectator: SpectatorService<SignalService>;
      let service: SignalService;
      const currentState: SignalState = {
        foo: 'someString',
        bar: false,
      };
    
      const createService = createServiceFactory({
        service: SignalService,
      });
    
      beforeEach(() => {
        spectator = createService();
        service = spectator.inject(SignalService);
        service['signaltState'].set(currentState);
      });
    
      it('should created service', () => {
        expect(service).toBeTruthy();
      });
    
      it('should not update state if value is not changed', () => {
        const nextSpy = jest.spyOn(service['signaltState'], 'set');
        service.updateFoo(currentState.foo);
        expect(nextSpy).not.toHaveBeenCalled();
      });
    });
    

    Service.ts

    import { Injectable, signal } from '@angular/core';
    import { toObservable } from '@angular/core/rxjs-interop';
    import { BehaviorSubject } from 'rxjs';
    import { isEqual } from 'lodash';
    
    export interface SignalState {
      foo: string;
      bar: boolean | undefined;
    }
    
    @Injectable({
      providedIn: 'root',
    })
    export class SignalService {
      // private signalState$ = new BehaviorSubject<SignalState>({ foo: '', bar: undefined }); //signalState bevor
      private signaltState = signal<SignalState>({ foo: '', bar: undefined });
      private signalState$ = toObservable(this.signaltState);
      public getValue = this.signaltState.asReadonly();
    
      public getSignalState() {
        return this.signalState$;
      }
    
      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 };
    
        if (this.isChanged(currentState, updatedState)) {
          this.signaltState.set(updatedState);
        }
      }
    }
    

    Stackblitz Demo