Тестирование библиотеки 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. Это помогает нам справиться со всеми трудностями, возникающими при тестировании асинхронного кода.