こもろぐ @tenkoma

What We Find Changes Who We Become -- Peter Morville著『アンビエント・ファインダビリティ 』

広告:本ブログで紹介している書籍等商品の紹介でAmazonアソシエイトを利用していることがあります。

CakePHP2 アプリでも PHPUnit と PhpStormを連携させる

CakePHP Advent Calendar 2018 - Qiita 3日目の記事になります。

去年のCakePHPアドベントカレンダーではCakePHP 3 のチュートリアルにユニットテストを追加する話を書きましたが、 今年はCake 2のテストの話を書きます。

CakePHP2 アプリは PhpStorm の恩恵が受けにくい

プロダクト開発でCakePHP2 を使ってアプリを構築していると、 PhpStorm 機能の恩恵を受けられない以下のような難点があります。

  • DBのデータを取り出すと配列で、コード補完ができない
  • 名前空間対応してないので、コード補完ができない
  • ユニットテスト実行が、phpunitコマンドではなく、独自のコマンド Console/cake test であり、PhpStorm と連携できない

1つ目と2つ目もつらい感じですが、テストを書くうえは3番目もつらいですね。 連携できれば、特定のテストメソッド実行や、行カバレッジをエディタに反映できてテストを書くのがはかどるのに!

ずっとそう思っていましたが、CakePHP のGitHubを見ていると次のIssueを見つけました。

[CakePHP2] Support for running phpunit directly? · Issue #12700 · cakephp/cakephp

But this means you can't integrate it with tooling like IDE/PhpStorm which expect to run phpunit directly and pass it appropriate flags.

This is very useful for re-running partial tests (i.e. only the failed tests), etc.

要は 「phpunit コマンドでテスト実行できないと、PhpStorm と連携できないけど、連携できると部分的にテスト再実行できたりして便利」という話です。

同じことを考えているCake2ユーザーは結構いるんじゃないかと思います。 ちなみにCakePHP 3は phpunit コマンドで実行できるので、PhpStorm 連携も簡単にできるはずです。

(2013年には独自コマンド Console/cake test のまま連携するためのノウハウがPhpStorm Blogで公開されていました(Running CakePHP2 Unit Tests in PhpStorm | PhpStorm Blog)が現在は動かないみたいです。)

さて、このIssueを見て僕は思いました。PhpStorm と連携できると、テストをとても書きやすくなるし、敷居も下がるはず。 そのためになんとかして phpunit コマンドでテストを動かせないか。

試行錯誤してみたところ、狙い通り動かすことができたので、記事としてまとめます。

目次

CakePHP 2 アプリのユニットテストをPhpStorm と連携させてできること

できたことを先に紹介します!

PhpStorm からテスト実行できる、結果がエディタに反映される

CakePHP2 アプリをインストールすると用意されている PagesController にテストを書いてみましょう。(app/Test/Case/Controller/PagesControllerTest.php)

<?php
App::uses('AppControllerTestCase', 'TestSuite');
class PagesControllerTest extends AppControllerTestCase
{
    public function testDisplay()
    {
        $this->testAction('/');
        $this->assertContains(
            'CakePHP is a rapid development framework for PHP',
            $this->contents
        );
    }

    public function testMyPage()
    {
        $this->testAction('/mypage');
    }
}

これを実行すると以下のような画面になります。

f:id:tenkoma:20181203023635p:plain

エディタ行番号の右にマークが付いていて、ここからテストを実行できます。 テスト結果は下のパネルに表示され、デバッグトレースで表示されるパスから、エディタにジャンプできます。

コードカバレッジがエディタに表示される

コードカバレッジを見れば、ユニットテストでよく分からないエラーになったときなど、どのコードまで実行されたのか簡単に追いかけることが出来ます。

class PagesControllerTest の左側のマークをクリックして、「Run 'PagesControllerTest (PHPUnit)' with Coverage」を選択してテストを実行してみましょう。 そして、テスト実行後に PagesController.php を開きます。

f:id:tenkoma:20181203024940p:plain

右側に Coverage パネルが表示され、エディタでは、実行された行(緑)と実行されてない行(赤)で色分けされています。

