RxJS v6 の進化した TestScheduler を使う

この記事は[ドワンゴ Advent Calendar 2018](https://qiita.com/advent-calendar/2018/dwango)の12日目です。

# TL;DR

RxJS v6.0.0 から追加された `testScheduler.run(callback)` を使うと様々な恩恵が得られて便利です。

1.  非同期系オペレーターが `testScheduler` を自動で利用するようになる
2.  マーブルテストのフレーム最大数 750 の制限がなくなる
3.  marble diagram の記法が拡張され `-a 100ms b-|` のように書けるようになる

# はじめに

berlysia といいます。Web フロントエンドを少々やっています。ブラウザの上に城を建てるのが得意です。

この記事では、RxJS のテストについて、直近のメジャーアップデートによってより簡単になった部分があるという話をします。詳細は [RxJSのマーブルテストに関するドキュメント](https://github.com/ReactiveX/rxjs/blob/be19ce94f769d210b89e75444158278a87b48c1c/doc/marble-testing.md) に任せ、例示につとめます。

記事中の例には redux-observable の Epic が登場しますが、Epic のテストに限らず利用可能な情報です。他の用例でもアナロジーが効くように、これまで苦しんでいた形を先に示し、ある程度苦しみから解放される形を確認していきます。

# TestScheduler の進化

RxJS v6 で、TestScheduler に次のようなメソッド `run` が追加されました。

```ts
const { TestScheduler } = require("rxjs/testing");
const { deepStrictEqual } = require("assert");
const testScheduler = new TestScheduler(deepStrictEqual);

testScheduler.run(helpers => {
  const { cold, hot, expectObservable, expectSubscriptions, flush } = helpers;
  // helpersの中身を使ってテストを書く
});
```

この `run` メソッドを使うと起こることは次の通りです。

1. `delay`, `timeout`, `debounceTime` のような `AsyncScheduler` を利用するオペレーターに `testScheduler` が自動的に渡る
2. `frameTimeFactor` が `1` に設定される（元は `10`）
3. `maxFrames` が `Number.POSITIVE_INFINITY` に設定される（元は `750`）
4. Time progression syntax が利用可能になる
5. コールバックが return したら自動的に `flush()` する

5 について、これまでテストライブラリが提供する `afterEach` のようなフックや、悪ければテストケースの末尾で必ず `flush()` を呼び出してやる必要がありましたが、 `run` を使う限りは不要になりました。

1,2,3,4 の変化はある観点において、これまでの marble testing の常識を大きく変えます。これまで `AsyncScheduler` を使っているオペレーターには様々な苦心[^1]をする必要がありましたが、もはや気にしなくてよくなるのです。

[^1]: 片っ端からschedulerを差し込み可能にしていく、VirtualTimeSchedulerを自前で設定する、無理やりオペレーターをモックして引数を書き換える、などなど

## 従来の利用法でナイーブに書いたマーブルテスト

従来の利用法でありがちな失敗例を確認しておきます。 `testScheduler` は同期的に時系列をシミュレートしますから、`AsyncScheduler` を使う `delay` のようなオペレーターには、明示的に `testScheduler` を渡してやらなければいけません。
次の例では何も対策をしていなかったのでテストが落ちました。

```ts
const { TestScheduler } = require("rxjs/testing");
const { deepStrictEqual } = require("assert");
const testScheduler = new TestScheduler(deepStrictEqual);

const { map, delay } = require("rxjs/operators");
const epicLike = action$ =>
  action$.pipe(
    delay(20),
    map(a => "b")
  );

const input$ = "-a---a---a---a---";
const output$ = "---b---b---b---b---"; // 1フレームが10msなので、2フレームずらす
testScheduler
  .expectObservable(epicLike(testScheduler.createHotObservable(input$)))
  .toBe(output$);
testScheduler.flush(); // assertion error、actualに値が入っていない
```

RxJS v6 でもこの挙動は互換性のために保存されています。 `run` メソッドという形式は、後方互換性を保ちつつ、新しい機能と書き方を提供するために選ばれたものだとのことです。

ref. [ReactiveX/rxjs marble-testing.md # Behavior is different outside of testScheduler.run(callback)](https://github.com/ReactiveX/rxjs/blob/be19ce94f769d210b89e75444158278a87b48c1c/doc/marble-testing.md#behavior-is-different-outside-of-testschedulerruncallback)

## 従来の利用法で頑張って scheduler を渡す例

これまでの利用法に則るなら、ここから `delay` に `testScheduler` を渡すように躍起になるところです。直近の事例では、テスト対象が Epic であることを利用して、redux-observable が提供する DI の仕組みに scheduler を載せて対処していました。次のコードはその実装例です。テストが通って本当によかったですね。

```ts
const { TestScheduler } = require("rxjs/testing");
const { deepStrictEqual } = require("assert");
const testScheduler = new TestScheduler(deepStrictEqual);

const { map, delay } = require("rxjs/operators");
const epic = (action$, store, dependencies) =>
  action$.pipe(
    // undefinedならデフォルトのschedulerを使うので、普段はただ与えなければよい
    delay(20, dependencies.scheduler),
    map(a => "b")
  );
const input$ = "-a---a---a---a---";
const output$ = "---b---b---b---b---";
testScheduler
  .expectObservable(
    epic(testScheduler.createHotObservable(input$), null, {
      scheduler: testScheduler // DI
    })
  )
  .toBe(output$);
testScheduler.flush(); // ok
```

あるいは、モックライブラリの力を借りてschedulerを差し込むこともできるでしょう。強引な方法ではありますが、schedulerを高度に使っている場合は、今後も必要になるかもしれません。

## 新しいメソッド `run` の威力

先述した `run` メソッドを使う形で、ここまでのテストを修正してみます。  
いま `epicLike` は 20ms の `delay` をかけていますが、1 フレームが 1ms になった上で Marble diagram に 20ms の遅延を表現するのは、少し大変そうですね。ここで [Time progression syntax](https://github.com/ReactiveX/rxjs/blob/be19ce94f769d210b89e75444158278a87b48c1c/doc/marble-testing.md#time-progression-syntax) が効いてきます。

```ts
const { TestScheduler } = require("rxjs/testing");
const { deepStrictEqual } = require("assert");
const testScheduler = new TestScheduler(deepStrictEqual);
const { delay, map } = require("rxjs/operators");

const epicLike = action$ =>
  action$.pipe(
    delay(20),
    map(a => "b")
  );
const input$ = "-a---a---a---a---|";
// 1フレームが1msなので20フレーム並べる、ということはなく 20ms と書ける
const output$ = "- 20ms b---b---b---b---|";

testScheduler.run(helpers => {
  const { hot, expectObservable } = helpers;
  expectObservable(epicLike(hot(input$))).toBe(output$);
});
```

Time progression syntax と testScheduler の自動設定、maxFrames の制限解除によって、 `AsyncScheduler` を使うオペレーターを含んだ処理のテストはぐっと簡単に書けるようになったことがわかるでしょうか。

補足のために、より長い時間を検証する必要がある場合を考えてみましょう。初期値を与えるとカウントダウンする Epic です。`timer` もデフォルトでは `AsyncScheduler` を使います。

```ts
const { TestScheduler } = require("rxjs/testing");
const { deepStrictEqual } = require("assert");
const testScheduler = new TestScheduler(deepStrictEqual);
const { ofType } = require("redux-observable");
const { of, timer } = require("rxjs");
const { switchMap, take, map } = require("rxjs/operators");

const startCountdown = payload => ({
  type: "START_COUNTDOWN",
  payload
});
const updateTime = payload => ({ type: "UPDATE_TIME", payload });

const epic = action$ =>
  action$.pipe(
    ofType("START_COUNTDOWN"),
    switchMap(({ payload }) =>
      timer(0, 1000).pipe(
        take(payload + 1),
        map(i => updateTime(payload - i))
      )
    )
  );

const input$ = "--s";
const output$ = "--7 999ms 6 999ms 5 999ms 4 999ms 3 999ms 2 999ms 1 999ms 0";
const values = {
  s: startCountdown(7),
  ...Array.from(Array(8), (x, i) => updateTime(i))
};

testScheduler.run(helpers => {
  const { hot, expectObservable } = helpers;
  expectObservable(epic(hot(input$, values))).toBe(output$, values);
});
```

時系列を追っての動作をフレームの単位に翻訳する手間なく、実際の動作通りに書けば良くなりました。
もちろんテストの実行は同期的にシミュレーションされますから、無駄に待たされるようなこともありません。

# おわりに

この記事では、RxJS v6 で`TestScheduler`に追加された `run` メソッドを使って、これまでと比較してハマりどころ少なくテストを書けることを紹介しました。
インポートパスの変更やpipeable operatorの導入に隠れてあまり話題になっていませんが、進化した `TestScheduler` を使ってみてはいかがでしょうか。

# 補足：テスト対象内でモックが呼ばれたことの確認をしたい場合

`flush()` を呼ぶタイミングはコールバックを実行した後です。jestで実行することを仮定すると、次のような形になります：

```ts
const { TestScheduler } = require("rxjs/testing");
const { interval, of } = require("rxjs");
const { take, map, mergeMap, delay } = require("rxjs/operators");
const { ofType } = require("redux-observable");
const testScheduler = new TestScheduler((actual, expected) =>
  expect(actual).toEqual(expected)
);

const KICK = "KICK";
const kick = () => ({ type: KICK });
const result = payload => ({ type: "RESULT", payload });

const epic = (action$, state$, { client }) =>
  action$.pipe(
    ofType(KICK),
    mergeMap(() =>
      interval(1000).pipe(
        take(3),
        mergeMap(() => client.fetch()),
        map(result)
      )
    )
  );

test("test", () => {
  const client = { fetch: jest.fn().mockReturnValue(of(42).pipe(delay(100))) };
  const input$ = "a|";
  const output$ = "1100ms b 999ms b 999ms (b|)";
  const values = {
    a: kick(),
    b: result(42)
  };
  testScheduler.run(({ hot, expectObservable, flush }) => {
    expectObservable(epic(hot(input$, values), null, { client })).toBe(
      output$,
      values
    );
    // まだflushされていないので呼び出されていない
    expect(client.fetch).not.toBeCalled();

    // 明示的に呼んでやるか、
    flush();
    expect(client.fetch).toBeCalledTimes(3);
  });

  // コールバックの外に書くとよい
  expect(client.fetch).toBeCalledTimes(3);
});
```