JavaScript
TypeScript

Sinon.JS でテストダブルを理解する

スパイ、スタブ、モック、フェイク、ダミー、それぞれの役割って言えますか?
私はつい最近まで言えませんでした。

だいぶ前に Sinon.JS というパッケージがある事を知ったのですが最近改めてドキュメントを読んだところ、これらを学ぶのにとても適している事に気づきました。Sinon.JS ではスパイやスタブなどが spy stub など独立したオブジェクトとして存在していて、それぞれのメソッド名も returns() notCalled() withArgs() など役割をそのまま表現した名前になっています。

APIリファレンスを眺めていたら spy.returns() メソッドは存在しない事に気づいてしまいました。
今まで何でもかんでも spyXX のように変数名を付けて、コール回数やら引数の検証やら戻り値の変更やら、ひとつのオブジェクトで全てを検証しようと詰め込んでいましたが、これは間違いだったんですね。
Sinon.JS を使うと検証内容に適したオブジェクトを必然的に選ぶ事になるため、良い習慣が身につきそうです。

この記事は Sinon.JS をガッツリ使う前準備として、用語について調べた事、ちょっと試して理解した事をまとめたものです。

http://sinonjs.org/

ちょっと試した時の環境

  • TypeScript 2.8
  • Webpack 4.4
  • sinon 4.5
  • Karma 2.0
  • Jasmine 3.1
  • node 9.9

ソースコードは Github で公開していますので、興味のある方はどうぞ。
https://github.com/ringtail003/typescript-webpack4-karma-sinon

Test Double(テストダブル)

まず用語について整理しておきましょう。

例えば、APIリクエストを実行してレスポンスを整形して表示するサービスがあったとします。

このようなサービスのテストは、リクエストの内容の検証、リクエストが成功しレスポンスが返却された場合の挙動の検証、リクエストが失敗した場合の挙動の検証、といったところでしょうか。簡単に思いつくのは、実際に存在する URL の HTTP リクエストを使ったリクエスト成功の検証、存在しない URL の HTTP リクエストを使ったリクエスト失敗の検証です。ただしこのテストは非常に不安定です。「実際に存在するURL」がメンテナンス中のお知らせのレスポンスを返したり、ネットワーク環境の影響でタイムアウトが発生したりすると、テストは続行できません。

この時に登場するのが テストダブル です。 HTTP リクエストをテストダブルに置き換える事で「リクエストが成功しレスポンスが返却される」「リクエストが失敗し HTTP 304 が返却される」などの状況を作る事ができます。実際の通信エラーなどに左右されず、テストダブルから受け取った情報を元にサービスがどのような挙動をするのかを検証すれば良い事になります。

  • HTTP リクエストを1回だけコールした
  • HTTP リクエストが失敗した時にリトライしコール回数は2回になった
  • HTTP リクエストが成功した時にフォーマッターを呼び出した
  • HTTP リクエストが失敗した時にフォーマッターを呼び出さなかった

テストダブルは、レスポンスの返却や、コール回数・引数の検証など、担う役割によってスパイ、スタブというように分類されます。

https://ja.wikipedia.org/wiki/テストダブル より引用
テストダブル (Test Double) とは、ソフトウェアテストにおいて、テスト対象が依存しているコンポーネントを置き換える代用品のこと。ダブルは代役、影武者を意味する。

Wikipedia では、スパイやスタブは テストダブルのパターン として記述されています。

パターン 用途
テストスタブ テスト対象に「間接的な入力」を提供するために使う。
テストスパイ テスト対象からの「間接的な出力」を検証するために使う。出力を記録しておくことで、テストコードの実行後に、値を取り出して検証できる。
モックオブジェクト テスト対象からの「間接的な出力」を検証するために使う。テストコードの実行前に、あらかじめ期待する結果を設定しておく。検証はオブジェクト内部で行われる。
フェイクオブジェクト 実際のオブジェクトに近い働きをするが、より単純な実装を使う。例として、実際のデータベースを置き換えるインメモリデータベースが挙げられる。
ダミーオブジェクト テスト対象のメソッドがパラメータを必要としているが、そのパラメータが利用されない場合に渡すオブジェクト。

テスト対象のソースコード

この記事では、以下のクラスを使ってテストを書く事にします。

export class Item {
  constructor(
    private price: number = 0,
  ) {}

  calculateDiscount(rate: number): number {
    return this.price - (this.price * 0.01 * rate);
  }
}

export class Order {
  private items: Item[] = [];
  private payment: number; 
  private paymentAt: Date = null;

  add(item: Item) {
    this.items.push(item);
  }