Xdebug 連携

Xdebug を使ったブレーク・ステップ実行自体は、phpunitコマンド化しなくても、なんとか可能と思います。 しかし、PHPUnit 連携すれば、テスト実行時にデバッグの有効無効を切り替えられるので、ブレークポイントの掃除が不要になります。

f:id:tenkoma:20181203030406p:plain

配列のアサーションDiff がエディタのDiffで見られる

assertEquals(), assertSame()*1 でテスト失敗時に「<Click to see difference>」をクリックすると以下のように表示されます。

f:id:tenkoma:20181203030717p:plain

以上のように便利になるので、ユニットテストが書きやすくなります。 それでは、連携を実現するための実装について説明します。

実装1: テストクラス・テスト対象クラスを実行するのに必要な前処理を app/Test/bootstrap.php に書く

Cake2アプリのユニットテストを実行するにはフレームワークとアプリで定義する定数、Configure、App クラスやその他の関数を読み込む必要があります。 そこで phpunit 向けのブートストラップファイルを用意し、CakePHP 2 TestShell がやってくれていた処理を書きます。

app/Test/bootstrap_for_phpunit_command.php

<?php
/**
 * Bootstrap for phpunit command
 */
/**
 * copy from app/Console/cake.php
 */
$dispatcher = 'Cake' . DS . 'Console' . DS . 'ShellDispatcher.php';
$root = dirname(dirname(dirname(__FILE__)));
$appDir = basename(dirname(dirname(__FILE__)));
$install = $root . DS . 'lib';
$composerInstall = $root . DS . $appDir . DS . 'Vendor' . DS . 'cakephp' . DS . 'cakephp' . DS . 'lib';
// the following lines differ from its sibling
// /lib/Cake/Console/Templates/skel/Console/cake.php
if (file_exists($composerInstall . DS . $dispatcher)) {
    $install = $composerInstall;
}
ini_set('include_path', $install . PATH_SEPARATOR . ini_get('include_path'));
if (!include $dispatcher) {
    trigger_error('Could not locate CakePHP core files.', E_USER_ERROR);
}
unset($dispatcher);
define('ROOT', $root);
define('APP_DIR', $appDir);
define('APP', ROOT . DS . APP_DIR . DS);
// ShellDispatcher内の、定数と環境変数を初期化するメソッドは利用するが、シェルは実行しない
new ShellDispatcher(array(getenv('_'), '-working', $appDir));
unset($root, $appDir, $install, $composerInstall);

// FixtureManager セットアップに必要
App::uses('AppFixtureManager', 'TestSuite/Fixture');

// cakeアプリならどこでも呼び出すのであえて App::uses() しないクラス。エラーになるので書く
App::uses('ClassRegistry', 'Utility');

app/Test/bootstrap.php

<?php
// phpunit コマンドから実行された場合に追加のbootstrap.php を読み込む
if (!defined('DS')) {
    define('DS', DIRECTORY_SEPARATOR);
    include_once dirname(__FILE__) . DS . 'bootstrap_for_phpunit_command.php';
}

bootstrap_for_phpunit_command.php に書かれていることは、app/Console/cake.php の記述をコピーして、必要な改変を加えたものです。 app/Test/bootstrap.php では DS 定数が定義されていたら*2読み込まないようにして、 Console/cake test でもテスト実行できるようにしています。 phpunit コマンドでこの app/Test/bootstrap.php を読み込むように設定します。

phpunit.xml.dist

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    colors="true"
    bootstrap="./app/Test/bootstrap.php"
    >
    <filter>
        <whitelist>
            <directory suffix=".php">app/Config</directory>
            <directory suffix=".php">app/Console</directory>
            <directory suffix=".php">app/Controller</directory>
            <directory suffix=".php">app/Lib</directory>
            <directory suffix=".php">app/Model</directory>
            <directory suffix=".php">app/Routing</directory>
            <directory suffix=".php">app/TestSuite</directory>
            <directory suffix=".php">app/View</directory>
        </whitelist>
    </filter>
</phpunit>

