普通じゃないモッキングフレームワークAspectMockがパワフル過ぎる post

この記事はPHP Advent Calendar 2014の10日目です。昨日はwateradaさんの「PHPコードのレビュー結果を共有してみる」でした。

今日は、イケてるモッキングフレームワークAspectMockを紹介します。

テストに関するよくある誤解

以下のようなクラスがある場合、Model_FormがActive Recordだとして、「このクラスはデータベースを操作せずにユニットテストすることができません」と言う人がいます。

class Controller_Admin_Form extends Controller_Admin
{
    public function action_index()
    {
        $data['forms'] = Model_Form::find(
            'all', 
            array('order_by' => array('created_at' => 'desc'))
        );
        $this->template->title = "問い合わせ";
        $this->template->content = View::forge('admin/form/index', $data);
    }
}

Model_Form::find()メソッドがハードコードされているためです。

しかし、Model_Form::find()をテストダブル(モック)で置き換えることができれば、データベースなしでテストすることはもちろん可能です。

それ、AspectMockなら簡単にできます。

test::double('Model_Form', ['find', []]);

これで、Model_Form::find()は空の配列を返すようになります。

データベースを操作せずにテスト可能になりました。つまり、静的メソッドがテストできないというのはAspectMockがなかった時代の古い知識です。現在は、正しくありません。

Note: 静的メソッドをハードコードするコーディングを推奨しているわけではありません。テストできないという説は単に正しくないと言っているだけです。

Note: AspectMock以前でもrunkitなどのPHPの機能拡張を使えば同じようなことは実現できました。

AspectMockとは?

AspectMockは普通じゃないPHPのモッキングフレームワーク。アスペクト指向プログラミングとイケてるGo-AOPライブラリのパワーで、AspectMockはほとんど全てのPHPコードのスタブとモックを作成できる! https://github.com/Codeception/AspectMock

AspectMockには以下のような特徴があります。

  • 静的メソッドのテストダブルの作成が可能
  • メソッドの動的な変更が可能
  • 名前空間内の関数のテストダブルの作成が可能
  • 覚えられるシンプルなシンタックス

AspectMockの要件

PHP 5.4以上が必要です。

AspectMockのインストールと設定

AspectMockは、Composerから簡単にインストールできます。

$ composer require codeception/aspect-mock:* --dev

注意

  • 重いので本番環境にはインストールしない方がよい
  • 必ずPHPUnitもプロジェクト内にインストールする

インストールが完了したら、テスト実行時にAspectMockを使うように設定します。Composerのオートローダを使っている場合は以下のように設定します。

require __DIR__.'/../vendor/autoload.php'; // Composerのオートローダ

$kernel = \AspectMock\Kernel::getInstance();
$kernel->init([
    'debug' => true,
    'includePaths' => [__DIR__.'/../src'],
    'cacheDir' => __DIR__.'/cache/AspectMock',
]);

$kernel->init()の引数

項目 説明
appDir Webアプリのルートフォルダ。デフォルトはComposerのvendorフォルダのあるフォルダ
includePaths AOPを適用して置き替えたいフォルダ
excludePaths AOPを適用しないフォルダ。テストフォルダは指定すべき
cacheDir キャッシュフォルダ

PHPUnitの設定

続いて、PHPUnitからAspectMockを使うための設定をします。まず、backupGlobalsを必ずfalseにします。

phpunit.xml

<phpunit bootstrap="bootstrap.php" backupGlobals="false">

そして、tearDown()メソッドで登録したテストダブルを削除するようにします。

TestCase

<?php
use AspectMock\Test as test;

abstract class TestCase extends \PHPUnit_Framework_TestCase
{
    protected function tearDown()
    {
        test::clean(); // 登録したテストダブルを削除
    }
}

AspectMockの使い方

それでは、AspectMockの使い方を見ていきましょう。

静的メソッドの置き換え

静的メソッドを置き換える構文は以下のようなシンプルなものです。

構文

test::double('クラス名', ['メソッド名' => 返り値]);

 @return ClassProxy

これなら覚えられますね。この場合、ClassProxyクラスのオブジェクトが返ります。