  calculateDiscount(rate: number): number {
    return this.items.reduce(
      (price,item) => price + item.calculateDiscount(rate), 
      0
    ); 
  }

  pay(paymentAt: Date, discountRate: number = 0) {
    this.payment = this.calculateDiscount(discountRate);
    this.paymentAt = paymentAt;
  }

  getPayment(): number {
    return this.payment;
  }

  receipt(): string {
    return this.paymentAt.getFullYear() + 
      '/' + 
      (this.paymentAt.getMonth()+1) + 
      ' ' + this.payment + '円'
    ;
  }
}

簡単に説明すると Order(注文)に Item(商品)を追加し、割引を適用した金額で支払い処理を行うものです。

const order = new Order();

order.add(new Item(100)); // 100円の商品を追加
order.add(new Item(200)); // 200円の商品を追加

order.pay(new Date(), 50); // 支払い(50%引き)
order.receipt(); // '2018/04 150円' というレシートを出力

テスト対象は Order クラスです。依存する Item クラスを Sinon.JS のテストダブルに置き換えて、それぞれのアプローチの違いを見てみましょう。

Spy(スパイ)

http://sinonjs.org/releases/v4.5.0/spies/

ドキュメント意訳
スパイは、引数、戻り値、thisの値、およびすべての呼び出しに対してスローされた例外(存在する場合)を記録する。

Sinon.JS でスパイを利用するには以下のように宣言します。

import * as sinon from 'sinon';

const spy = sinon.spy(); // 無名関数のスパイ
const spy = sinon.spy(myFunc); // 関数のスパイ
const spy = sinon.spy(obj, "method"); // メソッドのスパイ

スパイは以下のようなメソッドを持ちます。(一部抜粋したもの)

  • spy.withArgs(arg1[, arg2, ...]);
  • spy.calledWith(arg1, arg2, ...);
  • spy.calledOnceWith(arg1, arg2, ...);
  • spy.calledWithMatch(arg1, arg2, ...);
  • spy.calledWithNew();
  • spy.callCount
  • spy.called
  • spy.notCalled
  • spy.calledOnce
  • spy.firstCall
  • spy.calledBefore(anothierSpy)
  • spy.calledAfter(anotherSpy);
  • spy.threw();
  • spy.threw("TypeError");
  • spy.alwaysThrew("TypeError");
  • stub.returned(obj);

メソッドを眺めると、コール回数や引数、戻り値、例外を検証できる事が分かります。

スパイで Order クラスを検証する

import { Item, Order } from './index';
import * as sinon from 'sinon';

it('spy', () => {
  // 100円の商品を作成、割引計算をスパイとして設定
  const item = new Item(100);
  const spy = sinon.spy(item, 'calculateDiscount');

  // 注文に商品を2つ追加
  const order = new Order();
  order.add(item);
  order.add(item);

  // 支払い(20%引き)
  order.pay(new Date(), 20);

  // 商品(スパイ)の20%割引計算が2回呼び出された事を検証
  expect(spy.withArgs(20).calledTwice).toBe(true);

  // 合計金額は (100 * 0.8 = 80円) x 2 = 160円 になる
  expect(order.getPayment()).toBe(160); 
});

スパイの役割

スパイは コール回数、引数、戻り値、例外の情報を記録 します。テスト対象の処理が完了した後に、記録を照らし合わせて期待する呼び出しが行われたかどうかを検証します。

また Sinon.JS のスパイは単なる「記録係」です。そのため元のソースコードはそのまま実行されます。

const obj: any = { method: () => { console.log('called.'); } };
sinon.spy(obj, 'method');

obj.method(); // 'called.'

Stub(スタブ)

http://sinonjs.org/releases/v4.5.0/stubs/

ドキュメント意訳
あらかじめプログラムされた動作を持つスパイ。
エラーハンドリングなどのためにメソッドの振る舞いを変更する必要がある時、また元のコードが直接呼び出されないようにする必要がある時にスタブを用いる。

Sinon.JS でスタブを利用するには以下のように宣言します。

import * as sinon from 'sinon';

const stub = sinon.stub(); // 無名関数のスタブ
const stub = sinon.stub(obj, "method"); // メソッドのスタブ
const stub = sinon.stub(obj); // 全てのメソッドのスタブ

スタブは以下のようなメソッドを持ちます。(一部抜粋したもの)

  • stub.withArgs(arg1[, arg2, ...]);
  • stub.onCall(n).returns(obj);
  • stub.callsFake(fakeFunction);
  • stub.returnsArg(index);
  • stub.returnsThis();
  • stub.throws();
  • stub.rejects();
  • stub.callThrough();
  • stub.yields([arg1, arg2, ...])
  • stub(spy).withArgs(arg1[, arg2, ...]);
  • stub(spy).calledWith(arg1, arg2, ...);
  • stub(spy).calledOnceWith(arg1, arg2, ...);
  • stub(spy).calledWithMatch(arg1, arg2, ...);