4行目の bootstrap="./app/Test/bootstrap.php" がその設定になります。 <whitelist> の部分は、コードカバレッジの連携時に必要です。

実装2: CakeTestRunner がやっていたFixtureManagerを準備する処理を実装する

phpunit コマンドでテスト実行すると、CakeTestRunner が面倒を見ていた TestCase::$fixtureManager が用意されなくなりますので、自前で準備します。 ここでは、テストケースの run() メソッドをオーバーライドして、$fixtureManager を用意しました。

コントローラーテスト用の基底クラス AppControllerTestCase とその他のテスト用の基底クラス AppTestCase にそれぞれ追加します。 追加するコードは同じなので、トレイトにまとめてもいいでしょう。

app/TestSuite/AppControllerTestCase.php

<?php
App::uses('ControllerTestCase', 'TestSuite');
class AppControllerTestCase extends ControllerTestCase
{
    public function run(PHPUnit_Framework_TestResult $result = null)
    {
        $this->setUpFixtureManagerForPhpunitCommand();
        return parent::run($result);
    }
    /**
    * 正式なユニットテスト実行コマンドではCakeTestRunnerからテスト実行するが
    * phpunit コマンドから実行したときは準備されないので TestCase::run() で準備できるようにする
    */
    private function setUpFixtureManagerForPhpunitCommand()
    {
        if (is_null($this->fixtureManager)) {
            App::uses('AppFixtureManager', 'TestSuite');
            if (class_exists('AppFixtureManager')) {
                $this->fixtureManager = new AppFixtureManager();
            } else {
                App::uses('CakeFixtureManager', 'TestSuite/Fixture');
                $this->fixtureManager = new CakeFixtureManager();
            }
            $this->fixtureManager->fixturize($this);
        }
    }
}

app/TestSuite/AppTestCase.php

<?php
App::uses('CakeTestCase', 'TestSuite');
class AppTestCase extends CakeTestCase
{
    public function run(PHPUnit_Framework_TestResult $result = null)
    {
        $this->setUpFixtureManagerForPhpunitCommand();
        return parent::run($result);
    }
    /**
    * 正式なユニットテスト実行コマンドではCakeTestRunnerからテスト実行するが
    * phpunit コマンドから実行したときは準備されないので TestCase::run() で準備できるようにする
    */
    private function setUpFixtureManagerForPhpunitCommand()
    {
        if (is_null($this->fixtureManager)) {
            App::uses('AppFixtureManager', 'TestSuite');
            if (class_exists('AppFixtureManager')) {
                $this->fixtureManager = new AppFixtureManager();
            } else {
                App::uses('CakeFixtureManager', 'TestSuite/Fixture');
                $this->fixtureManager = new CakeFixtureManager();
            }
            $this->fixtureManager->fixturize($this);
        }
    }
}

app/TestSuite/Fixture/AppFixtureManager.php

テストを作り込んでいくと、FixtureManager もカスタマイズしたくなると思うので、先に継承しておきましょう。

<?php
App::uses('CakeFixtureManager', 'TestSuite/Fixture');
class AppFixtureManager extends CakeFixtureManager
{
}

以上で、基本的な追加の設定は終わりです。

PhpStorm の設定

多少設定を変えているので自信はありませんが、「PHP CLI」の設定と「Test Frameworks」の設定をすると動くと思います。 手元の環境の設定をスクリーンショットで紹介します。

PHP CLI 設定

f:id:tenkoma:20181203020457p:plain

php-build を使ってビルドした、Xdebugが使えるPHP を指定しています。

PHP Test Frameworks 設定

f:id:tenkoma:20181203020957p:plain

Configuration TypeがLocalの設定を追加して、 phpunitコマンドと phpunit.xml.dist の場所を指定しています。

既存のテストやプロダクションコードでテスト時にエラーが出るようになった場合は

phpunit コマンドでテストを実行すると、既存のテストやアプリケーションコードがエラーになるかもしれません。 理由として考えられるのが、CakePHP TestShell 経由だとロード済みのクラスが読み込まれてない可能性です。 たとえば ClassRegistry クラスがそうです。 その場合、使っているクラスファイル内で App::uses('ClassRegistry', 'Utility'); とちゃんと書くか、 bootstrap_for_phpunit_command.php に書けばテスト実行できるようになるはずです。

