Prophecyとは
PHPUnitと組み合わせて使えるモックライブラリ。
元はphpspecの一部として開発されたもので、phpspecと組み合わせなくても単独で使うことができる。
PHPUnit 4.5からパッケージに同梱されるようになった。PHPUnitの新しいバージョンを使っていれば、composer.jsonに加えなくても使うことができる。
テストダブルとは
ユニットテストを書くときに、テスト対象の依存の代替として使うオブジェクトをテストダブルと言う。目的はいくつかある。
- 依存オブジェクトの準備を手抜きする。100行の準備をしないとテストが書けないとしたらユニットテストが読みにくくなってしまう。
- 実行しづらいメソッドを差し替える。外部へ通信してしまうもの、I/Oが発生するもの、すごーくたまにしか失敗しないものなどを強制的に変更したいとき。
- メソッドを実行したかどうかを観測する。メソッドを実行しなければテストが落ちたとみなす、など。
これらはクラスを継承して、メソッドをオーバーライドしまくることで実現できるけど、普通にPHPで書くと結構な記述量になる。そこで、いい感じに記述量を抑えてモックを生成してくれるライブラリがあって、そのうちの一つがprophecyという位置づけ。
prophecyを使う例
composerで単独インストールしたとしたらこんな感じ。
<?php
require 'vendor/autoload.php';
$prophet = new Prophecy\Prophet;
$datep = $prophet->prophesize('DateTime');
// $datep instaneof Prophecy\Prophecy\ObjectProphecy であり、設定を投げるためのオブジェクト
// 期待する動作を設定していく
$datep->modify('+1 day')->shouldBeCalled();
$datep->format('Y-m-d H:i:s')->willReturn('2015-01-01 00:00:00');
// mockオブジェクトを取得!
$date = $datep->reveal();
// $date instanceof DateTime であり、タイプヒントしてあるところに投げ込める
// (ここで$dateを使って色々操作を行う)
// shouldBeCalledなどの最終チェック。呼び出されてなければ例外発生
$prophet->checkPredictions();
PHPUnit>=4.5の場合は、以下のように書ける。
<?php
class HogeTest extends \PHPUnit_Framework_TestCase
{
function testDoSomething()
{
// prophetの準備
$datep = $this->prophesize('DateTime');
$datep->modify('+1 day')->shouldBeCalled();
$datep->format('Y-m-d H:i:s')->willReturn('2015-01-01 00:00:00');
$hoge = new Hoge($datep->reveal());
$hoge->doSomething();
// もしmodifyが呼ばれていなかったらテストが落ちる
}
}
$prophet->checkPredictions();
などはPHPUnit側で勝手にやってくれる。getMockBuilderやcreateMockと比べても申し分ない書き味ではなかろうか。
prophecy用語
- Prophecy\Prophet ... モック生成の起点となるクラス。PHPUnit内蔵版を使っている場合は意識しなくてもよい。
- prophesize() ... クラス/インターフェースを引数に取り、ObjectProphecyを作る。このObjectProphecyに対してモックの設定を書いていく。
ObjectProphecyはスパイに使ったりもするので、必ず一時変数に代入しておく。 - reveal() ... ObjectProphecyから実際にモックとして使えるインスタンスを生成する。
モック方式 vs スパイ方式
事前にテストダブルに「呼び出されるであろうメソッド」をプログラミングしておき、呼び出されなければエラーにする方式をモックと言う。モックの本来の意味はこれ。
一方で、テストダブルはメソッド呼び出しを記録するようにしておき、適当に操作をして、「どんなメソッドをどういう風に呼び出されたか」を後から記録と照合する方式をスパイと言う。
モックは即座にエラーを検知できるため詳しい設定が可能だが、テストに先立ってテストダブルに複雑な設定を行うため、テストが読みにくくなる難点がある。
スパイは何も準備しなくても使えるのでテストは読みやすい傾向がある。
Prophecyはモック方式もスパイ方式も対応している。こんなイメージになる。
$datep = $this->prophesize('DateTime');
$hoge = new Hoge($datep->reveal()); //何の設定もしてないprophecyをいきなり渡す
$hoge->doSomething();
$datep->format('Y-m-d H:i:s')->shouldHaveBeenCalled(); // shouldBeCalledじゃないことに注意
もしメソッド呼び出しの記録が残ってなければ例外が発生する。
なお、何も設定していない場合、メソッドのプロトタイプに応じて処理が行われる。PHP7でreturn type hintingがされていれば、なるべく適当な戻り値を返そうとする。
メソッド呼び出しの設定
メソッド呼び出しに関する設定はこう書く。
$datep->format('Y-m-d H:i:s') // format()メソッドのMethodProphecyを作る
->willReturn('2015-01-01 00:00:00')
->shouldBeCalled();
$datep->modify('+1 day') // modify()メソッドのMethodProphecyを作る
->willReturn(null)
->shouldBeCalled();
//...
見ての通り、->format()
と本来のメソッド呼び出しのような書き方をする。一行目では$datep
が返ってるわけではなく、新規生成されたMethodProphecyのインスタンスが返っている。
will~やshould~はMethodProphecyのメソッドなのだが、メソッドチェーンで続けて書けるようになっているため、だいたいこんな風になる。
$prophecy->{'メソッド名'}(引数の設定)
->will~(戻り値の設定)
->should~(どんなふうに呼ばれるべきかの規定);
引数を緩く指定する
引数に具体的な値を入れた場合は、完全一致する場合のみマッチングする。
->format('Y-m-d H:i:s')
と書いたら、->format(' Y-m-d H:i:s')
では別物と判定されてしまう。
もう少しゆるく指定したいことも多いと思う。このためにProphecy\Argumentというstaticなクラスが用意されていて、他の指定ができる。このクラスを適当に短い名前にimportしておくと書きやすくなる。
use Prophecy\Argument as arg;
//..
$datep->format(arg::type('string'))
->...
Argumentのメソッド名 | 説明 |
---|---|
exact(mixed) | 指定された値と完全一致するもののみOK。(==で比較) Argument::などで囲わずにいきなり値を書いた場合、同様に判定される。(なのでexactを使うことは少ない) |
type(string) | 値の型が合致すればOK。 int, stringといったスカラ型も使えて、classやinterfaceの場合はinstanceofで判定。 |
which(string, mixed) | 値に、第一引数のメソッドを適用した結果、もしくはプロパティが、第二引数と合致していればOK。 例えばDateTimeが与えられるとして、arg::which('getTimestamp', 0) みたいな感じ。 あるpropertyの値、あるgetterの結果だけで判定ができる。 |
that(callback) | 値に、callbackを作用させた結果で判定。callbackは引数一つ、レスポンスはboolean。 |
any() | なんでもOK。ただし何か値が渡ってくることは必要。 |
cetera() | なんでもOK。これを一個指定しておくと、引数の数もチェックが走らず、引数関係では例外が起きなくなる。 |
allOf(...mixed) | 複数の条件をandでまとめることができる。引数は可変長。 arg::allOf(arg::which('a', 123), arg::which('b', 223)) みたいな感じか? |
size(int) | 値が配列もしくはcountableであり、count()の適用結果が与えられた数であればOK。 |
withEntry(mixed, mixed) | 値が配列もしくはTraversableであり、第一引数と同じkey, 第二引数と同じvalueが含まれていたらOK。 |
withEveryEntry(mixed) | 値が配列もしくはTraversableであり、全要素が第一引数と合致していればOK。 |
containing(mixed) | 値が配列もしくはTraversableであり、第一引数と同じ値が含まれていたらOK。 keyを無視するところがwithEntryと違う。 |
withKey(mixed) | 値が配列もしくはTraversableであり、第一引数と同じ値がkeyに含まれていたらOK。 valueを無視するところがwithEntryと違う。 |
not(mixed) | 値が指定された値と違っていたらOK。exactの否定みたいな感じ。 |
containingString(string) | 値が文字列で、指定された部分文字列を含んでいたらOK。 |
is(mixed) | exactに似ているが、===で比較して合致したらOK。 |
approximate(float, int=0) | 値がintかfloatで、指定した小数点を丸めた上で比較、第一引数と===ならOK。 第二引数で何桁で丸めるか指定できる。 |
戻り値の設定 (will~)
テストダブルが何か返してくれないとテストコードは正常に動かないであろう。戻り値の挙動を設定することができる。
メソッド名 | 説明 |
---|---|
willReturn(mixed) | 指定された値を返す |
willReturnArgument(int) | メソッドの引数のうち、指定された番号の引数をそのまま返す。数字は0から始まる。 例えば->foo(arg::any(), arg::any())->willReturnArgument(0) であれば、最初のarg::any()に渡された値を返す。 |
willThrow(string or Exception) | 指定された例外を投げる。 文字列でクラスを指定することもできる。 |
will(callable) | コールバックで戻り値を指定する。これがあれば複雑なことでも何でもできる。 |
willは非常に柔軟で、戻り値を自由に指定できるだけでなく、PHP5.4以降であれば$this
で`今生成中のObjectProphecyを参照することができる。
以下のように「setName()を実行すると、getName()のレスポンスが変わる」ぐらいだったら実装可能。
use Prophecy\Argument;
$user->getName()->willReturn(null);
// For PHP 5.4
$user->setName(Argument::type('string'))->will(function ($args) {
$this->getName()->willReturn($args[0]);
});
// For PHP 5.3
$user->setName(Argument::type('string'))->will(function ($args, $user) {
$user->getName()->willReturn($args[0]);
});
// Or
$user->setName(Argument::type('string'))->will(function ($args) use ($user) {
$user->getName()->willReturn($args[0]);
});
$user->setName(Argument::any())->will(function () {
});
なお、引数によってマッチングを行うので、こんな風に同じメソッドに対して引数が違うバージョンで複数の設定を書くこともできる。
$datep->format('c')->willReturn('2015-01-01T00:00:00+09:00');
$datep->format('Y')->willReturn('2015');
呼び出しに関する宣言 (should~)
モック方式
メソッド名 | 説明 |
---|---|
shouldBeCalled() | メソッドが呼ばれればOK (一度も呼ばれてなければNG) |
shouldNotBeCalled() | メソッドが呼ばれなければOK |
shouldBeCalledTimes(int) | メソッドが指定回数呼ばれればOK |
should(callback) | callbackを使って詳しく条件を指定する |
shouldの指定方法は柔軟だが結構複雑。
コールバックは引数を3つ取る。第一引数がProphecy\Call\Call
の配列で、メソッドの呼び出し履歴の記録である。
呼び出し元のファイル名や行数などもとれるが、正直なところ筆者は使ったことがない。使いたければProphecyのソースコードとにらめっこすることになるだろう。
$datep->format('Y')
->should(function(array $calls, ObjectProphecy $object, MethodProphecy $method){
//...
});
スパイ方式
should~がshouldHave~に変わるだけで、モック方式と指定の仕方は変わらない。
メソッド名 | 説明 |
---|---|
shouldHaveBeenCalled() | メソッドが呼ばれればOK (一度も呼ばれてなければNG) |
shouldNotHaveBeenCalled() | メソッドが呼ばれなければOK |
shouldHaveBeenCalledTimes(int) | メソッドが指定回数呼ばれればOK |
shouldHave(callback) | callbackを使って詳しく条件を指定する |
prophecyに対する個人的な感想
- きれい。
- static呼び出しはシンタックスシュガーを除けば作っていない。ちゃんとオブジェクト指向してる。
- ソースコードも素直で読みやすい。デメテル守ってる!!感
- prophetオブジェクトとモックオブジェクトを分離したため、定義が簡単に書けるし、変に元のクラスと干渉しない
- phpunitの
getMockBuilder()
とは違うのだよ!!
- phpunitの
- スパイに対応。
- 意外と珍しいんじゃない?
- これによってアサーションの数を必要最小限に抑えることができる。
- 原理主義。
- パーシャルモックは絶対に実装しない主義を貫いている。issueを検索すると子気味良い。
- 元の実装は上書きして発動させない
- オブジェクト指向的に正しく書けば、モックもシンプルに書ける。誘導される感覚。
- 元の実装に依存しなければテストが書けないとしたら、それは実装がおかしいのである。インターフェースに依存せよ。実装に依存してはならない。
- 「レガシーコードがー」と叫んでくる外野に屈せず、このまま突っ走ってほしい
Mockery、Phake、PHPUnit/MockObjectなどなど、今までモッキングライブラリは色々あったけど、phpunitにも組み込まれたことだし、今後はprophecyが主流になるんじゃないか。
「prophecyはいいぞ」