pixiv insideは移転しました! ≫ https://inside.pixiv.blog/

PHP のスタティックメソッドをモック化する

初登場の @tototoshi です。今回は pixiv のユニットテストで利用しているモックライブラリの紹介をします。

ここ2ヶ月くらいの間、レガシー化したとあるモジュールのリファクタリングに取り組んでいました。 リファクタリングにはテストコードが必須です。しかし今ではすっかりテストを書く文化が根付いている pixiv にもテストコードがない時代がありました。リファクタリングが必要な古いコードにはテストコードがないことが多く、そういったコードに新たにテストをつけていくのはなかなか大変です。テストの概念のないプロジェクトはテスト可能なように設計・実装されていません。テストを書くのが大変なのではなく、書けるようにするまでが大変です。

特にやっかいだったのは DB を参照したりするようなスタティックメソッドを使ったメソッドのテストでした。以下のようなスタティックな Dao を使用した Service 層のメソッドをイメージして頂けると良いでしょう。

1
2
3
4
5
6
7
8
9
10
<?php
class UserService
{
    private $user_id;
    ...
    public function isGoodUser($user_id)
    {
        $user = UserDao::find($user_id); // データベースへ接続してユーザー情報を取得する
        ...
    }
}

テスタビリティを考えればデータベースに依存しているメソッドはモック化できないスタティックメソッドではなくインスタンスメソッドとしたり、なにがしかの手段でデータベースへの依存を疎にする工夫をするべきだと思います。しかしテストを考えずに書かれたコードはそのようにはなってはいません。pixiv のコードにもこのようなデータベースべったり、環境べったりなスタティックメソッドがたくさん存在します。

データベースに依存しているスタティックメソッドを内部で使用しているメソッドをテストするためにはあらかじめデータベースにデータを投入する必要があります。Dao 層のテストであればデータベースにデータを入れてテストするのは当然ですが、Dao よりももっと上の層にあるメソッドのテストをするためにその都度データベースへデータを投入するのはめんどうですし、目的であるメソッドのユニットテストからは離れてしまいます。したいのはデータのテストではないのです。

さらに悪いことに、無駄にデータベースにデータを投入するようなテストを続けているとテストの所要時間もどんどん増えていきます。

レガシーなコードを片っ端から書き直していけば良いかもしれませんが、それは時間的制約からかないません。そこでなんとかしてスタティックメソッドもモック化した上でユニットテストを書けないだろうかと試行錯誤を重ねていました。そこで目をつけたのが runkit です。

runkit とは

runkit は、動的に PHP の定数や関数、クラス、メソッドを追加・変更・削除 する関数を追加する PHP 拡張です。

runkit のインストールは以下の手順で行えます。手順を実行したあとで、php.ini に extension=runkit.so を加えてください。

1
2
3
4
cd /tmp
git clone https://github.com/zenovich/runkit.git
cd runkit
pecl install package.xml

例えばメソッドを動的に変更したいというときは次のような runkit 関数が使用できます。

runkit_method_rename メソッド名を変更する
runkit_method_add メソッド名を変更する
runkit_method_remove メソッドを削除する

使用例を以下に示します。

A::foo() というスタティックメソッドの出力するメッセージが runkit 関数を実行することで変化していることに注目して下さい。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class A
{
    public static function foo()
    {
        echo "foo" . PHP_EOL;
    }
}
A::foo(); //=> "foo"
// A::foo を A::_foo にリネームし、A::foo の新しい実装を追加する
runkit_method_rename("A", "foo", "_foo");
runkit_method_add("A", "foo", "", "echo \"added by runkit\" . PHP_EOL;", RUNKIT_ACC_STATIC);
A::foo(); //=> "added by runkit"
// もとに戻す
runkit_method_remove("A", "foo");
runkit_method_rename("A", "_foo", "foo");
A::foo(); //=> "foo"

runkit を使えばテストの間だけ実装をすり替え、テストが終わった後元に戻すという方法でスタティックメソッドのモック化が行えます。とりあえずなんとかなりそうです。

