RxJS(5.x)で行うテストファーストな機能開発

MMM Corporation
mmmuser

小飼です。
『The Next Great Burger』にハマっています。

RxJS、使っていますか?
弊社では現在開発しているアプリケーションから、本格的にRxJSを導入して使い込んでいっています。
イベントを配列のように操作できるという高度な抽象化の恩恵で、非常にリーダブルかつ簡潔に機能実装が出来るところが非常に良いですね。

学び始めた当初はReactive Programming的な考え方で実装を発想するということができず、非常に難解なイメージを持っていましたが、
半年くらい置いて改めて使ってみたところ、非常に使いやすいライブラリであるというイメージにガラッと変わりました。
これはRPっぽい発想の転換ができたことと併せて、TypeScriptRxJSを用いることで、静的型解析の恩恵を受ける事ができた事も大きいと思っています。
(少なくともAPIの多さは問題にならなくなりました)

さて、私はRxJS(というかRP)を学ぶにあたってこの辺りのコンテンツを読みました。

特に実際のコード例に関してはReactive Programming with RxJSを主に参考にしていましたが、
これはRxJS 4.x~に基づいた書籍ですので、現在のベータリリースが公開されている最新版のRxJS 5.x~とは詳解されているAPIが随分変わっています。
基本的な概念の理解の用には今でも充分に足ると思いますが(ここがRP入門の最大の関門という気がします)、
特にテストの書き方については公式のドキュメントが用意されておらず、少し手探りで調べる必要がありました。
(RxJSのコントリビュータ向けと思しきテスト手法のドキュメントは存在しています)

そこで本稿では、RxJS 5.x~においてテストを実装するにあたって必要なステップをまとめました。
RxJSを使って書くことになるのはアプリケーションの機能の中でも最も抽象的・難解な箇所になることが多いので(個人の感想ですが。。。)
しっかりテストできるとうれしいのではないでしょうか。

マーブル記法によるテスト

まず、一番大きく変わっているところはここだと思います。
4.x~ではReactiveTestモジュールからexportされたonNext,onError,onCompletedなどのヘルパー関数に
emitする値とタイミングを渡す形で擬似的にテスト対象のソースとなるObservableを表現していました。

これを5.x~ではマーブル記法という、ある種のDSLでObservableを表現できるようになっています。
Writing Marble Tests

Reactive Programmingの入門コンテンツでは、Observableを表現するためにマーブルダイアグラムで発想してみることを推奨していることが多いです。

こういうやつです

マーブルダイアグラムに落として込んでObservableを考えてみるこの手法は非常に有用で、個人的には実装の前に必ず行う思考法になっているのですが、
これをそのままテスト用のソースObservableとして書けるようにしたのが、マーブル記法です。
後述のDSLに沿って書かれたマーブル文字列を、テストヘルパー関数に渡すとそれに基づいたObservableを生成して返してくれるという仕組みです
(今まで手書きでonNextしていたのを、マーブルダイアグラムで直感的に書けるようになった、という感じです)

例えば、---a---b---c---|は30ms目にa,70ms目にb,110ms目にcemitし、150ms目に完了するObseravableを表しています。

スケジューラ

こちらは4.x~の頃と大きな変更はないので簡単におさらいしておきます。

現実の時間経過を必要とする機能のテストは実装が困難で、かつテスト自体の実行時間を引き伸ばしていってしまいます。
これを避けるために、RxJSの内部にはスケジューラという実行タイミングを管理するための機構が用意されていて、
テストの時には、このスケジューラを仮想的な時間経過を適用できるものに切り替えて行います。

テストドキュメント

現在、5.x~公式ドキュメントには、テスト方法指南のドキュメントはありません。
ただし、RxJSの実装そのもののテストは掲載されていますので、これを参考にすればテストが書けます。

と言っても、このテストで使っているテストヘルパー関数のいくつか(グローバルな変数として露出しているもの)は、npmモジュールに公開されていません。
(例えばObseravable.mapのテストで使われているcold関数など)

この辺のヘルパー関数群は、実はTestSchedulerクラスのインスタンスメソッドを薄くラップしているものに過ぎませんので、
公式が内部で使ってるテストランナーを参考にすれば
RxJSのユーザ側でもテストに利用することができます。
※ごく小さな実装ですし、各位のテスト環境に合わせてヘルパーを作ってくれという方針なのかも知れません。

