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
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();
}));
2
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
1
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();
}));
2
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
.
4