引数の検証の他、何か返却したり例外を発生させられる事が分かります。

スタブで Order クラスを検証する

it('stub', () => {
  // 商品を作成(金額は省略)、割引計算をスタブとして設定
  const item = new Item();
  const stub = sinon.stub(item, 'calculateDiscount');

  // 割引計算の結果を、0回目:70円、1回目:80円とする
  stub.onCall(0).returns(70);
  stub.onCall(1).returns(80);

  // 注文に商品を2つ追加
  const order = new Order();
  order.add(item);
  order.add(item);

  // 支払い(20%引き)
  order.pay(new Date(), 20);

  // 商品(スタブ)の20%割引計算が2回呼び出された事を検証
  expect(stub.withArgs(20).calledTwice).toBe(true);

  // 合計金額は 70 + 80 = 150円 になる
  expect(order.getPayment()).toBe(150); 
});

スパイの場合は Item クラスの割引計算の結果を気にする必要がありましたが、スタブの場合は Order クラスの振る舞い(足し算)だけ気にすれば良くなりました。

スタブの役割

Sinon.stubSinon.spy を拡張したオブジェクトです。そのためスパイの持つメソッド(コール回数や引数の検証)も利用する事ができます。

スタブが独自に持つ機能は「戻り値の変更」です。これを利用すると、コール回数や引数に応じて戻り値にバリエーションを持たせる事ができます。

// 0回目のコールの場合 'ABC' を返却
stub.onCall(0).returns('ABC');

// 引数が 100 の場合 'DEF' を返却
stub.withArgs(100).returns('DEF');

// 元のメソッドの振る舞いを変更する事もできる
stub.callsFake(() => {
  objA.status = objB.status;
});

スタブはメソッドを置き換えるため、元のソースコードは実行されません。

const obj: any = { method: () => { console.log('called.'); } };
sinon.stub(obj, 'method');

obj.method(); // 何も表示されない

元のソースコードを実行せずメソッドや関数の置き換えをしたい場合 はスタブが適していると言えます。必要であれば returns() を利用して 戻り値を返却 します。

Mock(モック)

http://sinonjs.org/releases/v4.5.0/mocks/

ドキュメント意訳
スパイのようなフェイクメソッドと、スタブのような振る舞いを持つ。期待する挙動でない場合に検証を失敗させる。

期待する挙動をあらかじめ定義し、それに沿った挙動かどうかを検証する時にモックを利用する。アサーションを利用せず振る舞いを変更するようなケースではスタブを利用する。

Sinon.JS でモックを利用するには以下のように宣言します。

import * as sinon from 'sinon';

const mock = sinon.mock(obj);

モックは以下のようなメソッドを持ちます。(一部抜粋したもの)

  • sinon.mock(obj).expects("method");
  • sinon.mock(obj).verify();
  • sinon.mock(obj).restore();

expects() は expectation を返却します。この expectation に、期待するコール回数や引数を設定します。(expectation はスパイ・スタブを拡張しているため、これらのメソッドが利用可能です。)

  • expectation.never()
  • expectation.once()
  • expectation.twice()
  • expectation.exactly(n: number);
  • expectation.withArgs(...args: any[]);

verify() は必ず呼ばなければいけません。
verify() によって expectation に設定した通りの呼び出しが行われたか検査されます。

モックで Order クラスを検証する

it('mock', () => {
  const item = new Item();
  const mock = sinon.mock(item);

  // 割引計算をモックとして設定
  mock.expects('calculateDiscount')
    .twice() // 2回呼ばれる
    .withArgs(10) // 10%割引
    .returns(90) // 90円 を計算結果として返却
  ;

  // 注文に商品を2つ追加
  const order = new Order();
  order.add(item);
  order.add(item);

  // 支払い(10%引き)
  order.pay(new Date(), 10);

  // 検証
  mock.verify();

  // 90 + 90 = 180円
  expect(order.getPayment()).toBe(180);
});

スパイやスタブと明らかに違うのは、検証内容を事前に宣言する部分です。

モックの役割

モックは 事前にシナリオを宣言し、そのシナリオ通りの挙動をしているかを検証 します。

また Sinon.JS のドキュメントでは「1つのテストに複数のモックを宣言するべきではない」と書かれています。これは、モックとシナリオが等価のため、複数のシナリオの登場によって検証が複雑化し目的を見失ってしまう事を懸念しているのではないかと思います。(個人的な感想です)

Fake(フェイク)

フェイクはオブジェクトや関数などを偽の実装で置き換えます。
Sinon.JS ではフェイクタイマーとフェイク HTTP リクエストが提供されています。

http://sinonjs.org/releases/v4.5.0/fake-timers/
http://sinonjs.org/releases/v4.5.0/fake-xhr-and-server/

フェイクで Order クラスに偽の時刻を与える

フェイクは検証の手法のひとつではないため、スパイやスタブを使ったアプローチと比較する事はできません。この例ではフェイクタイマーを使って、支払い日時を置き換えています。

describe('fake', () => {
  let clock: any;

  beforeEach(() => {
    // 現在時刻を 2018/04/01 09:15 に変更
    clock = sinon.useFakeTimers(1522541700000);
  });

  afterEach(() => {
    // 現在時刻を元に戻す
    clock.restore();
  });

  it('paid', () => {
    const order = new Order();
    order.add(new Item(100));
    order.pay(new Date());

    // テストが実際の現在時刻に左右されず、常に同じ結果が得られる
    expect(order.receipt()).toBe('2018/4 100円');
  });
});

フェイクの役割

置き換えをするという点ではスタブと同じですが、フェイクは コール回数や引数など、呼び出しに関する検証が必要ない 場合に利用します。また 振る舞いや戻り値は1パターン しかなく、スタブのように引数に応じて戻り値にバリエーションを持たせるような事はありません。

Dummy(ダミー)

Sinon.JS にダミーの提供はありません。ダミーは機能ではないからです。

ダミーで Order クラスに偽のカード情報を与える

Order クラスでダミーを使った検証ができるように、メソッドを追加します。

export class CreditCard {
  no: string;
  kind: string;
}

class Order {
  private card?: CreditCard = null;
  ...
  payByCreditCard(
    paymentAt: Date, 
    discountRate: number = 0, 
    card: CreditCard,
  ) {
    this.pay(paymentAt, discountRate);
    this.card = card;
  } 

payByCreditCard() を呼び出すには CreditCard(クレジットカード情報)が必要です。ただし支払い処理にクレジットカード情報は関係しないため、金額を検証するには「クレジットカード情報の形をした何か」を渡せば良い事になります。

it('dummy', () => {
  const item = new Item(100);
  const order = new Order();
  order.add(item);

  const dummyCard: CreditCard = {
    no: '1234-0000-1234-0000',
    kind: 'visa',
  };

  order.payByCreditCard(new Date(), 20, dummyCard);
  expect(order.getPayment()).toBe(80);
});

「クレジットカード情報の形をした何か」を渡しました。CreditCard 型の要件を満たすなら、中身のテキストは何でも構いません。

ダミーの役割

ダミーは 検証の必要がなく振る舞いや戻り値が1パターンしかない、かつ内容そのものが検証に影響しない 事を示します。先ほどのフェイクの例ではフェイクタイマーの時刻を変更すると検証が失敗しますが、ダミーのカード種別を visa から master に変更しても検証結果は変化しません。

まとめ

Sinon.JS のテストダブルと役割の関係はこのようになっています。

役割/テストダブル スパイ スタブ モック フェイク ダミー
コール回数や引数を検証 - -
元のソースコードが呼ばれる - - - -
戻り値の変更 - -
戻り値のバリエーション - - -
検証が強制される - - - -

冒頭の文面を再掲してみます。

spy.returns() メソッドは存在しない事に気づいてしまいました。
今まで何でもかんでも spyXX のように変数名を付けて、コール回数やら引数の検証やら戻り値の変更やら、ひとつのオブジェクトで全てを検証しようと詰め込んでいましたが、これは間違いだったんですね。

何が間違いなのか、ここまで読み進めていただいた方には理解いただけたのではないでしょうか。

スパイやスタブの違いを意識していなくてもテストは可能で、検証内容が正しければ「テストをきちんと書いている」と言えるでしょう。けれども、検証も戻り値も持たないスタブが宣言されていれば「元のソースコードを実行したくない」という事が伝わりますし、ダミーを表す変数名が付いていれば「要件を満たすためのもので内容はなんでも良い」事が伝わります。テストの読みやすさが向上すれば、数ヶ月後にメンテナンスをする自分やチーム開発のしやすさに貢献できるのではないかと思います。

Sinon.JS はテストダブルのそれぞれの役割が学べるだけでなく、

  • 検証内容が明確に表現できる Assertion
  • オブジェクト比較などかゆい所に手が届く Matcher
  • スタブなどで変更した内容を一括で全てクリアできる Sandbox

のような機能も提供されていて、テストを強力にサポートしてくれそうです。
ぜひ一度使ってみてはいかがでしょうか。