Angular 17+ сервисов тестирования с сигналами и эффектами

У меня не очень большой опыт работы с сигналами 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 обладает многими инструментами и рекомендациями по безопасности приложений.


1
113
4

Ответы:

Проблема в том, что вы добавили услуги в 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 и позже:

После 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 эта функция считается предварительной версией для разработчиков, возможно, в нее будут внесены дальнейшие изменения.

Только Angular 16:

Функция не существует, поэтому нам нужно найти другой способ вызвать эффект. Посмотрев официальный 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.