CakePHPのバージョンは?
CakePHP 2.xについての記事です。CakePHP 3.xではどうやらこの記事に書いてあるようなことはIntegrationTestCase
というもので解消されて便利になっているようです。
redirect()は止まらない
CakePHP2.xでは公式ドキュメントにもある通り、PHPUnitテストケース実行中は通常のHTTPリクエストでの処理の場合とは違い、Controller::redirect()の箇所で処理が終わりません。
ソースを読んだ限りではテスト時にはObject(CakeObject)
の_stop()
がモックされています。このメソッドはredirect()メソッド内で呼ばれており、本来はただ単純にexit()
しているだけなのですが、PHPUnitテスト実行途中でexit()
しては困るわけで、メソッドでラップすることによりあえてモックしやすくした結果、めんどくさいことになった感じでしょうか。
そこで公式ドキュメントではコントローラーの実装時にreturn $this->redirect(...)
というようにreturn
をつけてテストの時でもそこで処理が終わるようにしよう!と言ってます。
まるで結婚式のスピーチは感涙するかガチガチに緊張しながら会場で直接やるのに、ビデオレターのことを考慮して台本に
…あれはあなたが中学校に上がるころでしたね。**(ここで編集用に数呼吸分間を開けるように!)**あなたが捨てコビトカイマンを拾ってきたときにはびっくりしました…
と書くごとく、本質的には無意味なことで、個人的にはただの設計ミスによって、無駄な「規約」が増えているだけと感じますが、ここは我慢してフレームワークのお作法に従い、redirect()するときは一緒にreturnするようにしましょう。
**ですがそれをやっても、AuthComponentの未認証時のリダイレクトには関係ありません。**通常のリクエストであればログインが必要なページにアクセスすると、ログインページ(デフォルトでは/users/login)にリダイレクトし、リクエストされたページに対応するアクションは実行されないのですが、テスト実行時ではリダイレクト処理が行われた後、そのままコントローラーのアクションが実行されてしまいます。
実例
ログインを前提としたアクションを実装していた場合にこれでテストが失敗することがあります。例えば:
class FooController extends AppController {
public function index() {
$friends = array();
foreach($this->Auth->user()['Friends'] as $friend) {
$friends[] = $friend['name'];
}
$this->set('friends', $friends);
}
}
$this->Auth->user()['Friends']
が配列である前提でループ処理をしてしまっていますね。ここで次のようなテストケースを実行してみます:
class FooControllerTest extends ControllerTestCase {
public function testNotLogin() {
$this->testAction("/foo/index", array('method' => 'get', 'return' => 'vars'));
$this->assertStringEndsWith('/users/login', $this->headers['Location']);
}
}
ログインしていない場合に正しく遷移するか、のテストです。これを実行するとInvalid argument supplied for foreach()
と言われてテストが失敗します。
PHPは大分アレな言語なんで多少のnullなどはエラーも吐かずに処理してしまうのですが、上記のようにforeach文にnullを渡すと、さすがにその寛大な内部処理にも受け入れてもらえません。
$this->Auth->user()['Friends']
がnullになる場合(user()の時点でnullです)とは、つまりログインしてない場合です。このアクションはログイン前提のものなのに、テスト時には未ログイン状態で処理が実行されてしまうのです。
回避策
コントローラーの処理前にリダイレクトされていた場合はコントローラーの処理を実行しないようにしないといけません。
と言葉でいうのは簡単ですが、CakePHPのソースを読んでみると、途中でそのような処理を割り込ませる余地がありません…。なので一部のメソッドごと書き換えます。この「書き換え」というのはもちろんlib/Cake/以下のファイルを直接書き換えてもできますが、幸運にも対象のメソッドがpublicとprotectedなのでオーバーライドしてみました。
いろいろ方法はありますが、ここではテスト用ヘルパとしてトレイトを作り、トレイトやそのファイル内でクラスを作ってオーバーライドします。
<?php
class CustomTestDispatcher extends ControllerTestDispatcher {
public function dispatch(CakeRequest $request, CakeResponse $response, $additionalParams = array()) {
// (継承元からコピペ)
$response = $this->_invoke($controller, $request);
if($response === null) return; // これを追加!
if (isset($request->params['return'])) {
return $response->body();
}
// (継承元からコピペ)
}
protected function _invoke(Controller $controller, CakeRequest $request) {
$controller->constructClasses();
$controller->startupProcess();
if(isset($controller->response->header()['Location'])) return null; // これを追加!
$response = $controller->response;
$render = true;
// (継承元からコピペ)
}
}
trait TestHelper {
protected function _testAction($url, $options = array()) {
// (継承元からコピペ)
if (is_string($options['data'])) {
$request->expects($this->any())
->method('_readInput')
->will($this->returnValue($options['data']));
}
$Dispatch = new CustomTestDispatcher(); // ここを変更!
foreach (Router::$routes as $route) {
if ($route instanceof RedirectRoute) {
$route->response = $this->getMock('CakeResponse', array('send'));
}
}
// (継承元からコピペ)
}
}
オリジナルソースの著作権: Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
MITライセンス: http://www.opensource.org/licenses/mit-license.php
※上記著作権表示、ライセンスはCakePHP 2.9.1のソースコードに記載されているものです。
まずは、ControllerTestDispatcher
のサブクラスを作って、dispatch()
と_invoke()
をオーバーライドします。なんとも情けないオーバーライドですが、それぞれのメソッドを継承元(正確にはControllerTestDispatcherの継承元のDispatcher
)からコピペして1行だけ追加してます。
_invoke()
内にてComponentが構築され、Cakeのイベント機構により各Componentの初期処理が実行されます。AuthComponentはその初期処理内にて未ログイン時にリダイレクトしてるので、上記では初期処理終了後にHTTPヘッダのLocation
を見て、リダイレクトされているようなら以降の処理を行わず、nullを返してます。
そしてさらに呼び出し元であるdispatch()
でもnullが返された場合にそのままreturnしてます。これにより、現在のテストケースは終了し、次のケースも続けて実行されます。
そして、このサブクラスを使うようにしないといけないので、ControllerTestCase::_testAction()
をまたまたコピペして、ControllerTestDispatcher
を使っていたところをサブクラスを使うように修正します。これはtrait内に記述してます。そして最後に、
<?php
App::uses('TestHelper', 'Test/Helper');
class FooControllerTest extends ControllerTestCase {
use TestHelper;
public function testNotLogin() {
$this->testAction("/foo/index", array('method' => 'get', 'return' => 'vars'));
$this->assertStringEndsWith('/users/login', $this->headers['Location']);
}
}
このようにコントローラーのテストクラスにてヘルパーをuseすれば、AuthComponentによるリダイレクトが発生してもコントローラーのアクションは実行されずに無事テストが完了します。
終わりに
オーバーライドすることにより予期せぬ事態が発生するかもしれませんし、ここまでやらないといけないの…って感じでしたね!CakePHP2.xではPHPUnitによるテストはモデル単体、コントローラー単体程度でとどめておき、結合テストは別の方法でやった方がいいのかもしれません。
もしもっと簡単な方法あるよ!って方は教えて頂けると嬉しいです。