おまけ: FixtureManager の準備をもう少し改善する

CI 含めて Console/cake test によるテスト実行を捨て、 phpunit コマンドに移行出来る場合は、FixtureManager の準備をもう少し改善することができます。 CakePHP 3 の phpunit.xml.dist が参考になります。 それを見ると、PHPUnit のテストリスナー機能を利用してテスト前後のイベントにフックしてテストケースに FixtureManager オブジェクトを渡していました。

移植できるか試してみたところ、CakePHP 2.10 + PHPUnit 3.7 または 5.7 で動作しました。

参考: PHPUnit_Framework_TestListener の実装

app/TestSuite/Fixture/AppFixtureInjector.php

<?php
App::uses('CakeTestCase', 'TestSuite');

class AppFixtureInjector implements PHPUnit_Framework_TestListener
{
    /** @var CakeFixtureManager */
    protected $fixtureManager;

    /** @var PHPUnit_Framework_TestSuite */
    protected $first;

    public function __construct(CakeFixtureManager $manager)
    {
        $this->fixtureManager = $manager;
        $this->fixtureManager->shutDown();
    }

    public function startTestSuite(PHPUnit_Framework_TestSuite $suite)
    {
        if (empty($this->first)) {
            $this->first = $suite;
        }
    }

    public function endTestSuite(PHPUnit_Framework_TestSuite $suite)
    {
        if ($this->first === $suite) {
            $this->fixtureManager->shutDown();
        }
    }

    public function startTest(PHPUnit_Framework_Test $test)
    {
        $test->fixtureManager = $this->fixtureManager;
        if ($test instanceof CakeTestCase) {
            $this->fixtureManager->fixturize($test);
            $this->fixtureManager->load($test);
        }
    }

    public function endTest(PHPUnit_Framework_Test $test, $time)
    {
        if ($test instanceof CakeTestCase) {
            $this->fixtureManager->unload($test);
        }
    }

    /**
    * {@inheritdoc}
    */
    public function addError(PHPUnit_Framework_Test $test, Exception $e, $time)
    {
    }

    /**
    * {@inheritdoc}
    */
    public function addFailure(PHPUnit_Framework_Test $test, PHPUnit_Framework_AssertionFailedError $e, $time)
    {
    }

    /**
    * {@inheritdoc}
    */
    public function addIncompleteTest(PHPUnit_Framework_Test $test, Exception $e, $time)
    {
    }

    /**
    * {@inheritdoc}
    */
    public function addSkippedTest(PHPUnit_Framework_Test $test, Exception $e, $time)
    {
    }
    
    /**
    * {@inheritdoc}
    */
    public function addRiskyTest(PHPUnit_Framework_Test $test, Exception $e, $time)
    {
    }
}

定義した AppFixtureInjector をテストリスナーとして追加するには phpunit.xml.dist<listeners> の部分を追加します。

phpunit.xml.dist

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    colors="true"
    bootstrap="./app/Test/bootstrap.php"
    >
    <listeners>
        <listener class="AppFixtureInjector" file="./app/TestSuite/Fixture/AppFixtureInjector.php">
            <arguments>
                <object class="AppFixtureManager" />
            </arguments>
        </listener>
    </listeners>
    <filter>
        <whitelist>
            <directory suffix=".php">app/Config</directory>
            <directory suffix=".php">app/Console</directory>
            <directory suffix=".php">app/Controller</directory>
            <directory suffix=".php">app/Lib</directory>
            <directory suffix=".php">app/Model</directory>
            <directory suffix=".php">app/Routing</directory>
            <directory suffix=".php">app/TestSuite</directory>
            <directory suffix=".php">app/View</directory>
        </whitelist>
    </filter>
</phpunit>

以上です。

4日目は @tsyama さんです。

*1:PHPUnit 7以上

*2:CakePHP TestShellから実行された場合は定義済み