Search code examples
angularangular-testangular-signals

Angular 17+ testing services with signals and effects


I am not very experienced with Angular signals, especially with services with signals and effects.

Basically, I have a service A which exposes a public method that sets/updates a private signal in the service. Anytime the value of the signal in service A changes, it triggers an effect (called in the constructor of the service), which effect calls a private method of service A. The private method is used to call a number of other different services' methods, but for the sake of simplicity let's just say that it's only one service - service B, and a exposed method of service B.

The code works as it's supposed to, but I need to write tests for this system and it seems like I cannot wrap my head around how services with signals, especially the triggered effects.

The goal of the test is to verify that once public method of service A (which updates the signal) is called, that also the whole chain happens, i.e. eventually public method of service B is called.

I've tried a number of different solutions, including using fakeAsunc + tick, TestBed.flushEffects, runInInjectionContext, and many other hacky solutions that defeat the purpose of writing tests.

Example:

@Injectable({
  providedIn: 'root'
})
export class ServiceA {
  private signalA: Signal<number> = signal(0);

  constructor(private readonly serviceB: ServiceB) {
    effect(() => {
      const signalAValue = signalA(); 
      this.privateMethod(signalAValue);
    });
  }

  public publicMethodA(value: number): void {
    this.signalA.update(value);
  }

  private privateMethodA(arg: number): void {
    this.serviceB.publicMethodB(arg)
  }
}

Test for ServiceA:

describe('ServiceA', () => {
  let serviceA: ServiceA;
  let serviceB: ServiceB;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        ServiceA,
        ServiceB
      ]
    });

    serviceA = TestBed.inject(ServiceA);
    serviceB = TestBed.inject(ServiceB);
  });

  it('should call publicMethodB of ServiceB when publicMethodA of ServiceA is called', fakeAsync(() => {
    jest.spyOn(serviceA, 'publicMethodA');
    service.publicMethod(1);

    expect(serviceB.publicMethodB).toHaveBeenCalled();
  }));
});

Test fails with:

   Expected number of calls: >= 1
    Received number of calls:    0

Solution

  • The reason why you can't trigger the update of your signal is connected to the ability to execute the effect.


    Angular 17 and after:

    Post Angular 17, you can use the function TestBed.flushEffect()s, like so:

    it('should call publicMethodB of ServiceB when publicMethodA of   ServiceA is called', fakeAsync(() => {
        jest.spyOn(serviceB, 'publicMethodB');
        service.publicMethod(1);
    
        TestBed.flushEffects(); // <- This!
        expect(serviceB.publicMethodB).toHaveBeenCalled();
    }));
    

    Note: in Angular 17 and 18, the function is considered developer preview, it is possible further changes to it are done.

    Angular 16 only:

    The function does not exist, so we need to find another way to do trigger the effect. Looking at the official DOC for clues, we find the following for components:

    When the value of that signal changes, Angular automatically marks the component to ensure it gets updated the next time change detection runs.

    Furthermore:

    Effects always execute asynchronously, during the change detection process.

    Therefor, the easiest way to achieve your goal in Angular 16 is by creating a dummy component, and calling the change detection on it.

    @Component({
      selector: 'test-component',
      template: ``,
    })
    class TestComponent {}
    
    describe('ServiceA', () => {
      let serviceA: ServiceA;
      let serviceB: ServiceB;
      // We add the fixture so we can access it across specs
      let fixture: ComponentFixture<TestComponent>;
    
      beforeEach(() => {
        TestBed.configureTestingModule({
          providers: [
            ServiceA,
            ServiceB,
          ]
        });
    
        serviceA = TestBed.inject(ServiceA);
        serviceB = TestBed.inject(ServiceB);
        fixture = TestBed.createComponent(TestComponent);
      });
    
      it('should update the signalA value when publicMethodA is called but not call the publicMethodB of ServiceB', () => {~
        jest.spyOn(serviceB, 'publicMethodB');
        serviceA.publicMethodA(1);
    
        expect(serviceA['signalA']()).toEqual(1);
        expect(serviceB.publicMethodB).not.toHaveBeenCalled();
      });
    
      it('should update the signalA value and call publicMethodB of the ServiceB when publicMethodA', () => {
        jest.spyOn(serviceB, 'publicMethodB');
        serviceA.publicMethodA(1);
        fixture.detectChanges();
    
        expect(serviceA['signalA']()).toEqual(1);
        expect(serviceB.publicMethodB).toHaveBeenCalled();
      });
    });
    

    To improve our knowledge, lets understand what the TestBedd.flushEffects method actually does:

      /**
       * Execute any pending effects.
       *
       * @developerPreview
       */
      flushEffects(): void {
        this.inject(EffectScheduler).flush();
      }
    

    So this just triggers a normal flush event with the EffectScheduler. Digging a bit more leads to this file:

    export abstract class EffectScheduler {
      /**
       * Schedule the given effect to be executed at a later time.
       *
       * It is an error to attempt to execute any effects synchronously during a scheduling operation.
       */
      abstract scheduleEffect(e: SchedulableEffect): void;
    
      /**
       * Run any scheduled effects.
       */
      abstract flush(): void;
    
      /** @nocollapse */
      static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
        token: EffectScheduler,
        providedIn: 'root',
        factory: () => new ZoneAwareEffectScheduler(),
      });
    }
    
    /**
     * A wrapper around `ZoneAwareQueueingScheduler` that schedules flushing via the microtask queue
     * when.
     */
    export class ZoneAwareEffectScheduler implements EffectScheduler {
      private queuedEffectCount = 0;
      private queues = new Map<Zone | null, Set<SchedulableEffect>>();
      private readonly pendingTasks = inject(PendingTasks);
      private taskId: number | null = null;
    
      scheduleEffect(handle: SchedulableEffect): void {
        this.enqueue(handle);
    
        if (this.taskId === null) {
          const taskId = (this.taskId = this.pendingTasks.add());
          queueMicrotask(() => {
            this.flush();
            this.pendingTasks.remove(taskId);
            this.taskId = null;
          });
        }
      }
    
      private enqueue(handle: SchedulableEffect): void {
        ...
      }
    
      /**
       * Run all scheduled effects.
       *
       * Execution order of effects within the same zone is guaranteed to be FIFO, but there is no
       * ordering guarantee between effects scheduled in different zones.
       */
      flush(): void {
        while (this.queuedEffectCount > 0) {
          for (const [zone, queue] of this.queues) {
            // `zone` here must be defined.
            if (zone === null) {
              this.flushQueue(queue);
            } else {
              zone.run(() => this.flushQueue(queue));
            }
          }
        }
      }
    

    During an Angular app normal functioning, effects will be scheduled for execution via the ZoneAwareEffectScheduler. The engine will then deal with each effect as they are executed (ChangeDetection, browser events and others trigger the execution).

    What the TestBed.flushEffects is it provides us with a way to run these effects but exposing an entry point to execute them on the ZoneAwareEffectScheduler.