プロダクトコードの強制モック化 with PHP5.1


モックを独自に作ろう

こんにちは(=゚ω゚)ノ

今回は、PHPUnitを使ってUnitTestを書く時に大切になるモックを作る独自メソッドを作成しました。
理由としては、PHPUnitのバージョンを上げた時にメソッドの書き換えなどをしなくても済むようにするためです。

テスト書く前にバージョンアップしろよって話なんですがまぁそう簡単にはいかない大人の事情があるのです

runkit関数を使う

今回はPHPのrunkit関数を使用してメソッドをゴニョゴニョします。
この関数を使えば、メソッドや組み込み関数の処理を動的に変更したり、有無を言わさず抹消したりすることが出来ちゃう恐ろしくも中毒じみた威力を発揮する魔法です。
この関数を使って、本来のメソッドの皮を被った偽物を作り出しちゃおうというのが作戦です。

今回は例として、以下のようなメソッドに対してスタブを作成します。

class TestClass {
    public function add10($a) {
        return $a + $this->return10();
    }

    private function return10(){
        return 10;
    }
}

今回目標とするのは

  • return10()メソッドのスタブを作り、return10()が10以外の値を返すこと
  • add10()を呼んだ時に本物のreturn10()が通らないこと
  • 他のテストケースに影響しないこと

です。

今回はスタブを作り、テストコードをスムーズに書けることが目的なので、テスト駆動開発(TDD)を採用して進めようと思います。ですので、先に理想とするテストコードを書いておきます。

class TestClassTest extends TestClass {
    public function test_generate_return10のスタブを作ること() {
        RunkitStub::generate('TestClass', 'return10', 50);
        $instance = new TestClass();
        $result = $instance->add10(10);
        $this->assertSame(60, $result);
    }

    public function tearDown() {
        RunkitStub::revertAll();
    }
}

TestClassTestとかいう色々ツッコミどころのあるクラス名になってしまいましたが許して下さい←
このテストを書くことによって、generate()の仕様をある程度固めながら実装を進めることが出来ます。
generate()の引数は、順にクラス名, メソッド名, 期待する戻り値です。

runkit関数を使っていよいよスタブ化メソッドの実装

では実際に中身を実装していきます。
流れとしては以下の通りです。※以下、面倒なのでTestClassのコードを「本番コード」と呼ぶことにします。

  1. 本番コードのクラス名とメソッド名を受け取り、ランダムな文字列にリネーム
  2. メソッド名とランダムな文字列を紐づけるために、プロパティにスタック
  3. 該当するメソッド名と同じ名前の偽メソッドを作成(この時、偽メソッドの中身は引数として受け取った値を返すだけ)
  4. 偽メソッドが本番コードになりすましている間にテストを実行
  5. テストが終わったら偽メソッドを消して、プロパティから本番コードを取り出し、元の名前にリネーム

少し分かりづらいかも知れませんが、要は本番コードと同じ名前の偽物コードを生成してテスト実行時に代行してもらうというかなり強引なやり方です。

では行きましょう(=゚ω゚)ノ

1. 本番コードのクラス名とメソッド名を受け取り、ランダムな文字列にリネーム

まず、受け取ったメソッドをリネームする必要があります。
この時に注意しなければならないのが、リネームをする際は一意な値でなければならないことと、リネームする前の本来のクラス名とメソッド名から切り離してはいけないことです。
メソッド名をランダムな文字列に変換する処理ですが、ここでhash()などを使いたいところですが、ハッシュ値にしてしまうと、例えば同じテストケース内で同じメソッド名を複数回スタブ化する場合に同一のメソッド名になってしまい、エラーとなってしまいます。
今回私はuniqid()というものを使い、'dummy'という文字列から一意な文字列を生成しました。

$tmp_hashed_method_name = uniqid('dummy_');
runkit_method_rename($class_name, $method_name, $tmp_hashed_method_name);

2. メソッド名とランダムな文字列を紐づけるために、プロパティに連想配列としてスタック

