Nenad
Nenad

Reputation: 13

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

Upvotes: 1

Views: 4045

Answers (4)

IDK4real
IDK4real

Reputation: 1062

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.

Upvotes: 4

Matthieu Riegler
Matthieu Riegler

Reputation: 55544

Effect don't flush automatically. You need to do it yourself with TestBed.flushEffect.

it('should call publicMethodB of ServiceB when publicMethodA of ServiceA is called', fakeAsync(() => {
    jest.spyOn(serviceB, 'publicMethodB');
    service.publicMethod(1);
    TestBed.flushEffect(); // You need to manually flush the effects 
    expect(serviceB.publicMethodB).toHaveBeenCalled();
}));

Upvotes: 1

Naren Murali
Naren Murali

Reputation: 57756

Since you spied on publicMethodA the method is never called, because the spy stops the execution of the actual method. I think you need to spy on method publicMethodB of service B instead.

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

Upvotes: 0

JSON Derulo
JSON Derulo

Reputation: 17708

The problem is that you added the services to the providers of the Test Bed testing module. As a consequence, the instance of ServiceB injected by ServiceA is a different one than the instance you are spying on. Just remove the services from the providers and it should work.

Further reading: https://angular.dev/guide/di/dependency-injection

Upvotes: 0

Related Questions