15. Тестирование RxJS
16. Тестирование RxJS 🧪
Заголовок раздела «16. Тестирование RxJS 🧪»Привет! Яша на связи. Тестирование асинхронного кода — сложная задача. RxJS предоставляет инструменты, которые делают тестирование потоков предсказуемым и быстрым: TestScheduler и мраморное тестирование.
Почему тестировать RxJS сложно?
Заголовок раздела «Почему тестировать RxJS сложно?»// Обычный тест с реальным временем — медленно и ненадёжноit('debounce работает', (done) => { const result: number[] = []; source$.pipe(debounceTime(300)).subscribe(v => result.push(v));
// Придётся ждать реальные 300+мс в каждом тесте setTimeout(() => { expect(result).toEqual([42]); done(); }, 400); // 😱 медленно});
// С TestScheduler — мгновенно!testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('--a--|'); const result$ = source$.pipe(delay(30)); expectObservable(result$).toBe('-----a--|'); // проверяется немедленно ✅});Синтаксис мраморных диаграмм
Заголовок раздела «Синтаксис мраморных диаграмм»В TestScheduler каждый символ в строке представляет 10 виртуальных миллисекунд:
Символ Значение─────────────────────────────────────────────- 10мс паузы (один фрейм времени)a-z Значение (a = {a: 'a'}, или задаётся через values)| Успешное завершение потока^ Момент подписки (в hot observable)! Момент отписки( ) Синхронная группа — всё в одном фрейме// Примеры мраморных строк'--a--b--c|' // a через 20мс, b через 50мс, c через 80мс, complete 90мс'--a--b--#' // a, b, затем ошибка'(abc|)' // a, b, c, complete — все синхронно в одном фрейме'--^--a--b--|' // подписка на 20мс, a на 50мс, complete на 110мс (для hot)'^--a--! // подписка, a, отписка на 70мсTestScheduler
Заголовок раздела «TestScheduler»import { TestScheduler } from 'rxjs/testing';
// Настройка TestSchedulerconst testScheduler = new TestScheduler((actual, expected) => { // Можно использовать любую библиотеку сравнения expect(actual).toEqual(expected); // Jest/Jasmine});
// Базовый синтаксисtestScheduler.run(({ cold, hot, expectObservable, expectSubscriptions }) => { // cold — холодный Observable (производит данные для каждого подписчика) // hot — горячий Observable (данные идут независимо от подписки) // expectObservable — проверяем что Observable испускает // expectSubscriptions — проверяем когда были подписки/отписки});Тест cold Observable
Заголовок раздела «Тест cold Observable»import { TestScheduler } from 'rxjs/testing';import { map, filter } from 'rxjs/operators';
describe('Операторы RxJS', () => { let scheduler: TestScheduler;
beforeEach(() => { scheduler = new TestScheduler((actual, expected) => { expect(actual).toEqual(expected); }); });
it('map умножает на 2', () => { scheduler.run(({ cold, expectObservable }) => { const source$ = cold('--a--b--c|', { a: 1, b: 2, c: 3 }); const result$ = source$.pipe(map(x => x * 2));
expectObservable(result$).toBe('--a--b--c|', { a: 2, b: 4, c: 6 }); }); });
it('filter оставляет чётные', () => { scheduler.run(({ cold, expectObservable }) => { const source$ = cold('a-b-c-d|', { a: 1, b: 2, c: 3, d: 4 }); const result$ = source$.pipe(filter(x => x % 2 === 0));
expectObservable(result$).toBe('-b---d|', { b: 2, d: 4 }); // нечётные отфильтрованы }); });});Тест с задержкой времени
Заголовок раздела «Тест с задержкой времени»it('debounceTime работает за 300мс', () => { scheduler.run(({ cold, expectObservable }) => { // Быстрые нажатия клавиш const keystrokes$ = cold('-a-b-cde----|', { a: 'r', b: 're', c: 'rea', d: 'reac', e: 'react' });
const result$ = keystrokes$.pipe( debounceTime(300) // 300мс = 30 фреймов по 10мс каждый );
// Только 'react' проходит (остальные "задавлены") // ↓ через 30 фреймов после 'e' expectObservable(result$).toBe('----------e----|', { e: 'react' }); });});Тест hot Observable
Заголовок раздела «Тест hot Observable»it('takeUntil останавливает по сигналу', () => { scheduler.run(({ cold, hot, expectObservable, expectSubscriptions }) => { const source$ = hot('--a--b--c--d--|'); // горячий — данные без подписки const notifier$ = hot('------n----|'); // сигнал остановки
const result$ = source$.pipe(takeUntil(notifier$));
// После 'n' (60мс) — поток останавливается expectObservable(result$).toBe('--a--b|');
// Проверяем когда была подписка expectSubscriptions(source$.subscriptions).toBe('^-----!'); });});Тест обработки ошибок
Заголовок раздела «Тест обработки ошибок»it('catchError восстанавливает поток', () => { scheduler.run(({ cold, expectObservable }) => { const source$ = cold('--a--#', { a: 1 }, new Error('упс'));
const result$ = source$.pipe( catchError(() => cold('--b--|', { b: 42 })) // резервный Observable );
// Ошибка на 50мс, затем резервный Observable expectObservable(result$).toBe('--a----b--|', { a: 1, b: 42 }); });});Тест switchMap с отменой
Заголовок раздела «Тест switchMap с отменой»it('switchMap отменяет предыдущий Observable', () => { scheduler.run(({ cold, hot, expectObservable, expectSubscriptions }) => { const inner = cold('---x|', { x: 'response' });
const outer$ = hot('a--b--|'); const result$ = outer$.pipe(switchMap(() => inner));
expectObservable(result$).toBe('---x--x|'); // ↑ ↑ // inner(a) inner(b) — inner(a) отменён на 30мс
// Проверяем подписки на inner expectSubscriptions(inner.subscriptions).toBe([ '^--!', // подписка для 'a' — отменена когда пришло 'b' '---^---!', // подписка для 'b' — завершена нормально ]); });});Полный тест кастомного оператора
Заголовок раздела «Полный тест кастомного оператора»import { pipe } from 'rxjs';import { filter } from 'rxjs/operators';import { OperatorFunction } from 'rxjs';
function filterNil<T>(): OperatorFunction<T | null | undefined, T> { return filter((v): v is T => v != null);}
describe('filterNil', () => { let scheduler: TestScheduler;
beforeEach(() => { scheduler = new TestScheduler((a, e) => expect(a).toEqual(e)); });
it('убирает null и undefined', () => { scheduler.run(({ cold, expectObservable }) => { const values = { a: 1, b: null as null, c: 3, d: undefined as undefined, e: 5 }; const source$ = cold('a-b-c-d-e|', values);
const result$ = source$.pipe(filterNil<number>());
// null и undefined пропускаются, позиции в диаграмме остаются expectObservable(result$).toBe('a---c---e|', { a: 1, c: 3, e: 5 }); }); });});Практика
Заголовок раздела «Практика»Попробуйте примеры в интерактивном редакторе: