RxJS
RxJS5

RxJS Marble Test 入門

More than 1 year has passed since last update.

When

2016/12/08

At

Meguro.es #7 @ Livesense


今回の(出典)GitHubリポジトリ

ovrmrw/rxjs-marble-tests

===

話す内容

  • RxJS 5のObservableをどうやってテストするか。

参考になるサイト


自己紹介

ちきさん
(Tomohiro Noguchi)

Twitter/GitHub/Qiita: @ovrmrw

ただのSIer。

Angular Japan User Group (ng-japan)スタッフ。

3a2512bb-aa72-4515-af42-1f1721252f39.jpg


アカウントIDの由来

  1. the day after tomorrow
  2. overmorrow(俗語)
  3. 略して
  4. ovrmrw
  5. 「先を見据えて」よりさらに先を、みたいな感じです。

(よく聞かれるので:innocent:)


:tada: RxJS 5はとうとう来週stableがリリースされます! :tada:


(ここから本編)


あるところにこういうObservableがありました。

  Observable.of(1, 2, 3)
    .map(value => value * 10)
    .filter(value => value > 10)
    .do(value => console.log(value));

出力はこうなります。

20
30

mapによって値が10倍されて
filterによって10より大きい値だけが抽出されました。


これが本当にそうなるかをテストします。


先程のObservableをこのように関数でラップします。

Interface: (observable) => observable

function mapFilterTest(observable: Observable<number>): Observable<number> {
  return observable
    .map(value => value * 10)
    .filter(value => value > 10);
}

テストしたいObservableをこのように書き直すのがRxJSのテストの第一歩です。


Observableをテストするために必要なもの

  • source$
  • expected, values
  • actual$
  • expectObservable, flush

source$

const source$: Observable<number> = cold<number>('-a-b-c', { a: 1, b: 2, c: 3 });

coldはObservableを生成する関数です。他にSubjectを生成するhotがあります。

'-a-b-c'がMarbleです。一文字目が0msで、その後一文字分が10msの時間の流れを持ちます。
上記の例で言えばこのようなストリームを表します。

  • 10ms時に値 1 が流れてきた。
  • 30ms時に値 2 が流れてきた。
  • 50ms時に値 3 が流れてきた。

expected, values

const expected = '---B-C';
const values = { A: 10, B: 20, C: 30 };

テスト用語で言うところの Expected を書きます。

expectedの記号にvaluesの値を当てはめていくと考えてください。

この例ではこのような結果を期待しています。

  • 10ms時には何も流れてこないだろう。
  • 30ms時には値 20 が流れてくるだろう。
  • 50ms時には値 30 が流れてくるだろう。

actual$

const actual$: Observable<number> = mapFilterTest(source$);
(mapFilterTest再掲)
function mapFilterTest(observable: Observable<number>): Observable<number> {
  return observable
    .map(value => value * 10)
    .filter(value => value > 10);
}
(source$再掲)
const source$: Observable<number> = cold<number>('-a-b-c', { a: 1, b: 2, c: 3 });

テスト用語で言うところの Actual を書きます。

この結果が先程の Expected と一致するかどうかをテストしたいわけですね。


expectObservable, flush

ts.expectObservable(actual$).toBe(expected, values);
ts.flush();

説明は後回しになりますが、tsはRxJSのTestSchedulerクラスのインスタンスです。

Observableであるactual$が、expectedvaluesの組み合わせと一致することを期待しています。

flushで定義済みのストリームを流します。"Run"みたいな意味合いです。


今まで説明してきたことをitで書くとこんな感じになります。

  it('should return correct observable', () => {
    const source$ = cold<number>('-a-b-c', { a: 1, b: 2, c: 3 });
    const expected = '---B-C';
    const values = { A: 10, B: 20, C: 30 };
    const actual$ = mapFilterTest(source$);
    ts.expectObservable(actual$).toBe(expected, values);
    ts.flush();
  });

テストの全容はこのようになります。TestSchedulerクラスを使うのが肝です。

import { Observable, Subject, TestScheduler } from 'rxjs/Rx';
import { assertDeepEqual } from '../testing/helper';

describe('TEST: RxJS Marble Test', () => {
  let ts: TestScheduler;
  let hot: typeof TestScheduler.prototype.createHotObservable;
  let cold: typeof TestScheduler.prototype.createColdObservable;

  beforeEach(() => {
    ts = new TestScheduler(assertDeepEqual);
    hot = ts.createHotObservable.bind(ts);
    cold = ts.createColdObservable.bind(ts);
  });

  it('should return correct observable', () => {
    const source$ = cold<number>('-a-b-c', { a: 1, b: 2, c: 3 });
    const expected = '-A-B-C';
    const values = { A: 10, B: 20, C: 30 };
    const actual$ = mapFilterTest(source$);
    ts.expectObservable(actual$).toBe(expected, values);
    ts.flush();
  });
}

Test to fail

  it('should return correct observable', () => {
    const source$ = cold<number>('-a-b-c', { a: 1, b: 2, c: 3 });
    const expected = '-A-B-C'; // 元は'---B-C'
    const values = { A: 10, B: 20, C: 30 };
    const actual$ = mapFilterTest(source$);
    ts.expectObservable(actual$).toBe(expected, values);
    ts.flush();
  });

わざとテストを落とすためにexpectedを変更してみました。これにより

  • 10ms時には値 10 が流れてくるだろう。
  • 30ms時には値 20 が流れてくるだろう。
  • 50ms時には値 30 が流れてくるだろう。

という結果を期待することになりますが、実際には10ms時には何も流れてきません。


ActualとExpectedを全てコンソール出力すると結果はこうなります。一致していないことがわかりますね。

========== ACTUAL ==========
[
  {
    "frame": 30,
    "notification": {
      "kind": "N",
      "value": 20,
      "hasValue": true
    }
  },
  {
    "frame": 50,
    "notification": {
      "kind": "N",
      "value": 30,
      "hasValue": true
    }
  }
]
========== EXPECTED ==========
[
  {
    "frame": 10,
    "notification": {
      "kind": "N",
      "value": 10,
      "hasValue": true
    }
  },
  {
    "frame": 30,
    "notification": {
      "kind": "N",
      "value": 20,
      "hasValue": true
    }
  },
  {
    "frame": 50,
    "notification": {
      "kind": "N",
      "value": 30,
      "hasValue": true
    }
  }
]

ちなみにこのような関数を自作すると、前ページのような出力結果を得られます。

import * as lodash from 'lodash';
import * as assert from 'assert';

export function assertDeepEqual(actual: any, exptected: any): void {
  let messages: string[] = ['\n'];
  if (!lodash.isEqual(actual, exptected)) {
    messages.push('='.repeat(10) + ' ACTUAL ' + '='.repeat(10));
    messages.push(JSON.stringify(actual, null, 2));
    messages.push('='.repeat(10) + ' EXPECTED ' + '='.repeat(10));
    messages.push(JSON.stringify(exptected, null, 2));
    messages.push('\n');
  }
  assert.deepEqual(actual, exptected, messages.join('\n'));
}

テスト失敗時にactualexpectedの配列をJSON.stringifyして出力しています。


Marble Testを書いてみると、自分が思ってた挙動と違うときがあったりするので救われる:wink:


(今日気付いたこと)

  • Marble Testの中に非同期(Promise等)が混入すると期待通りのテスト結果は得られません。おそらくMarble Testは非同期に非対応です。(自信は無い)

Thanks!