🍰

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

2020/09/25に公開

140313 追記: 以下の方法を使わずに更にシンプルに準備できる改訂版を掲載しました。以下は古い内容をそのまま載せています。

--

前回のPhake のモックで CakePHP をテスト モデル篇の続きです。Phakeは PHPUnit 標準のモック機構と違い When, Verify に分けて定義を記述できる点が特徴で、流れを追いやすく一行もシンプルで済む今おすすめのモック・フレームワークです。

さて、この Phake を CakePHP の Controller Test で活用しようとすると、いきなり壁にブチ当たります。

##CakePHP は PHPUnit を標準採用している
幸か不幸か CakePHP は PHPUnit を標準で採用しており、高い親和性で書けるのが特徴ですが、Phake といった外部テスト・フレームワークの入る余地は少なくなっています。

どうにか Phake 一本で Controller のモック化が出来ないものか…と悩み続け最終的に思いついたアイデアは「妥協して PHPUnit のモックを併用する」ということでした。

##PHPUnit モックを踏み台にする

結合度の高い CakePHP Controller

今回の本題です。CakePHP の Controller は、命名規則から動的に生成される Model オブジェクトや、共通して使うメソッドをまとめた Component から成り立ちます。関係の深い Model 以外のものを使う場合は、app::import()してからnew Model()するという説明があります。

public $components = [
    'RequestHandler',
    'MySession',
    'MySomeModule', // なにかしら独自に作ったコンポーネント群
];

// 中略

public function edit()
{
    app::import('Model'); // 他のモデルもちょっと使いたい
    $this->Model = new Model();
    // ...
}

この文字列ベタ書きの指定や Controller 実装内でのnewコンストラクタの呼び出しは、結合度を 極めて高めます! ガッチガチに結びつきます!

CakePHP リファレンスでも説明されるこの書き方で通常は問題ありません。しかし Phake テストを行おうとすると、この結合度の高さが問題となります。

###結合度を下げる
CakePHPControllerTestCaseではgenerate()という Controller モック生成メソッドが準備されています。これは PHPUnit のモック機構を利用します。Phake ではなく、やたらと改行の多い忌まわしいモックが生まれてしまうのです。

しかし PHPUnit モックも常用しなければ無害。PHPUnit モックを踏み台にして Phake モックを注入すれば、以降は Phake ひとつでテストを書き進められます。

##Phake モックを注入するための準備
準備が多いのは最初だけです。一度スーパークラスに準備しておけば、あとはサブクラスでテストをガンガン書けるようになります。

ControllerTestCase のサブクラスを作成

まず CakePHP 標準の Controller テスト用クラスControllerTestCaseのサブクラスを作成します。

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

ここにinjection()というメソッド(と関連メソッド)を実装します。

class MyControllerTestCase extends ControllerTestCase
{
    // 略
    public function getParentClassOfPhake($object)
    {
        $class = get_class($object);
        $array = explode('_', $class);
        return $array[0];
    }

    public function injection(CKString $factory, $mock)
    {
        $method = 'factory'.$this->getParentClassOfPhake($mock);

        $this->controller->Factory
        ->expects($this->any())
        ->method($method)
        ->will($this->returnValue($mock));
    }
}

getParentClassOfPhake()は内部処理を追い出したものです。Phake ではModel_PHAKE53205b373bad5というような固有の ID をクラス名に与えるようで、この先頭部分のみ取り出すためです。injection()の使い方は後述します。

もう一段階サブクラスを作成

今度は実際にテストが記述されるテストクラスを作成します。ですが個人的なオススメとしてはもう一段階、TestCase のサブクラスとしてPostsControllerTestCaseを作っておくと後々便利です。

今回はPostsControllerというクラスをテストすると想定します。PostsControllerにはindex()add()edit()といったメソッドが備わっているとします。

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

  • PostsControllerIndexTest
  • PostsControllerAddTest
  • PostsControllerEditTest

PostsControllerTestCaseを継承して、このようなテストクラスを作成することになります。

generate()を活用する

CakePHP の Controller テストで介入の余地があるのはgenerate()に限ります。

