У меня не очень большой опыт работы с сигналами 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
Проблема в том, что вы добавили услуги в 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();
}));
Маловероятно, что это проблема, поскольку на это укажет TypeScript.
Afaik flush()
не сбрасывает эффекты, это делает ` TestBed.flushEffect()`.
Эффект не сбрасывается автоматически. Это нужно сделать самому с 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();
}));
Это правильное решение. Я проверил это в StackBlitz: stackblitz.com/edit/stackblitz-starters-iwdbt8
Это работает только в версии 17/18. Эта функция недоступна в коде Angular 16, поэтому я добавил ответ с этим и обходным путем, а также некоторые пояснения.
Причина, по которой вы не можете запустить обновление вашего сигнала, связана с возможностью выполнения эффекта.
После 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
.
Это просто ложь. CD не играет роли, если нет компонента. Этот тест посвящен исключительно внедренным сервисам.
Согласно официальному документу, эффекты запускаются только во время цикла обнаружения изменений. Если у вас нет цикла обнаружения изменений, вы не сможете запускать эффекты. Вы можете взять мой код, и он будет работать отлично благодаря тому, о чем я говорю. Если это неверно, укажите, какая часть неверна, поскольку я основывал это на официальном документе и предоставил рабочий пример.
Документы, на которые вы указываете, говорят о компонентах. Это верно в отношении компонентов, но эффект может существовать вне любых компонентов, отсюда и отрицательное мнение. Вот репродукция: stackblitz.com/edit/…
Я рассмотрел свой ответ на основе того, что вы упомянули. Я сосредоточился на версии Angular 16, в которой нет функции TestBed.flushEffects, и это текущий обходной путь. Кроме того, я также добавил больше деталей, чтобы объяснить, что происходит, и исправить свое недоразумение. Эффекты планируются, а затем выполняются движком/браузером angular. fixment.detectChanges — это просто еще один способ запуска запланированных эффектов. Спасибо!
Массив провайдеров эквивалентен предоставленномуIn 'root', поэтому я считаю, что этот ответ неверен!