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
コマンドでテストを動かせないか。
試行錯誤してみたところ、狙い通り動かすことができたので、記事としてまとめます。
目次
- CakePHP2 アプリは PhpStorm の恩恵が受けにくい
- 目次
- CakePHP 2 アプリのユニットテストをPhpStorm と連携させてできること
- コードカバレッジがエディタに表示される
- Xdebug 連携
- 配列のアサーションDiff がエディタのDiffで見られる
- 実装1: テストクラス・テスト対象クラスを実行するのに必要な前処理を app/Test/bootstrap.php に書く
- 実装2: CakeTestRunner がやっていたFixtureManagerを準備する処理を実装する
- PhpStorm の設定
- 既存のテストやプロダクションコードでテスト時にエラーが出るようになった場合は
- おまけ: FixtureManager の準備をもう少し改善する
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'); } }
これを実行すると以下のような画面になります。
エディタ行番号の右にマークが付いていて、ここからテストを実行できます。 テスト結果は下のパネルに表示され、デバッグトレースで表示されるパスから、エディタにジャンプできます。
コードカバレッジがエディタに表示される
コードカバレッジを見れば、ユニットテストでよく分からないエラーになったときなど、どのコードまで実行されたのか簡単に追いかけることが出来ます。
class PagesControllerTest
の左側のマークをクリックして、「Run 'PagesControllerTest (PHPUnit)' with Coverage」を選択してテストを実行してみましょう。
そして、テスト実行後に PagesController.php
を開きます。
右側に Coverage パネルが表示され、エディタでは、実行された行(緑)と実行されてない行(赤)で色分けされています。
Xdebug 連携
Xdebug を使ったブレーク・ステップ実行自体は、phpunit
コマンド化しなくても、なんとか可能と思います。
しかし、PHPUnit 連携すれば、テスト実行時にデバッグの有効無効を切り替えられるので、ブレークポイントの掃除が不要になります。
配列のアサーションDiff がエディタのDiffで見られる
assertEquals()
, assertSame()
*1 でテスト失敗時に「<Click to see difference>
」をクリックすると以下のように表示されます。
以上のように便利になるので、ユニットテストが書きやすくなります。 それでは、連携を実現するための実装について説明します。
実装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 設定
php-build を使ってビルドした、Xdebugが使えるPHP を指定しています。
PHP Test Frameworks 設定
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 さんです。