generate()は Controller 名を与えることでモックを生成します。同時に Model 名などを記述すれば、関連する Model、Component といった結合度の高いものも「丸ごと PHPUnit で」モック生成してくれます。今回は Phake が使いたいので、ここで生成してはいけません。ここで生成するのはFactoryだけです。

Controller のモックを generate()で生成

class PostsControllerTestCase extends MyControllerTestCase
{
    public function setUp()
    {
        parent::setUp(); // どのサブクラスでも忘れないように

        $this->controller = $this->generate(
            'Posts', // Controller名
            ['components' => ['Factory']] // FactoryComponentのMockを生成
        );
    }
    // 略
}

一段階サブクラスを挟んだのは、この Controller モック生成処理をまとめておくためでもあります。このクラスのsetUp()でモックを生成します。

###FactoryComponent を準備
FactoryComponentもまだ準備していませんでした。Phake モックを注入するための重要なクラスです。DI コンテナだとか Abstract Factory パターンだとか色々ありますが、その辺のつもりです。

ここでPhake::mock()とするのではなく、ここでは常用する正規のクラス名を記述します。


class FactoryComponent extends Component
{
    public function factoryPost()
    {
        return new Post;
    }

    public function factoryComment()
    {
        return new Comment;
    }

    // Model以外のロジックをまとめたクラスなどもここで
    public function factoryFilter()
    {
        return new Filter;
    }

    // 必要なクラス分準備
}

PostsControllerでもFactoryComponentの使用を明言しなくてはなりません。

public $components = [
    'RequestHandler',
    'Factory', // 追加
    'MySession',
    'MySomeModule',
];

さらに、PostsControllerbeforeFilter()で、FactoryComponentが生成したオブジェクトを受け取るための処理を追加します。

public function beforeFilter()
{
    parent::beforeFilter(); // 忘れないように

    $this->Post    = $this->Factory->factoryPost();
    $this->Comment = $this->Factory->factoryComment();
    $this->Filter  = $this->Factory->factoryFilter();
}

慣れた方ならもう理解されたかもしれません。通常の動作ではFactoryComponentは正規のオブジェクトを返しますが、テスト時には、先に準備したinjection()によってここで Phake モックを注入するのです!

あとはテストを書くだけ

長かった準備がようやく終わりました。この準備を済ませておけばテストを書きながらアレコレ考える必要はありません。

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

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

    $Filter = Phake::mock('Filter');
    $dummy  = Phake::partialMock('CommentObject');
    Phake::when($Filter)->filterComment($any)->thenReturn($dummy);
    Phake::when($Filter)->save($dummy)->thenReturn(true);
    $this->injection($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);
}

筆者が開発しているものと例が全然違い貧弱な例で申し訳ないですが、複数の Model が絡む Controller テストも、このように PHPUnit モック機構に触れず書くことができます。事前にスーパークラスでinjection()を実装したのはこのためです。

Phake::when()の定義後、必ずinjection()する必要がある点は前回モデル篇より手間ですが、前回と同様に準備と検証の分かれたスッキリとした記述が可能です。PHPUnit モックでは関連クラスが増えれば増えるほど行はどんどん嵩み、->がズラズラ並び、見るのも嫌になってしまいます。

一手間かける価値あり

いかがでしたか。このテーマを書くと決めた時点で長くなることは覚悟していましたが、実際書いてみると随分長くなりました。すみません。

PHPUnit モックから Phake モックを注入するというアイデアを思いついてからも試行錯誤を重ねており、ここに至るまでに数日掛かってしまいましたが、この一手間のお陰で今では Controller テストも臆することなくガンガン書けています。テストがしっかり書けるということは、それだけリファクタリングに強くなるので大胆な分割や委譲もしやすくなり、結果的には効率化に結びつきました。

「PHPUnit のモックにそろそろ不満が…」という方は、ぜひともこの書き心地を試してもらいたいです!

さらにつづく

すでに長いので今回は省略しましたが、このままではまだ他の Component が絡んだテストで Phake を使うことができません。Component を Phake モックに置き換えるためにはあと少しの手間が必要になります。それについてはまた次回。(140313 追記: Component にも対応した改訂版を掲載しました。

ここまで読んでいただきまして、ありがとうございました!

Discussion