作例

ざっとテストに必要な概念をご紹介しましたので、実際にコードに起こしてみようと思います。
こんな感じの機能を開発するとして、ユーザの文字入力から適切なタイミングで検索APIを叩くための中間層を実装したいと想定しています。

  • ユーザの文字入力による、インクリメンタルサーチ機能実装したい
  • 検索リクエストの頻度を適切に抑えるために100msほどのバッファタイムを設ける
  • スペースで句切られた文字列は別の単語として検索する(例: queries[]=foo&queries[]=buzz)

それではこの仕様を満たしそうなテストを書いてみます。
まずソースObservableとなる、ユーザ入力をマーブル記法で書いてみます。

// 30msごとに`RxJS5`と入力するObservable
"---a---b---c---d---e-", { a: "R", b: "x", c: "J", d: "S", e: "5" })

次に、これがどういうObseravableにしたいのかをマーブル記法で書きます。

// 100msごとに溜めたすべての文字列を発行するObseravable
"----------f---------g", { f: ["Rx"], g: ["RxJS5"] })

検索ワードがスペースで区切られていた場合のテストケースを作るために、もう一つマーブルを作っておきます

"---a---b---c---d---e-", { a: "r", b: "x", c: " ", d: "j", e: "s" }
"----------f---------g";, { f: ["rx"], g: ["rx", "js"] }

テストコードの全体像はこんな感じになりました。

import { Observable, TestScheduler } from "rxjs";
import * as assert from "assert";

import { createIncrementalSearch } from "../app";

describe("非同期処理のテスト", () => {
  let testScheduler, hot, cold;
  beforeEach(() => {
    // テスト対象のObseravableと、期待するObseravableを比較する関数
    // `(actual, expected) => boolean`を渡してTestSchedulerをインスタンス化する
    testScheduler = new TestScheduler(assert.deepEqual);

    // RxJS本家に倣って、Obseravableのファクトリメッソッドのエイリアスを作る(必須ではない)
    cold = testScheduler.createColdObservable.bind(testScheduler);
  });

  it("100ms毎に検索を実行する", () => {
    const input$  = cold("---a---b---c---d---e-", { a: "R", b: "x", c: "J", d: "S", e: "5" });
    const expect$ =      "----------f---------g";

    const test$ = createIncrementalSearch(input$, testScheduler);
    testScheduler.expectObservable(test$).toBe(expect$, { f: ["Rx"], g: ["RxJS5"] });
    testScheduler.flush();
  });

  it("スペースで単語を区切られる", () => {
    const input$  = cold("---a---b---c---d---e-", { a: "r", b: "x", c: " ", d: "j", e: "s" });
    const expect$ =      "----------f---------g";

    const test$ = createIncrementalSearch(input$, testScheduler);
    testScheduler.expectObservable(test$).toBe(expect$, { f: ["rx"], g: ["rx", "js"] });
    testScheduler.flush();
  });
});

テストが書けたので、これを満たす実装コードを書いてみます。
bufferに入力値を貯めてから、入力値を畳み込んで文字列に戻しています。
最後にスペースでsplitして完成です。

import { Observable } from "rxjs";

export const createIncrementalSearch = (input$: Observable<string>, testScheduler = null): Observable<string[]> => {
  return input$
    .bufferTime(100, null, testScheduler)
    .filter(c => c.length > 0)
    .map(c => c.join(""))
    .scan((acc, s) => (acc + s), "")
    .map(s => s.split(" "));
};

コードはgithubのレポジトリに上がっています
npm testで実際にテストが通ることが確認できると思いますので、良かったらこちらも見てみて下さい。

まとめ

RxJS 5.x~でのテストについて、簡単にまとめてみましたが、いかがでしたでしょうか。
参考になれば幸いです。

AUTHOR
デロイト トーマツ ウェブサービス株式会社(DWS)
デロイト トーマツ ウェブサービス株式会社(DWS)
デロイト トーマツ ウェブサービス株式会社はアマゾン ウェブ サービス(AWS)に 専門性や実績を認定された公式パートナーです。
記事URLをコピーしました