У меня не очень большой опыт работы с сигналами Angular, особенно с сервисами с сигналами и эффектами.
По сути, у меня есть служба A, которая предоставляет общедоступный метод, который устанавливает/обновляет частный сигнал в службе. Каждый раз, когда значение сигнала в сервисе A изменяется, он запускает эффект (вызываемый в конструкторе сервиса), который вызывает частный метод сервиса A. Приватный метод используется для вызова ряда других методов различных сервисов. , но для простоты скажем, что это только один сервис — сервис B и открытый метод сервиса B.
Код работает так, как должен, но мне нужно написать тесты для этой системы, и я, похоже, не могу уложить в голове, как обслуживаются сигналы, особенно триггерные эффекты.
Цель теста — убедиться, что после вызова общедоступного метода службы A (который обновляет сигнал) происходит вся цепочка, т. е. в конечном итоге вызывается общедоступный метод службы B.
Я пробовал множество различных решений, в том числе использование fakeAsunc + Tick, TestBed.flushEffects, runInInjectionContext и многих других хакерских решений, которые противоречит цели написания тестов.
Пример:
@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)
}
}
Тест для 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();
}));
});
Тест не пройден с:
Expected number of calls: >= 1
Received number of calls: 0
🤔 А знаете ли вы, что...
Angular обладает многими инструментами и рекомендациями по безопасности приложений.
Проблема в том, что вы добавили услуги в providers
модуля тестирования Test Bed. Как следствие, экземпляр ServiceB
, внедренный ServiceA
, отличается от экземпляра, за которым вы шпионите. Просто удалите услуги от провайдеров и всё должно заработать.
Дальнейшее чтение: https://angular.dev/guide/di/dependent-injection
Поскольку вы шпионили за publicMethodA
, метод никогда не вызывается, потому что шпион останавливает выполнение фактического метода. Я думаю, вам нужно вместо этого шпионить за методом publicMethodB службы B.
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();
}));
Эффект не сбрасывается автоматически. Это нужно сделать самому с 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();
}));
Причина, по которой вы не можете запустить обновление вашего сигнала, связана с возможностью выполнения эффекта.
После Angular 17 вы можете использовать функцию TestBed.flushEffect()s
, вот так:
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();
}));
Примечание: в Angular 17 и 18 эта функция считается предварительной версией для разработчиков, возможно, в нее будут внесены дальнейшие изменения.
Функция не существует, поэтому нам нужно найти другой способ вызвать эффект. Посмотрев официальный DOC в поисках подсказок, мы находим следующее о компонентах:
Когда значение этого сигнала изменяется, Angular автоматически помечает компонент, чтобы гарантировать его обновление при следующем запуске обнаружения изменений.
Более того:
Эффекты всегда выполняются асинхронно во время процесса обнаружения изменений.
Поэтому самый простой способ достичь своей цели в Angular 16 — создать фиктивный компонент и вызвать в нем функцию обнаружения изменений.
@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();
});
});
Чтобы улучшить наши знания, давайте поймем, что на самом деле делает метод TestBedd.flushEffects
:
/**
* Execute any pending effects.
*
* @developerPreview
*/
flushEffects(): void {
this.inject(EffectScheduler).flush();
}
Таким образом, это просто запускает обычное событие сброса с помощью EffectScheduler
.
Если покопаться еще немного, то можно найти этот файл:
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));
}
}
}
}
Во время нормального функционирования приложения Angular эффекты будут запланированы для выполнения через ZoneAwareEffectScheduler
. Затем движок будет обрабатывать каждый эффект по мере его выполнения (ChangeDetection, события браузера и другие запускают выполнение).
Что за TestBed.flushEffects
, он дает нам возможность запускать эти эффекты, но предоставляет точку входа для их выполнения на ZoneAwareEffectScheduler
.