Перейти к содержимому

15. Тестирование RxJS

Привет! Яша на связи. Тестирование асинхронного кода — сложная задача. RxJS предоставляет инструменты, которые делают тестирование потоков предсказуемым и быстрым: TestScheduler и мраморное тестирование.

// Обычный тест с реальным временем — медленно и ненадёжно
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мс

import { TestScheduler } from 'rxjs/testing';
// Настройка TestScheduler
const testScheduler = new TestScheduler((actual, expected) => {
// Можно использовать любую библиотеку сравнения
expect(actual).toEqual(expected); // Jest/Jasmine
});
// Базовый синтаксис
testScheduler.run(({ cold, hot, expectObservable, expectSubscriptions }) => {
// cold — холодный Observable (производит данные для каждого подписчика)
// hot — горячий Observable (данные идут независимо от подписки)
// expectObservable — проверяем что Observable испускает
// expectSubscriptions — проверяем когда были подписки/отписки
});

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' });
});
});

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 });
});
});

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 });
});
});
});

Попробуйте примеры в интерактивном редакторе: