RxJS
Точка входа vitest-auto-spy/rxjs даёт спаю возвращать Observable, которым управляет тест. Она нужна, когда проверяемый код подписывается на метод сервиса или на свойство с $.
Импортируйте её один раз в setup-файле и укажите этот файл в конфиге Vitest:
// vitest.setup.ts
import 'vitest-auto-spy/rxjs';// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { setupFiles: ['./vitest.setup.ts'] },
});Дальше спека создаёт спай, передаёт его проверяемому классу и задаёт, что выдаст поток:
// product-list.spec.ts
import { expect, it } from 'vitest';
import { type Spy, createSpyFromClass } from 'vitest-auto-spy';
import { ProductList } from './product-list';
// в load() вызывает products.getProducts().subscribe(...)
import { ProductService } from './product.service';
// getProducts(): Observable<Product[]>
it('shows the products the service returns', () => {
const products: Spy<ProductService> = createSpyFromClass(ProductService);
const list = new ProductList(products);
products.getProducts.nextWith([{ name: 'Tea' }]);
list.load();
expect(list.names).toEqual(['Tea']);
});Если импорта в setup-файле нет, первый же observable-хелпер бросает ошибку Observable spies require rxjs и называет нужный импорт. Остальная библиотека rxjs не импортирует, так что проектам без rxjs ставить его не нужно.
Observable-свойства
Свойство вроде items$ — не метод, поэтому его перечисляют в observablePropsToSpyOn. После этого у него те же хелперы, что у метода.
const store = createSpyFromClass(CartStore, { observablePropsToSpyOn: ['items$'] });
store.items$.nextWith([{ id: 1 }]);Хелперы
Эти хелперы есть у каждого метода-спая, который возвращает Observable, и у каждого свойства из observablePropsToSpyOn.
| Хелпер | Что делает |
|---|---|
nextWith(value) | выдаёт value; поток остаётся открытым |
nextOneTimeWith(value) | выдаёт value и завершает поток |
throwWith(error) | завершает поток ошибкой |
complete() | завершает поток |
nextWithValues(configs) | выдаёт последовательность: значения, ошибку, завершение, задержки |
nextWithPerCall(list) | даёт каждому вызову свой поток; возвращает их subject'ы |
returnSubject() | возвращает Subject за спаем — для полного ручного управления |
subscriberCount() | число открытых подписок (только у свойств) |
products.getProducts.nextWith([{ name: 'Tea' }]); // значение, поток открыт
products.getProducts.nextOneTimeWith([{ name: 'Tea' }]); // одно значение, затем завершение
products.getProducts.throwWith(new Error('offline')); // ошибка в потоке
products.getProducts.complete(); // завершение
// точная последовательность
products.getProducts.nextWithValues([{ value: [{ name: 'Tea' }] }, { errorValue: 'offline' }, { complete: true }]);
// свой поток на каждый вызов: первый вызов получит ['a'], второй — ['b']
products.getProducts.nextWithPerCall([{ value: ['a'] }, { value: ['b'] }]);
// сам Subject
const subject = products.getProducts.returnSubject();
subject.next([{ name: 'Coffee' }]);Элементы списков для двух хелперов:
| Тип | Форма |
|---|---|
ValueConfig (для nextWithValues) | { value, delay? }, { errorValue, delay? } или { complete?, delay? } |
ValueConfigPerCall (nextWithPerCall) | { value, delay?, doNotComplete? }; каждый поток завершается, если нет doNotComplete |
nextWith пишет в общий subject; nextWithValues публикует новый
С виду они взаимозаменяемы. На observable-свойстве — нет. На спае метода они ведут себя одинаково: каждый вызов читает текущий поток.
| Хелпер | Что делает с потоком | Подписчик, который уже на нём |
|---|---|---|
nextWith / nextOneTimeWith / throwWith / complete | пишет в subject, общий для всех | получает значение |
nextWithValues (на свойстве-спае) | подменяет свойство новым потоком | остаётся на старом |
Компонент обычно подписывается на свойство один раз, в ngOnInit, и держит поток, полученный тогда. Если вызвать nextWithValues после этого, компонент значений не увидит. Библиотека один раз предупредит:
[vitest-auto-spy] Feed.items$.nextWithValues() ran after something subscribed to Feed.items$, and it
publishes a new stream that subscriber never sees — these values will not reach it. Call
nextWithValues() before the code under test subscribes, or push into the stream it holds with nextWith().
Docs: https://asdalexey.github.io/vitest-auto-spy/runtimes/rxjs#nextwith-pushes-nextwithvalues-republishesПочинить можно двумя способами:
- Вызвать
nextWithValuesна этапе подготовки, до создания компонента. - Или писать в живой поток:
service.items$.nextWith(['a']); // дойдёт до компонента, подписанного в ngOnInit
service.items$.returnSubject().error(new Error('offline')); // это тожеКак читать последовательность как marble-диаграмму
nextWithValues выдаёт элементы по порядку. Время между ними задаёт только delay. В комментариях каждый пример записан marble-диаграммой (| — завершение, # — ошибка).
products.getProducts.nextWithValues([{ value: 'a' }, { value: 'b' }, { complete: true }]);
// (ab|) оба значения сразу, затем завершениеproducts.getProducts.nextWithValues([{ value: 'a' }, { value: 'b', delay: 20 }, { complete: true, delay: 10 }]);
// a 20ms b 10ms |products.getProducts.nextWithValues([{ value: 'a' }, { errorValue: 'boom', delay: 20 }]);
// a 20ms #{ complete: false }ничего не выдаёт и оставляет поток открытым.- Всё после первого
{ complete: true }отбрасывается.
Тайминги
delay— в миллисекундах реального времени. Для значений и завершения работаетdelay()из RxJS, для ошибок —timer(). Виртуального планировщика нет.- Без задержки значение приходит синхронно, в том же тике.
- Поздний подписчик тоже получит последнее значение. За спаем стоит
ReplaySubject(1). ПоэтомуnextWith(v)работает, подписался ли проверяемый код до вызова или после. - С фейковыми таймерами задержку нужно промотать. Используйте
advanceTimers(ms): он ещё и выполняет отложенные колбэки промисов. Голыйvi.advanceTimersByTime()этого не делает, и проверка может сработать слишком рано.
import { expect, it } from 'vitest';
import { advanceTimers, setupFakeTimers } from 'vitest-auto-spy/setup';
setupFakeTimers();
it('emits after 100 ms', async () => {
products.getProducts.nextWithValues([{ value: 'a', delay: 100 }]);
const seen: string[] = [];
products.getProducts().subscribe((value) => seen.push(value));
await advanceTimers(100);
expect(seen).toEqual(['a']);
});Отдельный конструктор observable
createObservableWithValues строит такой же поток без спая. Элементы — те же, что у nextWithValues.
import { createObservableWithValues } from 'vitest-auto-spy/rxjs';
const fake$ = createObservableWithValues([{ value: 1 }, { value: 2 }, { complete: true }]);
// вместе с Subject
const { values$, subject } = createObservableWithValues([{ value: 1 }], { returnSubject: true });Проверить, что никто не забыл отписаться: subscriberCount()
items$.subscriberCount() возвращает, сколько подписок на свойство-спай открыто сейчас. Подписка перестаёт считаться, когда от неё отписались или когда поток завершился либо упал с ошибкой. Проверьте 0 после уничтожения компонента — так ловится утечка подписки:
fixture.destroy();
expect(store.items$.subscriberCount()).toBe(0);Сбрасывать потоки между тестами
У каждого члена-спая один ReplaySubject(1). Сохранённое в нём значение — это настройка, как правило calledWith (ответ, заданный для конкретных аргументов). Если спай живёт дольше одного теста, сбрасывайте его, иначе следующий тест первым получит старое значение.
import { beforeEach } from 'vitest';
import { resetAutoSpy } from 'vitest-auto-spy';
beforeEach(() => {
resetAutoSpy(service); // TestBed собран в beforeAll, спай общий
});vi.clearAllMocks()иclearMocks: trueпоток не сбрасывают. Поток — состояние библиотеки, а не часть мока раннера. ВызывайтеresetAutoSpy(spy).- Завершённый поток заменяется. Поток завершён после
throwWith()илиcomplete()на спае либо послеcomplete()илиerror()на subject изreturnSubject(). Тогда следующийnextWithначинает новый поток:
const subject = service.load$.returnSubject();
subject.complete(); // спека закрывает его руками
service.load$.nextWith(page); // новый поток, а не значение, которое никто не получит- В одном тесте вызовы складываются.
nextWith(a), затемthrowWith(e)— это «выдатьa, потом упасть». Поток, который падает сразу при подписке, что бы ни было до этого, даётnextWithValues([{ errorValue: e }]). На свойстве-спае вызывайте его до подписки: см.nextWithпишет в общий subject;nextWithValuesпубликует новый.
Проверять вместо того, чтобы подписываться
Чтобы проверить, что выдаёт поток («выдаёт», «выдаёт эти три», «молчит»), используйте проверки observable. Они работают с любым объектом, у которого есть subscribe, и rxjs им не нужен:
import { expectEmission } from 'vitest-auto-spy';
const emitted = expectEmission(products.getProducts());
products.getProducts.nextWith(['x']);
expect(await emitted).toEqual(['x']);rxjs в типах
Объявления типов ядра не импортируют rxjs. Проекты на React, Vue, Svelte и Node без rxjs проходят проверку типов с skipLibCheck: false и не загружают ни одного .d.ts из rxjs. rxjs упоминают только объявления vitest-auto-spy/rxjs и vitest-auto-spy/observer-spy.
Ни одна из этих двух не упоминает vitest, поэтому проект на Bun или node:test проходит с ними проверку типов при skipLibCheck: false и без установленного Vitest. На Bun и node:testreturnSubject() получает тип Subject из rxjs, как только эта точка входа импортирована, — так же, как на Vitest.
Observable-хелперы получает любой Observable или Subject из rxjs, а также EventEmitter из Angular. Как определяется тип — в разделе Подробнее.
Как назвать тип subject
returnSubject() возвращает SubjectOf<T>:
- собственный
Subject<T>из rxjs, еслиvitest-auto-spy/rxjsимпортирован там, где его видит ваша программа TypeScript; - иначе
SubjectLike<T>— обычный интерфейс со всем, для чего нужен хелпер.
import type { Subject } from 'rxjs';
import 'vitest-auto-spy/rxjs';
const subject: Subject<Product[]> = products.getProducts.returnSubject(); // ✔ компилируетсяЧастая ошибка: аннотация выше перестаёт компилироваться, потому что единственный import 'vitest-auto-spy/rxjs' лежит в setup-файле, которого нет в tsconfig спек. Положите импорт в файл, который проверяет компилятор:
- работает: спека, файл из
setupFiles, файл.d.ts, а с@angular/build:unit-test— ещё иprovidersFile; - не работает: обычный
.ts, который только указан вincludeфайлаtsconfig.spec.json. Это касается@angular/build:unit-test22.2 и новее.
Все четыре типа экспортируются из ядра, спека может назвать любой:
interface ObservableLike<T> {
subscribe(...args: never[]): { unsubscribe(): void };
forEach(next: (value: T) => void, ...rest: never[]): Promise<void>;
}
interface SubjectLike<T> extends ObservableLike<T> {
next(value: T): void;
error(err: unknown): void;
complete(): void;
unsubscribe(): void;
asObservable(): ObservableLike<T>;
readonly closed: boolean;
}
// здесь пустой, заполняется из `vitest-auto-spy/rxjs`
interface AutoSpyRxjsTypes<T> {}
type SubjectOf<T> = AutoSpyRxjsTypes<T> extends { subject: infer S } ? S : SubjectLike<T>;Если в проекте свой класс Subject, направьте на него SubjectOf расширением типов (declare module):
declare module 'vitest-auto-spy' {
interface AutoSpyRxjsTypes<T> {
subject: MyOwnSubject<T>;
}
}Положите его в файл .d.ts (с import или export {}, чтобы он остался расширением), в спеку или в setup-файл. Правило то же, что выше. Обычный .ts, указанный только в include, не работает с @angular/build:unit-test 22.2 и новее.
subscribeSpyTo — для набора, пришедшего с observer-spy
vitest-auto-spy/observer-spy повторяет API пакета @hirez_io/observer-spy. Он нужен при переезде тестов, которые уже на нём: они запускаются до того, как вы перепишете проверки потоков.
import { subscribeSpyTo } from 'vitest-auto-spy/observer-spy';
const spy = subscribeSpyTo(service.load());
expect(spy.getValues()).toEqual(['a', 'b']);
expect(spy.receivedComplete()).toBe(true);Это мост. В новых тестах используйте expectEmission и соседние проверки. С subscribeSpyTo поток, который ничего не выдал, даёт getValues() === [], и тест может пройти, ничего не увидев. expectEmission ждёт значение, и молчание превращается в таймаут с именем потока.
Отличия от @hirez_io/observer-spy:
getValues()возвращает копию с типомT[], а неany[].getFirstValue()иgetValueAt(i)бросают ошибку, если значения нет, а не возвращаютundefined.- Неожиданную ошибку потока бросает следующий читатель значений (
getValues()и другие), исходная ошибка лежит вcause. Если ошибка и есть предмет теста, передайте{ expectErrors: true }(или вызовите.expectErrors()) и читайтеgetError(). await onComplete()отклоняется, если поток упал с ошибкой, аawait onError()— если поток завершился. Иначе тест висел бы до таймаута файла. Не важно, завершился поток до вызоваonComplete()/onError()или после. Форма с колбэком (onComplete(() => …)) работает как в оригинале: колбэк просто не вызывается.
[vitest-auto-spy] this spy's observable errored (Error: offline), so the promise from onComplete() can never resolve:
completion is not coming. Read receivedComplete() / receivedError(), or await
`expectCompletion(source$)` / `expectError(source$)`, which fail with a message naming the stream.
Docs: https://asdalexey.github.io/vitest-auto-spy/runtimes/rxjsSubscriberSpy поддерживает using, так что подписку можно ограничить блоком, а не общим afterEach:
using spy = subscribeSpyTo(service.load());fakeTime() нет: он построен на TestScheduler из rxjs и колбэке done. Используйте фейковые таймеры или TestScheduler напрямую.
Подробнее
Член класса считается observable, если в его типе есть subscribe и forEach(next), возвращающий промис. Подходят Observable из rxjs, любой Subject и EventEmitter из Angular. Не подходят Promise, массивы, Signal и OutputEmitterRef из Angular. Observable из второй копии rxjs в node_modules тоже подходит и получает nextWith.