Тестирование библиотеки RxJs может потребовать от программистов изменить свое мышление, поскольку это создает множество проблем. Она не зависит от какой-либо инфраструктуры JavaScript и имеет множество функций, что делает ее очень популярной библиотекой среди технических стеков фронтенда. Реактивный способ создания приложений требует, чтобы разработчики изменили свое представление о поведении приложений во время выполнения. При этом может быть сложно понять поток данных и внутреннее поведение приложения. По моему опыту, один из методов, который помогает разработчикам лучше понять, что делает код, - это писать больше модульных тестов. Они не только улучшают стабильность и ремонтопригодность кода, но и в процессе написания и чтения также улучшают понимание кода разработчиком.

В этой статье я покажу вам некоторые шаблоны и методы, которые помогают мне тестировать реактивный RxJ-код. Я использую их ежедневно, и я думаю, что вы тоже будете использовать их, если вы их еще не используете. В качестве фреймворка для тестирования я использовал одно из лучших доступных решений: Jest.

Наблюдаемый без какой-либо ценности

Одна из распространенных ситуаций - это когда вы хотите протестировать наблюдаемую, которая не возвращает никакого значения. Как и в примере ниже, значения отфильтровываются оператором filter, и в функцию подписки не передаются никакие данные:

of(1, 2, 3)
   .pipe(
      filter(v => v > 3)
   )
   .subscribe(console.log); // no value

Как проверить, не испускается ли значение из наблюдаемого? Наиболее популярным решением является использование функции done, предоставляемой фреймворком Jest. Объявление функции done просто сообщает исполнителю теста, что тест не может быть завершен, пока эта функция не будет вызвана. Бегун ожидает его вызова после завершения of наблюдаемого.

it('verifies that value is greater than 3', (done) => {

   // when
   of(1, 2, 3)
      .pipe(
         filter(v => v > 3)
      )
      .subscribe({
         complete: () => done()
      });
});

Это решение работает, потому что мы знаем точное поведение of создателя. Он испускает все значения в начале, а затем наблюдаемое завершается. А как насчет случаев, когда он не доделывается? Реальный пример этого - когда вам нужно проверить, что Subject или ReplaySubject пусто при запуске:

it('checks if ReplaySubject is empty at start', (done) => {

   // given
   const subject = new ReplaySubject(1);
   // when
   subject
      .subscribe({
         complete: () => done()
      });
});

В этом случае done никогда не будет вызван, потому что подписчик на ReplaySubject никогда не завершает работу.

Мок-функции

И снова Jest предлагает идеальное решение для этого типа тестового примера, которым является имитация функций Jest.

Мок-функции позволяют тестировать связи между кодом, стирая фактическую реализацию функции, фиксируя вызовы функции (и параметры, переданные в этих вызовах), захватывая экземпляры функций-конструкторов при их создании с new и разрешая конфигурацию во время тестирования. возвращаемых значений.

Мы можем просто использовать имитацию, чтобы убедиться, что функция next никогда не вызывалась:

it('checks if ReplaySubject is empty at start', () => {

   // given
   const nextFn = jest.fn();

   // when
   new ReplaySubject(1)
      .subscribe({
         next: value => nextFn(value)
      });

   // then
   expect(nextFn).not.toHaveBeenCalled();
});

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

Вернемся к первому примеру кода и посмотрим, как он будет выглядеть с фиктивными функциями:

it('verifies if value is greater than 3', () => {

   // given
   const nextFn = jest.fn();
   // when
   of(1, 2, 3)
      .pipe(
         filter(v => v > 3)
      )
      .subscribe({
         next: value => nextFn(value)
      });

   // then
   expect(nextFn).not.toHaveBeenCalled();
});

Ошибки теста

Мок-функции также могут использоваться для утверждения ошибок, вызванных наблюдаемыми объектами:

it('should verify value greater than 3', () => {

   // given
   const nextFn = jest.fn(),
      errorFn = jest.fn(),
      completeFn = jest.fn(),
      givenError = new Error('Test error');
   // when
   throwError(givenError)
       .subscribe({
           next: value => nextFn(value),
           error: error => errorFn(error),
           complete: () => completeFn()
      });

   // then
   expect(errorFn).toHaveBeenCalledWith(givenError);
});

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

Долгосрочные наблюдаемые

До сих пор мы видели закономерности в отношении синхронных наблюдаемых, но наиболее распространенные случаи использования в реальной жизни вращаются вокруг наблюдаемых, которые возвращают значение с течением времени. Наличие времени добавляет сложности самому тесту и затрудняет его составление. Давайте посмотрим на этот пример кода:

it('waits for timer', () => {

   // given
   const givenValue = 'Bruce Wayne';

   // when
   timer(3000)
      .pipe(
         mapTo(givenValue)
      )
      .subscribe({
         next: value => {

            // then
            expect(value).toEqual(givenValue);
         }
      });
});

Он использует timer, который создает наблюдаемый объект, который генерирует значение через определенный период времени. Приведенный выше тестовый пример выполняется синхронно, код вызывается построчно. Бегун не ждет, пока будет вызван обратный вызов внутри функции subscribe. Утверждение expect никогда не выполняется.

Чтобы проверить это, нам нужно добавить в тест какую-то задержку. Общий подход заключается в использовании ранее упомянутой функцииdone, которая будет ждать в течение определенного периода времени, пока тест не вызовет ее изнутри.

it('waits for done to end', (done) => {

   // given
   const givenValue = 'Bruce Wayne';

   // when
   timer(3000)
      .pipe(
         mapTo(givenValue)
      )
      .subscribe({
         next: value => {

            // then
            expect(value).toEqual(givenValue);
         },
         complete: () => done()
      });
});

Как вы можете видеть, thetimer выдает значение через определенный период времени, а затем завершается. В этом тесте функция done выполняется по наблюдаемому завершению, поэтому исполнителю тестового примера нужно подождать. Единственная проблема с этим методом тестирования асинхронного кода - это время. Для запуска этого тестового примера требуется поразительные 3 секунды.

Представьте себе проект масштаба предприятия, в котором у вас может быть тысяча таких тестовых примеров. Работа в такой среде была бы настоящей неприятностью.

Поддельные таймеры

И снова Jest помогает нам и приносит нам фальшивые таймеры, которые являются идеальным решением. Они заменяют встроенные функции таймера, то есть setTimeout, setInterval, clearTimeout, clearInterval, поддельной реализацией, которая не зависит от реального времени. Это то, что делает тестовые примеры действительно быстрыми.

it('works fast with fake timers', () => {

   jest.useFakeTimers();

   // given
   const givenValue = 'Bruce Wayne',
      nextFn = jest.fn();

   // when
   timer(3000)
      .pipe(
         mapTo(givenValue)
      )
      .subscribe({
         next: value => nextFn(value)
      });

   jest.advanceTimersByTime(3000);
   // then
   expect(nextFn).toHaveBeenCalledWith(givenValue);
});

Вначале мы заявляем, что будем использовать фальшивые таймеры с jest.useFakeTimers(), а затем после подписки мы переместимся в будущее с функцией jest.advanceTimersByTime, поэтому нам не нужно ждать. Код действует так, как будто он был исполнен синхронно.

Средство выполнения тестов не полагается на режим реального времени, он имитирует течение времени, что делает его быстрым.

Приведенный выше тест - это всего лишь простой пример фальшивых таймеров. Jest предлагает гораздо больше функций, например runAllTimers, runOnlyPendingTimers, runAllTicks. Это очень мощная функция, я настоятельно рекомендую прочитать о ней подробнее в официальной документации.

Проверка серии значений

Одна из самых известных черт наблюдаемых - то, что они возвращают серию значений. С другой стороны, эта полезная функция может стать проблемой при тестировании. Я предполагаю, что наиболее популярный подход - просто сохранить выдаваемые значения в массиве, а затем сделать утверждение против этого массива. На мой взгляд, это дополнительный шаблон, который делает код менее читаемым:

// given
const values = [],
   source$ = of(1, 2, 3)
      .pipe(
         map(v => v * 3)
      );
// when
   source$.subscribe(v => values.push(v));

// then
expect(values).toEqual([3, 6, 9]);

Библиотека RxJs предоставляет нам оператор под названием toArray, который собирает все выданные значения и возвращает их, когда наблюдаемое завершается. Так что он идеально подходит для нашего случая:

it('tests a series of values with the toArray operator', () => {

   // given
   const values = [],
      source$ = of(1, 2, 3)
         .pipe(
            map(v => v * 3)
         );
   // when
   source$
      .pipe(
         toArray()
      )
      .subscribe(values => {
   
         // then
         expect(values).toEqual([3, 6, 9]);
      });
});

Мок-функция с серией значений

Другой подход к этой проблеме - убедиться, что функция шутки-имитация вызывалась много раз. В приведенном ниже примере вы можете видеть, что есть три утверждения для функции nextFn, которая проверяет, была ли она вызвана с ожидаемыми значениями.

it('tests a series of values with a mock function', () => {

   // given
   const nextFn = jest.fn();

   // when
   of(1, 2, 3)
      .pipe(
         map(value => value * 3)
      )
      .subscribe(value => nextFn(value));

   // then
   expect(nextFn).toHaveBeenCalledWith(3);
   expect(nextFn).toHaveBeenCalledWith(6);
   expect(nextFn).toHaveBeenCalledWith(9);
});

Все примеры кода находятся в этом репозитории Github.

Резюме

RxJs - отличная библиотека, которая позволяет программистам внедрять реактивность в свои веб-приложения, и ее тестирование создает множество проблем. К счастью, у нас есть мощный набор инструментов, таких как фиктивные функции, поддельные таймеры, операторы RxJs, сопоставители Jest. Это помогает нам справиться со всеми трудностями, возникающими при тестировании асинхронного кода.