CakePHPでの開発を始めて1年半近く経ちました。しばらく、テストは目視でのテストばかりやっていたのですが、最近になってようやくPHPUnitを使ったテストを始めました。
以前にJavaの開発でJUnitを使っていたことはあるので、xUnitを使ったユニットテストのやり方とか考え方は理解しています。なので、CakePHPでのModelのテストは難なく理解出来たのですが、Controllerをどうやってテストするのか?というのが難しかった。
そもそも、ControllerというはHTTPのRequestとResponseが前提なので、Modelのように単純にテストコード内でインスタンスを作って、メソッドを呼び出して、結果を検証して・・・というようには行きません。
以前のJUnitでやっていた頃はWebのフレームワークとしてStrutsを使っていたけど、あのときはActionのテストってどうやったかな・・・。
CakePHP2.xとPHPUnit
CakePHPでは2.xからユニットテストフレームワークがPHPUnitに変わりました。テストコードのベースクラスとなるCakeTestCaseはPHPUnit_Framework_TestCaseのサブクラスなので、CakePHPのTestSuiteは完全にPHPUnitを継承したテストフレームワークといえるでしょう。なので、PHPUnitのドキュメントはまず読んでおくべきです。これから説明するtestActionの使い方でも強く関連してきます。
もちろん、CakePHP 2.x BookのTestingのページも必読。(書いてないことも多いけど)
class PostsController extends AppController {
public $components = array('SocialBookmark');
public function view($id) {
$post = $this->Post->read(null, $id);
$hatena = $this->SocialBookmark->getHatena($post['Post']['permalink']);
$this->set(compact('post', 'hatena'));
}
}
こんな適当なControllerがあったとします。
テストコードはこんな感じになるでしょう。
class TestPostsController extends PostsController {
public $autoRender = false;
public function redirect($url, $status = null, $exit = true) {
$this->redirectUrl = $url;
}
}
class PostsControllerTest extends ControllerTestCase {
public $fixtures = array('app.post');
public function setUp() {
parent::setUp();
ob_end_clean();
$this->Posts = new TestPostsController();
$this->Posts->constructClasses();
}
public function tearDown() {
unset($this->Posts);
ob_start();
parent::tearDown();
}
public function testView() {
…
}
}
testAction
さて、問題はtestViewをどう書くかです。
Controllerのメソッドを直接呼び出す方式もありますが、定石はtestActionを使う方法だと思います。
public function testView() {
$return = $this->testAction('/posts/view/1', array('method' => 'get', 'return' => 'contents'));
debug($return);
これで$returnにはViewのレンダリングまで終えた文字列がセットされるので、適宜assertすれば良いでしょう。
POSTの場合は、こうなります。
public function testAdd() {
$data['Post']['title'] = '記事のタイトル';
$data['Post']['content'] = '記事の本文';
$return = $this->testAction('/post/add', array('data' => $data, 'method' => 'post', 'return' => 'contents'));
debug($return);
}
TestPostsControllerは必要か?
ところで、testAction(‘/posts/view/1’)などすると、これはどのControllerが動くのでしょうか?実際のPostsController?それともテストコード内に書いたTestPostsController?
そもそも、TestPostsControllerは一体何のためにあるのでしょうか?cake bake TestCaseをやると、一緒にbakeされます。(bakeするとTestクラスの親クラスはControllerTestCaseではなくCakeTestCaseになります。testActionを使うためにControllerTestCaseに書き直しています)
上記の書き方でtestActionした場合、実行されるControllerはPostsControllerになります。TestPostsControllerは無用の長物です。
とはいえ、TestPostsControllerを呼び出す方法もあります。
$return = $this->testAction('/test_posts/view/1', array('method' => 'get', 'return' => 'contents');
んー。そりゃそうか。でも、これはこれで役立つこともあると思います。
でも、テストコードの中で直接Controllerのメソッドを呼び出したり、testActionで/test_postsのような呼び出し方をしないのであれば、あえてTestPostsControllerを作る必要はなさそうです。
$this->generate()を使う方法
setUp()のこの部分に注目してください。これもまたbakeで焼かれるコードなのですが・・・。
$this->Posts = new TestPostsController();
$this->Posts->constructClasses();
ここでインスタンスを作るのがTestPostsControllerではなくてPostsControllerであったとしても一緒なのですが、この$this->Postsは、testActionで呼び出されるのでしょうか?
これは先に説明したとおりで、呼び出されませんね。つまり、このコードもControllerのメソッドを直接呼び出すときに使うというだけで、testActionとは無関係なんですね。
では、testActionから呼び出すControllerをTestPostsControllerにする方法はないでしょうか?
$this->Posts = $this->generate('TestPosts');
$this->testAction('/posts/view/1');
この書き方なら、testActionで実行されるControllerはTestPostsControllerになります!
ちなみに、$this->generate()の戻り値を$this->Postsにセットしていますが、実はセットしなくても構いません。というのは、generateメソッドは引数として渡されたController名のControllerのモックを作成し、それを$this->controllerにセットしています。そして、testActionでは、$this->controllerにセットされたControllerがある場合、そのControllerに対してRequest(のモック)をディスパッチするのです。
(あらかじめ$this->controllerにセットされたControllerがない場合は、testAction内で引数にセットされたURLよりControllerのモックを作成し、Requestをディスパッチします。)
さらに、この方法を使うと、こんなことも出来ます。
(TestPostsControllerも作らないとすると・・・)
$this->Posts = $this->generate('Posts', array('components' => array('SocialBookmark' => array('getHatena', 'getGooglePlus')));
$this->Posts->SocialBookmark->expects($this->once())->method('getHatena');
$this->Posts->SocialBookmark->expects($this->never())->method('getGooglePlus');
$this->testAction('/posts/view/1');
コンポーネントのモックでテストしているわけですね。getHatena()が1回呼び出されて、getGooglePlus()は呼び出されないことを検証できます。(他意はありません)
ということでtestActionは奥が深いですね。