🍰

PhakeのモックでCakePHPをテスト コントローラ篇・改

2020/09/25に公開

こんにちは。前回のPhake のモックで CakePHP をテスト コントローラ篇にもレスポンスが頂けて嬉しいです。天の声に耳を傾けていると(ただのエゴ・サーチです…)素敵なご指摘が聞けたので、改訂版として掲載します。予定していた「コンポーネント篇」は改訂版のおかげで不要になりました! 準備も少なくて済む画期的な方法です。

Controller で Phake モックを使いたいという背景については前回の記事をそのまま残しますのでご参照ください。

--

※本記事では CakePHP に Phake を導入する手法について解説しています。Phake 自体の使い方や PHPUnit 標準モックとの比較は過去の記事にて紹介しています。

2 段階のサブクラスを作成する

前回と同じく、CakePHP のControllerTestCaseから機能を拡張するためMyControllerTestCaseを作成します。abstract classとして継承しないとテスト実行時に「テストがありません」と怒られます。

abstract class MyControllerTestCase extends ControllerTestCase
{
    public function setUp()
    {
        parent::setUp();
    }
    // 略
}

今回は例としてPostsControllerという Controller についてテストを行うため、さらにもう 1 段階サブクラスを作成します。

abstract class PostsControllerTestCase extends MyControllerTestCase
{
    public function setUp()
    {
        parent::setUp();
    }
    // 略
}

PostsControllerにはindex()add()edit()といったメソッドが備わっているとします。

これらのテストを 1 つのファイルで扱う「1 クラス-1 テストクラス」でも問題はありませんが、モック・テストは行数が嵩むため、分割して「1 メソッド-1 テストクラス」で進める方がクラスごとに共通処理をくくるsetUp()の恩恵を受けやすく、効率が良いと感じています。

  • PostsControllerIndexTest
  • PostsControllerAddTest
  • PostsControllerEditTest

例としてこのようなテストクラスを作成します。これらは全てPostsControllerTestCaseを継承します。

Phake モック注入のための準備

ここからが前回と大きく異る方法です。天の声によるとClassRegistryという CakePHP API を活用すれば良いと聞こえました。恥ずかしながら私はこのクラスの存在を知らなかったのですが、調べてみると非常によくできたクラスで、「CakePHP は高結合度で使いにくいヤツだ!」と dis っていたことはこの場で謝ります。

前回はFactoryComponentを作成し

  • PHPUnit モック生成 →FactoryComponentスタブ定義 →Phake モック注入

という方法をとっていました。今回、改訂版では PHPUnit モックすら使わずいきなり

  • ClassRegistry→Phake モック注入

とできます。準備の行数や手間も遥かに少なくて済みます。

ClassRegistry を用いた injection()を実装する

abstract class MyControllerTestCase extends ControllerTestCase
{
    public function setUp()
    {
        parent::setUp();
    }

    public function tearDown()
    {
        parent::tearDown();
    }

    public function convertToKey($mock)
    {
        $class  = get_class($mock);
        $array  = explode('_', $class);
        $result = preg_replace('/(Helper|Component)/', '', $array[0]);
        return $result;
    }

    public function injection($mock)
    {
        $args = func_get_args();
        foreach ($args as $k => $v) {
            $key = $this->convertToKey($v);
            ClassRegistry::addObject($key, $v);
        }
    }
}

MyControllerTestCaseinjection()を実装します。convertToKey()メソッドはクラス名文字列を取得するためのものです。Phake ではModel_PHAKE53205b373bad5というような固有の ID をクラス名に与えるようで、この先頭部分のみ取り出すためです。さらに CakePHP の規則では Controller 内ではFooComponentBarHelperといったクラス名の先頭部分(Foo, Bar)しかプロパティ名として扱わないため、適合するように変換しています。

injection()は前回と異なり、ClassRegistry::addObject()を用いています。可変引数として、渡したモックを全て注入します。

各 Controller ごとの TestCase を準備

MyControllerTestCaseのサブクラスでも準備が必要です。PostsControllerTestCaseに次のように記述します。

abstract class PostsControllerTestCase extends MyControllerTestCase
{
    public function setUp()
    {
        parent::setUp();
        $this->controller = $this->generate('Posts');
    }

    public function tearDown()
    {
        parent::tearDown();
    }
}

$this->generate()を使う点は前回と同じですが、ここで Component 群のモックを作成していません。Controller のモックを作成するのみです。

ここも省略してしまうとClassRegistryの内容が断絶してしまったので、PHPUnit モックを完全に使わない方法は、やはり無理なようです。

Phake モック受け取り側の準備

受け取り側とは、テスト対象となる実際の Controller のことです。自分で実装する各種 Controller はControllerを直接継承せずに間にAppControllerを挟みます。このAppControllerクラスに受け取り準備をしましょう。

class AppController extends Controller
{
    public function beforeFilter()
    {
        parent::beforeFilter();

        $class = Hash::merge(
            $this->uses,
            $this->helpers,
            array_keys($this->components)
        );

        foreach ($class as $key) {
            $this->{$key} = (ClassRegistry::isKeySet($key))
                            ? ClassRegistry::getObject($key)
                            : $this->{$key};
        }
    }
}
  • $this->uses
  • $this->helpers
  • $this->components

これらはControllerの public なプロパティです。CakePHP の作法ではここに文字列でクラス名を列挙します。コア実装によると、これらの生成はClassRegistryでしていたようで、その手法を借りた形です。

$class変数に一旦 Controller が使用する全てのクラス名をまとめます。そしてforeachで個々のクラスに対して処理をします。ClassRegistryはキー名のみ指定するほか、 事前にインスタンスの参照自体を渡す こともできます。この方法で Phake モック・インスタンスのやりとりをします。

上記injection()ClassRegistry::addObject()しなければ、ClassRegistry::isKeySet()falseを返します。そのときは手を加えずに CakePHP 本来の実装に頼りインスタンスを保持します。もしtrueなら(つまりテスト中にinjection()を用いたら)ここで Phake モックが介入できます。もし$classに不正な文字列が紛れ込んでもisKeySet()falseな限り無害です。

独自クラスの受け取りについて

このままでは CakePHP サブクラスしか受け取ることができません。CakePHP に依存しないロジックをまとめたクラスなどは、AppControllerではなく該当 Controller 内で生成します。

public function beforeFilter()
{
    parent::beforeFilter();

    $this->Filter = (ClassRegistry::isKeySet('Filter'))
                    ? ClassRegistry::getObject('Filter')
                    : new Filter;
}

Filterは独自に作成したクラスを想定しています。new Filterとベタ書きしていますが、ClassRegistry::getObject()によって介入の余地を残しているため結合度は下がります。ただし先頭でApp::uses()する必要は出てくるので、分離を徹底する場合はここで別のFactoryを挟むべきです。現在はちょっと妥協してます。

あとはテストを書くだけ

整いました。あとはテストを書くだけです。前回と準備内容は大きく異るもののインタフェースを変えていないので、テストメソッドは書き直さずそのまま使えます。

public function test_save_success()
{
    // Setup
    $any = Phake::anyParameters();

    $Post = Phake::mock('Post');
    Phake::when($Post)->save($any)->thenReturn(true);

    $Filter = Phake::mock('Filter');
    $dummy  = Phake::partialMock('CommentObject');
    Phake::when($Filter)->filterComment($any)->thenReturn($dummy);
    Phake::when($Filter)->save($dummy)       ->thenReturn(true);

    $this->injection($Post, $Filter);

    // Test
    $this->testAction('/posts/edit/', ['method' => 'POST', 'data' => ['dummy']);

    // Verify
    Phake::verify($Post)  ->save($any);
    Phake::verify($Filter)->filterComment($any);
    Phake::verify($Filter)->save($dummy);
}

ここだけ読むと分かりませんが、内部では PHPUnit モックを作成せず、いきなりClassRegistry経由で Phake モックが使われています。すごい!

改訂版のメリット多し

前回は独自にFactoryComponentを用意して、beforeFilter()にも色々書いて…と手間が掛かっていました。掲載予定は無くなりましたが、準備していた「コンポーネント篇」ではさらにもう一手間増えていました。それが Model、Component、独自クラス、全てのクラスをただClassRegistryでまかなうことが出来るのです。

準備が少なくなったので、ますます Phake モックテストを導入しやすくなったと思います。ぜひ、このテストの書き味を体験してみてください!

--

最後に、「ClassRegistry を使えばよくなる」と教えて下さった天の声の主様、ClassRegistryについての解説を載せていたブログの方々に感謝します。もっと精進します。

--

2014/5/9 追記: 改めてPhake のモックで CakePHP をテスト コンポーネント篇を書きました。Component を単独でテストしたい場合はこちらです。

Discussion