15
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

APIの返り値、mockReturnValueOnce, testScheduler.runのどちらでモックする?

Last updated at Posted at 2025-12-16

本稿はHubble Advent Calendar 2025の17日目の記事です。

なぜこの記事を書こうと思ったのか

最近、ComponentStore 周りのテストを書いていて、「Observable を返す依存をモックするなら jest.spyOn(...).mockReturnValueOnce(...) で十分なのか?それとも testScheduler.run(...) を使うべきなのか?」で手が止まることが何度かありました。

特に、既存のテストで TestScheduler が使われていると、「これって本当に“時間”を検証しているから必要なのか」「単に Observable を返すだけのモック用途で入っているのか」が判別しづらく、テストの意図が読み取りにくくなることがあります。逆に、of/throwError だけで済ませた結果、debounceTimeswitchMap のキャンセルといった “時間や順序の仕様” を守れていないままテストが通ってしまうケースもあります。

そこで本稿では、mockReturnValueOncetestScheduler.run を 役割の違い から整理し、どんなときにどちらを選ぶと読みやすく・壊れにくいテストになるかを、架空の todo-page.store.ts を例にまとめます。

結論

  • spyOn/mockReturnValueOnce:依存(API/Service)の戻り値を差し替える
  • TestScheduler.run:Observable の時間・順序・キャンセルを仮想時間で検証する

mockReturnValueOnce が解決するもの

「このテストでは API が成功する/失敗することにしたい」を簡単に書けます。

  • 成功:of(...)
  • 失敗:throwError(...)

“いつ流れるか”より、“何が返るか”を制御するのが主目的です。

パターン1:多くのテストは spyOn + of/throwError で十分

todo-page.store.ts
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('読み込みに失敗しました'),
    });
  }
}

todo-page.store.spec.ts
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 の 回数・間隔
todo-page.store.ts
search(query$: Observable<string>) {
  return query$.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((q) => this.api.searchTodos(q)),
  );
}

これを “本当に 300ms 待ってる?” “前の検索はキャンセルされる?” まで含めて確かめるなら、仮想時間での検証(= TestScheduler)が最適です。

todo-page.store.spec.ts
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さんです!

15
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?