この記事はドワンゴ Advent Calendar 2018の12日目です。
TL;DR
RxJS v6.0.0 から追加された testScheduler.run(callback)
を使うと様々な恩恵が得られて便利です。
- 非同期系オペレーターが
testScheduler
を自動で利用するようになる - マーブルテストのフレーム最大数 750 の制限がなくなる
- 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
メソッドを使うと起こることは次の通りです。
-
delay
,timeout
,debounceTime
のようなAsyncScheduler
を利用するオペレーターにtestScheduler
が自動的に渡る -
frameTimeFactor
が1
に設定される(元は10
) -
maxFrames
がNumber.POSITIVE_INFINITY
に設定される(元は750
) - Time progression syntax が利用可能になる
- コールバックが 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 を渡す例
これまでの利用法に則るなら、ここから delay
に testScheduler
を渡すように躍起になるところです。直近の事例では、テスト対象が 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);
});
-
片っ端からschedulerを差し込み可能にしていく、VirtualTimeSchedulerを自前で設定する、無理やりオペレーターをモックして引数を書き換える、などなど ↩