本稿はHubble Advent Calendar 2025の17日目の記事です。
なぜこの記事を書こうと思ったのか
最近、ComponentStore 周りのテストを書いていて、「Observable を返す依存をモックするなら jest.spyOn(...).mockReturnValueOnce(...) で十分なのか?それとも testScheduler.run(...) を使うべきなのか?」で手が止まることが何度かありました。
特に、既存のテストで TestScheduler が使われていると、「これって本当に“時間”を検証しているから必要なのか」「単に Observable を返すだけのモック用途で入っているのか」が判別しづらく、テストの意図が読み取りにくくなることがあります。逆に、of/throwError だけで済ませた結果、debounceTime や switchMap のキャンセルといった “時間や順序の仕様” を守れていないままテストが通ってしまうケースもあります。
そこで本稿では、mockReturnValueOnce と testScheduler.run を 役割の違い から整理し、どんなときにどちらを選ぶと読みやすく・壊れにくいテストになるかを、架空の todo-page.store.ts を例にまとめます。
結論
-
spyOn/mockReturnValueOnce:依存(API/Service)の戻り値を差し替える -
TestScheduler.run:Observable の時間・順序・キャンセルを仮想時間で検証する
mockReturnValueOnce が解決するもの
「このテストでは API が成功する/失敗することにしたい」を簡単に書けます。
- 成功:
of(...) - 失敗:
throwError(...)
“いつ流れるか”より、“何が返るか”を制御するのが主目的です。
パターン1:多くのテストは spyOn + of/throwError で十分
type Todo = { id: string; title: string };
class TodoApiService {
getTodos(): Observable<Todo[]> { /* ... */ }
}
class SnackbarService {
success(msg: string) {}
negative(msg: string) {}
}
class TodoPageStore {
private readonly todoApiService = inject(TodoApiService);
private readonly snackbarService = inject(SnackbarService);
loadTodos() {
this.api.getTodos().subscribe({
next: (todos) => this.patchState({ todos }),
error: () => this.snackbar.negative('読み込みに失敗しました'),
});
}
}
import { of, throwError } from 'rxjs';
it('成功したら state を更新する', () => {
const store = new TodoPageStore(api, snackbar);
jest.spyOn(store, 'patchState');
jest.spyOn(api, 'getTodos').mockReturnValueOnce(
of([{ id: '1', title: 'Buy milk' }]),
);
store.loadTodos();
expect(store.patchState).toHaveBeenCalledWith({
todos: [{ id: '1', title: 'Buy milk' }],
});
});
it('失敗したら snackbar を出す', () => {
const store = new TodoPageStore(api, snackbar);
jest.spyOn(snackbar, 'negative');
jest.spyOn(api, 'getTodos').mockReturnValueOnce(
throwError(() => new Error('API Error')),
);
store.loadTodos();
expect(snackbar.negative).toHaveBeenCalledWith('読み込みに失敗しました');
});
パターン2:testScheduler.run が必要になる場合
次のような要件が考えられます。
-
debounceTime,delay,timeoutなど 時間依存 -
switchMapによる キャンセル(前のリクエストが破棄される) -
retry/backoffの 回数・間隔
search(query$: Observable<string>) {
return query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.api.searchTodos(q)),
);
}
これを “本当に 300ms 待ってる?” “前の検索はキャンセルされる?” まで含めて確かめるなら、仮想時間での検証(= TestScheduler)が最適です。
import { TestScheduler } from 'rxjs/testing';
it('debounce され、最後の入力だけが検索される', () => {
const testScheduler = new TestScheduler((a, e) => expect(a).toEqual(e));
testScheduler.run(({ cold, expectObservable }) => {
// 入力が短い間隔で連続するイメージ
const query$ = cold('a 50ms b 50ms c 400ms |', {
a: 't',
b: 'to',
c: 'tod',
});
// APIが返す結果(ここは例として固定)
jest.spyOn(api, 'searchTodos').mockImplementation((q: string) =>
cold('10ms (r|)', { r: [`result:${q}`] }),
);
const store = new TodoPageStore(api, snackbar);
const result$ = store.search(query$);
// debounceTime(300) により 'tod' だけが流れる想定
expectObservable(result$).toBe('510ms 10ms (r|)', {
r: ['result:tod'],
});
});
});
使い分けチェックリスト
-
mockReturnValueOnce(of/throwError)を優先- API 成功/失敗で state/snackbar がどうなるか確認したい
- 呼び出し回数・引数を確認したい
- 時間・順序・キャンセルを厳密に見る必要がない
-
testScheduler.runを使う- debounce/delay/retry など “時間が仕様”
- switchMap のキャンセルなど “順序が仕様”
まとめ
-
jest.spyOn(...).mockReturnValueOnce(of(...)/throwError(...))は、依存(API/Service)の戻り値を差し替えて、分岐や副作用(state 更新・snackbar 表示など)を手早く検証するのに向いています。多くの Store テストはこれで十分に読みやすく書けます -
testScheduler.run(...)は、debounceTime/delay/retryのような 時間依存 や、switchMapの キャンセル、複数ストリームの 順序 といった “ストリームの性質そのもの” が仕様になっている場合に使うべきです(ここを曖昧にすると、遅いテストや通ってしまうテストになりがちです) - 迷ったら、まずは
spyOn + of/throwErrorで簡潔に書く。そして「時間・順序・キャンセルまで含めて保証したい」箇所だけを TestScheduler の marble テストに切り出すとバランス良くなります
明日は@ktkr-wksさんです!