StaticMock

runkit を使えば目的自体はなんとか達成できそうなことがわかりました。とは言えこれではまだ使いにくすぎてとても常用はできません。そこで runkit をラップしたテストヘルパークラスを作りはじめました。何度かの試行錯誤の後に生まれたのが StaticMock です。

StaticMock は runkit を利用して作られた DSL ライブラリです。テストメソッド内だけで一時的にスタティックメソッドを置き換えることでユニットテスト用のモック・スタブ機能を提供します。特定のテストフレームワークには依存していません。

StaticMock を使うと最初に登場した UserService::isGoodUser() のテストは以下のように書くことができます。

1
2
3
4
5
6
7
8
9
10
11
<?php
public function testIsGoodUser()
{
    $user_id = 1;
    $mock = StaticMock::mock('UserDao::find')
        ->with($user_id)
        ->once()
        ->andReturn(new User(1));
    UserService::isGoodUser($user_id);
    $mock->assert();
}

UserService::isGoodUser() の中で使用されている UserDao::find() は StaticMock によりこのテストメソッド内では new User(1) という値を返すようになります。withonce といったメソッドでアサーションを仕込むこともできます。この場合は UserDao::find()UserService::isGoodUser の中で1回、$user_id を引数として呼ばれなければ、最後の $mock->assert() の時点でエラーとなります。 StaticMock の mock オブジェクトがスコープを抜けるとそのデストラクタ内でメソッドの実装の復元が行われます。

StaticMock でよく使うメソッドの一覧を表にしました。

メソッド効果
with($args …) モックしたメソッドに渡されるべき引数を指定する。
withNthArg($n, $arg) モックしたメソッドに渡されるべきn番目(1-origin)の引数を指定する。
times($n) モックしたメソッドがn回呼び出されるべきであることを示す。
once() モックしたメソッドが1回呼び出されるべきであることを示す。
twice() モックしたメソッドが2回呼び出されるべきであることを示す。
never() モックしたメソッドが一度も呼び出されないべきであることを示す。
andReturn($value) モック化したメソッドの戻り値を指定する(スタブ機能)。
andImplement(Callable $func) メソッドの実装を指定した無名関数で置き換える。
assert() アサーションを実行する。with, once などを呼び出した後 assert を実行していないとアサーション忘れの警告メッセージが表示される。

PHPUnit と一緒に使う

StaticMock は特定のフレームワークに依存していませんが、PHPUnit のための Constraint を用意しています。 StaticMockConstraint クラスを使用すれば簡単に PHPUnit_Framework_TestCase クラスを拡張し、StaticMock 用のアサーションメソッドを追加できます。次の例は assertStaticMock() というメソッドを追加する例です。

1
2
3
4
5
6
7
8
9
<?php
use StaticMock\Mock;
use StaticMock\PHPUnit\StaticMockConstraint;
class StaticMockTestCase extends \PHPUnit_Framework_TestCase
{
    public function assertStaticMock(Mock $mock)
    {
        $this->assertThat($mock, new StaticMockConstraint);
    }
}

Mock::assert() ではなく、assertStaticMock() メソッドを使用すると PHPUnit のテスト結果のカウンターに StaticMock のアサーションの結果も含まれるようになります。

1
2
Time: 1 second, Memory: 30.00Mb
OK (7 tests, 7 assertions)  // <-- ここ

まとめ

レガシーなスタティックメソッドをテストするために runkit を用いてモックライブラリを作成しました。これにより、テストを書くのに割く労力が減り、以前はテストをあきらめていた箇所のテストも書くことが可能になりました。テストの間に DB へ接続する回数も減り、テストの実行も高速化できました。

StaticMock はまだ未熟なところも多々あります。特にエラーメッセージの改善や、フールプルーフな作り込みがまだ足りないと思っています。その他にも何か問題がありましたら github で報告して頂けると大変有り難いです。