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
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Angular и React для вашего проекта веб-разработки?
Angular и React для вашего проекта веб-разработки?
Когда дело доходит до веб-разработки, выбор правильного front-end фреймворка имеет решающее значение. Angular и React - два самых популярных...
Эпизод 23/17: Twitter Space о будущем Angular, Tiny Conf
Эпизод 23/17: Twitter Space о будущем Angular, Tiny Conf
Мы провели Twitter Space, обсудив несколько проблем, связанных с последними дополнениями в Angular. Также прошла Angular Tiny Conf с 25 докладами.
Угловой продивер
Угловой продивер
Оригинал этой статьи на турецком языке. ChatGPT используется только для перевода на английский язык.
Мое недавнее углубление в Angular
Мое недавнее углубление в Angular
Недавно я провел некоторое время, изучая фреймворк Angular, и я хотел поделиться своим опытом со всеми вами. Как человек, который любит глубоко...
Освоение Observables и Subjects в Rxjs:
Освоение Observables и Subjects в Rxjs:
Давайте начнем с основ и постепенно перейдем к более продвинутым концепциям в RxJS в Angular
1
0
113
4
Перейти к ответу Данный вопрос помечен как решенный

Ответы 4

Проблема в том, что вы добавили услуги в providers модуля тестирования Test Bed. Как следствие, экземпляр ServiceB, внедренный ServiceA, отличается от экземпляра, за которым вы шпионите. Просто удалите услуги от провайдеров и всё должно заработать.

Дальнейшее чтение: https://angular.dev/guide/di/dependent-injection

Массив провайдеров эквивалентен предоставленномуIn 'root', поэтому я считаю, что этот ответ неверен!

Naren Murali 16.07.2024 17:26

Поскольку вы шпионили за 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.

JSON Derulo 16.07.2024 17:26

Afaik flush() не сбрасывает эффекты, это делает ` TestBed.flushEffect()`.

Matthieu Riegler 16.07.2024 18:52

Эффект не сбрасывается автоматически. Это нужно сделать самому с 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

JSON Derulo 16.07.2024 19:29

Это работает только в версии 17/18. Эта функция недоступна в коде Angular 16, поэтому я добавил ответ с этим и обходным путем, а также некоторые пояснения.

IDK4real 16.07.2024 20:12
Ответ принят как подходящий

Причина, по которой вы не можете запустить обновление вашего сигнала, связана с возможностью выполнения эффекта.


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.

Это просто ложь. CD не играет роли, если нет компонента. Этот тест посвящен исключительно внедренным сервисам.

Matthieu Riegler 16.07.2024 19:09

Согласно официальному документу, эффекты запускаются только во время цикла обнаружения изменений. Если у вас нет цикла обнаружения изменений, вы не сможете запускать эффекты. Вы можете взять мой код, и он будет работать отлично благодаря тому, о чем я говорю. Если это неверно, укажите, какая часть неверна, поскольку я основывал это на официальном документе и предоставил рабочий пример.

IDK4real 16.07.2024 19:16

Документы, на которые вы указываете, говорят о компонентах. Это верно в отношении компонентов, но эффект может существовать вне любых компонентов, отсюда и отрицательное мнение. Вот репродукция: stackblitz.com/edit/…

Matthieu Riegler 16.07.2024 19:20

Я рассмотрел свой ответ на основе того, что вы упомянули. Я сосредоточился на версии Angular 16, в которой нет функции TestBed.flushEffects, и это текущий обходной путь. Кроме того, я также добавил больше деталей, чтобы объяснить, что происходит, и исправить свое недоразумение. Эффекты планируются, а затем выполняются движком/браузером angular. fixment.detectChanges — это просто еще один способ запуска запланированных эффектов. Спасибо!

IDK4real 16.07.2024 20:11

Другие вопросы по теме