Help us understand the problem. What is going on with this article?

RxJS v6 の進化した TestScheduler を使う

More than 1 year has passed since last update.

この記事はドワンゴ Advent Calendar 2018の12日目です。

TL;DR

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

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

はじめに

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

この記事では、RxJS のテストについて、直近のメジャーアップデートによってより簡単になった部分があるという話をします。詳細は RxJSのマーブルテストに関するドキュメント に任せ、例示につとめます。

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

TestScheduler の進化

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

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. frameTimeFactor1 に設定される(元は 10
  3. maxFramesNumber.POSITIVE_INFINITY に設定される(元は 750
  4. Time progression syntax が利用可能になる
  5. コールバックが return したら自動的に flush() する

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

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

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

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

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)

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

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

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 が効いてきます。

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 を使います。

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で実行することを仮定すると、次のような形になります:

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

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした