30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Sinon.jsでspy,stub,mockを使う

Sinon.jsというライブラリを利用することで、テストダブルを用いたテストを書くことが出来る。

テストを書く際、そのテストが依存している特定の処理を書き換えることで、テストが書きやすくなるケースがある。
APIを叩いたり、データベースと接続したりする処理などが、それにあたる。

これらは環境を用意するのが面倒であること多く、こういった処理があるとテストが書きにくい。
そこで、そういった面倒な処理を別のオブジェクトで置き換えてしまう。こうすることでテストが書きやすくなる。

この、面倒な処理を置き換えるためのオブジェクトが、テストダブルである。

テストダブルにはいくつか種類があり、代表的なのがspy,stub,mockの3つ。

以下、それぞれの使い方を見ていく。テストフレームワークにはmochaを使っている。

spy

いきなりだが、spyは、上記の「テストダブル」の定義からは少し外れている気がする。
既存の処理を置き換えてしまうのではなくて、スパイの名の通り、既存の処理に「仕込む」といったほうが正しいように思う。
spyを既存の関数に仕込んでおくことで、その関数が呼ばれた際の引数や戻り値、呼ばれた回数などを記録し、検証することが出来る。

まず以下のような形で、テストしたい対象にスパイを仕込む。

const spy = sinon.spy(対象のオブジェクト, '対象のメソッドの名前');

後は、spyオブジェクトが持っている様々なメソッドを使って、テストが出来る。
例えば、spy.callCountではテスト対象が呼ばれた回数を、spy.withArgs(arg).callCountではテスト対象が特定の引数で呼ばれた回数を、それぞれ調べることが出来る。
他にも様々なメソッドがある。

以下はそのサンプル。
myObj.incCountmyObj.operateDataに渡された引数が0を越えているときにの呼ばれるが、その挙動をテストしている。

import sinon from 'sinon';
import assert from 'assert';

const myObj = {
  count: 0,
  operateDate(arg) {
    if (myObj.isPositive(arg)) myObj.incCount(arg);
  },
  incCount(arg) {
    myObj.count += arg;
    return myObj.count;
  },
  isPositive(arg) {
    return arg > 0;
  },
};

describe('spy', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('method', () => {
    const spy = sinon.spy(myObj, 'incCount');
    myObj.operateDate(5);
    myObj.operateDate(-1);
    myObj.operateDate(3);
    myObj.operateDate(0);
    assert(spy.callCount === 2);  // incCountが呼ばれた回数
    assert(spy.getCall(0).args[0] === 5); // 最初にincCountが呼ばれた際の引数
    assert(spy.args[0][0] === 5); // 同じく、最初にincCountが呼ばれた際の引数
    assert(spy.returnValues[1] === 8);  // 2回目にincCountが呼ばれた際の戻り値
    assert(spy.withArgs(3).callCount === 1);  // incCountが引数3で呼ばれた回数
  });
});

restore()

以下のテストは、アサーションは正しいが、パスしない。
TypeError: Attempted to wrap incCount which is already wrappedというエラーが出る。

import sinon from 'sinon';
import assert from 'assert';

const myObj = {
  count: 0,
  operateDate(arg) {
    if (myObj.isPositive(arg)) myObj.incCount(arg);
  },
  incCount(arg) {
    myObj.count += arg;
    return myObj.count;
  },
  isPositive(arg) {
    return arg > 0;
  },
};

describe('spy', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('method', () => {
    const spy = sinon.spy(myObj, 'incCount');
    myObj.operateDate(1);
    assert(spy.callCount === 1);  // incCountが呼ばれた回数
  });
  it('method2', () => {
    const spy = sinon.spy(myObj, 'incCount');
    myObj.operateDate(-1);
    assert(spy.callCount === 0);  // incCountが呼ばれた回数
  });
});

incCountに対して二重にスパイを仕込む形になってしまうから、エラーになったのだろう。
そのため、テストが終わる度に、スパイの解除が必須になる。
具体的にはspy.restore()とすると、メソッドに対するスパイを解除することが出来る。
上記の例では、1回目のitの最後にspy.restore()とすることで、テストがパスするようになる。

これは、スタブやモックなど、他のテストダブルでも同様。

stub

スタブはスパイと違い、対象のメソッドを完全に置き換える。ラップする、といったほうが正確のようだが。
スパイとは異なり、元の挙動を完全に上書きする。

スタブによってラップされたメソッドは、従来の処理を全く行わない。
戻り値はundefinedとなる。

import sinon from 'sinon';
import assert from 'assert';

const myObj = {
  count: 0,
  operateDate(arg) {
    if (myObj.isPositive(arg)) myObj.incCount(arg);
  },
  incCount(arg) {
    myObj.count += arg;
    return myObj.count;
  },
  isPositive(arg) {
    return arg > 0;
  },
};

describe('stub', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('wrap', () => {
    assert(myObj.incCount(2) === 2);
    assert(myObj.count === 2);
    const stub = sinon.stub(myObj, 'incCount');
    assert(myObj.incCount(3) === undefined);  // stubの戻り値はundefinedになる
    assert(myObj.count === 2);  // myObj.countは2のまま
    stub.restore(); // restore()によって、ラップされた状態は終わる
    assert(myObj.incCount(3) === 5);
    assert(myObj.count === 5);
  });
});

stub.returns()で戻り値を指定することも出来る。
また、stub.withArgs(hoge).returns(true)とすると、引数がhogeの場合はtrueを返す。
渡された引数についての指定がある場合はそれを、無い場合はstub.returns()で指定されたものを、その指定がない場合はundefinedを、返す。

describe('stub', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('returns', () => {
    const stub = sinon.stub(myObj, 'incCount');
    stub.returns('hoge');
    assert(myObj.incCount(0) === 'hoge');
    stub.withArgs(0).returns('zero');
    stub.withArgs(9).returns('nine');
    assert(myObj.incCount(0) === 'zero');
    assert(myObj.incCount(1) === 'hoge');
    assert(myObj.incCount(9) === 'nine');
    stub.restore();
  });
});

コールバックを指定することも可能。
stub.callsArg(Number)とすることで、Number番目の引数を、コールバックとして実行するようになる。
また、stub.callsArg(Number, Arg1, Arg2)とすると、Arg1以降を、コールバック関数に引数として渡す。これは複数設定することが出来る。

describe('stub', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('callback', () => {
    const countIsNine = () => {
      myObj.count = 9;
    };
    const changeCount = (arg) => {
      myObj.count = arg;
      return false;
    };
    const stub = sinon.stub(myObj, 'incCount');
    stub.callsArg(0);
    myObj.incCount(countIsNine);
    assert(myObj.count === 9);
    stub.callsArgWith(1, 'hoge');
    myObj.incCount(countIsNine, changeCount); // changeCount('hoge')を実行する
    assert(myObj.count === 'hoge');
    stub.restore();
  });
});

stub.throws()とすれば、スタブを実行した際に例外が投げられる。
特定の引数が渡された際にのみ例外を投げたければ、stub.withArgs(引数).throws();とすればいい。

describe('stub', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('throws', () => {
    const stub = sinon.stub(myObj, 'incCount');
    stub.withArgs(false).throws('error message');
    try {
      myObj.incCount();
    } catch (e) {
      myObj.count = e.name;
    }
    assert(myObj.count === 0);
    try {
      myObj.incCount(false);
    } catch (e) {
      myObj.count = e.name;
    }
    assert(myObj.count === 'error message');
    stub.restore();
  });
});

mock

モックは、メソッドがどのように呼び出されるかなどを調べるために、使われる。

オブジェクトをラップすることで使えるようになる。
その後、調べたいメソッドについてexpectsを設定する。

obj.methodが1度呼ばれることを期待する場合は、以下のように書く。

const mock = sinon.mock(obj);
mock.expects('method').once();

このようにすると、obj.methodの呼び出しをチェックするようになる。
また、スタブと同じようにラップされ、本来の挙動は行われない。
method以外のobjのメソッドは、通常通りに動く。
expectsを設定したメソッドのみがラップされる。

mock.verify()を実行すると、メソッドが期待された動きをしたかチェックし、期待から外れていた場合は例外を投げる。
また、verify()を実行しなくても、期待から外れた時点で、verify()の実行を待つこと無く、例外を投げる。
先程のmock.expects('method').once();の例だと、obj.methodが2回呼ばれた時点で、例外を投げる。

withArgs()を使うと、メソッドを呼び出す際の引数を指定できる。
指定した以外の引数でメソッドを呼び出すと、その時点で例外を投げる。

import sinon from 'sinon';
import assert from 'assert';

const myObj = {
  count: 0,
  operateDate(arg) {
    if (myObj.isPositive(arg)) myObj.incCount(arg);
  },
  incCount(arg) {
    myObj.count += arg;
    return myObj.count;
  },
  isPositive(arg) {
    return arg > 0;
  },
};

describe('mock', () => {
  let mock;
  beforeEach(() => {
    myObj.count = 0;
  });
  afterEach(() => {
    mock.restore();
  });
  it('basic', () => {
    mock = sinon.mock(myObj);
    assert(myObj.incCount(3) === 3);
    assert(myObj.count === 3);
    mock.expects('incCount').once();
    assert(myObj.incCount(2) === undefined);  // incCountはwrapされる
    assert(myObj.count === 3);
    assert(myObj.isPositive(-1) === false); // isPositiveはwrapされていない
    mock.verify();
  });
  it('verify1', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').once();
    mock.verify();  // ここで例外を投げる
    console.log('このコードは実行されない');
  });
  it('verify2', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').once();
    myObj.incCount(1);
    myObj.incCount(1);  // ここで例外を投げる
    console.log('このコード以降は実行されない');
    mock.verify();
  });
  it('arg1', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').withArgs(1).exactly(2);
    myObj.incCount(1);
    myObj.incCount(1);
    mock.verify();  // このテストはパスする
  });
  it('arg2', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').withArgs(1).exactly(2);
    myObj.incCount(1);
    mock.verify();  // ここで例外を投げる
  });
  it('arg3', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').withArgs(1).exactly(2);
    myObj.incCount(0);  // ここで例外を投げる
  });
});

参考資料