指定クラスの指定メソッドを置き換える
$fs = test::double('Fuel\Core\Fieldset', ['repopulate' => true]);

これでFuel\Core\Fieldsetクラスのrepopulate()メソッドはtrueを返すようになります。

例外を発生させる
$model_mail = test::double(
    'Model_Mail',
    ['send' => function() { throw new EmailSendingFailedException; }]
);

これでModel_Mailクラスのsend()メソッドは例外EmailSendingFailedExceptionを投げるようになります。

引数の値によりテストダブルが返す値を変更する

引数の値によりテストダブルの動作を変更することも可能です。

test::double('Fuel\Core\Config', ['get' => function ($arg) {
    if ($arg === 'foo.bar') {
        return 'foo.bar';
    } else {
        return 'baz';
    }
}]);

引数の値がfoo.barの場合は、foo.barが返り、その他の場合はbazが返ります。

引数の値により実際のメソッドを実行する

引数の値により実際のメソッドを実行することも可能です。

test::double('Fuel\Core\Config', ['get' => function ($arg) {
    if ($arg === 'foo.bar') {
        return 'foo.bar';
    } else {
        // モックせずに実際のメソッドを実行させる
        return __AM_CONTINUE__;
    }
}]);

関数の置き換え

名前空間内にある関数は同じように置き換えできます。

構文

test::func('名前空間', '関数名', 返り値);

 @return FuncProxy
PHP内部関数の置き換え
$func = test::func(__NAMESPACE__, 'header', '');

現在の名前空間内でheader()関数が空文字列を返すだけになります。

メソッド呼び出しの検証

メソッドが呼び出されたことを検証することももちろんできます。

$user = test::double(new User, ['getName' => 'davert']);
$this->assertEquals('davert', $user->getName());

$user->verifyMethodInvoked('getName');                 // 呼び出されたか?
$user->verifyMethodInvoked('setName', ['davert']);     // 引数の指定
$user->verifyMethodInvokedOnce('getName');             // 一度だけ?
$user->verifyMethodNeverInvoked('setName');            // 呼び出されない?
$user->verifyMethodInvokedMultipleTimes('setName', 1); // 呼び出し回数の指定

これらのメソッドは、Proxyクラス(ClassProxyおよびInstanceProxy)が持つメソッドになります。

モックオブジェクトを取得するには?

タイプヒントで指定のタイプのモックが必要な場合は、Proxyクラスからモックオブジェクトを取得します。

ClassProxyの場合

// コンストラクタを呼ばずにモックオブジェクトを取得
$user = test::double('User')->make(); 

// コンストラクタを呼び出しモックオブジェクトを取得
$user = test::double('User')->construct([
     'name' => 'davert',
     'email' => 'davert@mail.ua'
]);

InstanceProxyの場合

$user = test::double(new User)->getObject();

AspectMockの仕組み

さて、どうしてAspectMockではこのようなことが可能なのでしょう?

AspectMockは、AOP(アスペクト指向プログラミング)によりモックするメソッドをインターセプトして置き換えます。

具体的には、Go AOPライブラリを使いPHPファイルを、以下のように動的に書き換えています。

class User
{
    function setName($name)
    {
        $this->name = $name;
    }    
}

class User
{    
    function setName($name)
    { if (($__am_res = __amock_before($this, __CLASS__, __FUNCTION__, 
array(), false)) !== __AM_CONTINUE__) return $__am_res; 
        $this->name = $name;
    }    

}

このようにして、指定のメソッドをテストダブルで置き換えるかどうかという処理をしているわけです。

さらに知るには?

さらに知りたい場合は、AspectMockのリポジトリや解説記事などをご覧願います。公式のドキュメント(英語)はリポジトリにあります。

(2014/12/10 13:38追記)

AspectMockのまとめ

  • 静的メソッドや関数のテストダブルを作成できます
  • テストダブル作成の構文は簡単で覚えられます
  • テストできないものはほぼありません
  • ガンガンテストしましょう♪

この記事はPHP Advent Calendar 2014の10日目です。明日はne_sachirouさんの「PHPで簡単に華麗にDIとAOPをキメる」です。

Date: 2014/12/10

Tags: php, aspectmock, phpunit, testing