本番コードのメソッドに一時的に皮を被せたところで、ユニークな値とそのメソッドの情報をプロパティに格納します。
こうしないと、テストが終わった後に本番コードを元に戻せなくなります。ここが一番悩んだ

まずクラスの配列プロパティを宣言しておきます。


private static $stashed_methods = array();

定義したプロパティに、ユニークな値とクラス名・メソッド名をぶっ込みます。


self::$stashed_methods[] = array(
        'class_name'   => $class_name,
        'method_name'  => $method_name,
        'hashed_name'  => $hashed_name
);

これで本番コードを無事に退避させることが出来ました。

3. 該当するメソッド名と同じ名前の偽メソッドを作成(この時、偽メソッドの中身は指定された戻り値を返すだけ)

さて、本番コードを避難させたところで偽物くんに登場していただきましょう。
ここでは、runkit_method_addを使用して引数として受け取ったクラス名とメソッド名、そしてreturnした値を元にスタブメソッドを作成します。

runkit_method_add($class_name, $method_name, '', "return $return;");

runkit_method_addの引数とか詳しいことは公式ドキュメントをご参照ください。
この1行で、引数として受け取った値を返すだけのメソッドが作れちゃう!すごいや!!

4. 偽メソッドが本番コードになりすましている間にテストを実行

頑張ってもらいましょう。

5. テストが終わったら、偽メソッドには消えてもらう(この処理は、後のテストケースに影響が出ないようtearDownに記載)

さて、偽物を駆除する時間です。
テストが終わった後、作成したスタブを消さないと後のテストやカバレッジに大きく影響します。
ここで注意しなければならないことは、後ろのスタブから順に削除することと、プロパティの中身を全て削除することです。

まず後ろのスタブから順に消す理由についてですが、1つのテストケースの中で複数回スタブを生成した場合に問題が発生しないようにするためです。
2回目以降に生成されたスタブは、それより前の処理で作られたスタブから作られるスタブだからです。
すんごい変な日本語で申し訳ないですが、1つのテストケースで複数回スタブを生成した場合、後ろから戻していかないと途中で辻褄が合わなくなってしまいます。それを防ぐために、プロパティにスタックされたメソッド達を一つずつポップします。


while(!empty(self::$stashed_methods)){
    $origin = array_pop(self::$stashed_methods);
    runkit_method_remove($origin['class_name'], $origin['method_name']);
    runkit_method_rename($origin['class_name'], $origin['hashed_name'], $origin['method_name']);
}

こうすることで、次のテストケースに余計な情報を渡すことなく何事も無かったかのようにテストを終えることが出来ます。

カバレッジを見る

実際にRunkitStubクラスを使ってテストを実行して、カバレッジを見てみましょう。
先述したテストコードを実行して、カバレッジが以下のようになっていれば、return10()のスタブが上手く作られている証拠です。

まとめ

まとめるとこんな感じです。

<?php
class RunkitStub {

private static $stashed_methods = array();

protected function generate($class_name, $method_name, $return) {
    $hashed_name = uniqid('Dummy__'); //同じメソッド名でも違う値になるようuniqidを使用
    runkit_method_rename($class_name, $method_name, $hashed_name);

    self::$stashed_methods[] = array(
        'class_name'   => $class_name,
        'method_name'  => $method_name,
        'hashed_name'  => $hashed_name
    );

    runkit_method_add($class_name, $method_name, '', "return $return;");
}

protected function revertAll() {
    while(!empty(self::$stashed_methods)){
        $origin = array_pop(self::$stashed_methods);
        runkit_method_remove($origin['class_name'], $origin['method_name']);
        runkit_method_rename($origin['class_name'], $origin['hashed_name'], 
$origin['method_name']);
    }
}

かなり強引なやり方ですので、勝手にやると怒られる場合があるので真似る際は自己責任でお願いします。

参考

[runkit関数]
http://php.net/manual/ja/ref.runkit.php