cakephp2.0でのテストについて

この記事は CakePHP Advent Calendar 2011 24日目として書いています。


前日は@takuo_doiさんの「SQLから考えるModel::findの使い方」でした。SQLは誰もが悩むところだと思いますので非常に参考になると思いました。

最終日になりましたが、私はテストについて書かせて頂きたいと思います。

cakephp2.0からテストがphpunitになりました。
phpunitマニュアル
http://www.phpunit.de/manual/3.5/ja/index.html

cakephpのテストの実行には2つ方法がありまして、
  1. ブラウザからtest.phpにアクセスする。
  2. cake testsutie コマンドから実行する。
今回は2のコマンドからのテスト実行について書きたいと思います。
cakeコマンドを通す必要がありますのでまだの方は@nojimageさんの昨年のadventが参考になるかと思います。

またcakephpを使ったテストですが、これまで参考にさせていただいたサイトもご紹介させて頂きます。
以下実際にテストをしてみた個人的な感想です。
  1. cakeのコアをちゃんと見るようになった。と思う。
  2. コアのテストを見ると勉強になる&使い方が分かる(Setクラスとか)
  3. phpunitの勉強にもなった
  4. 安心感・・・
テストのないコード=負債
テストのあるコード=資産
というのをどこかで見た気がします。これからは資産を増やしたいものです。

注意)この記事はテスト初心者が行ったもので必ずしもcake2の正しいテスト方法ではございません。あくまで参考にして頂ければというところと、間違ってたらフィードバックが欲しいという願望がたくさん含まれています。

と、実際に記事を書く前に少しお断りをさせてもらいつつ…w
では本題に。

今回は下記の構成で簡単なブログアプリケーションを作ってみました。


実際のデモ
http://cakeblogdemo.fluxflex.com/

ソースコード
https://github.com/milds/cakeblogdemo
(全てのテストは網羅してません…Postを中心に…)

  • php5.3.8
  • phpunit3.5
  • cakephp2.0.4(管理画面はprefixルーティングでadminにしました)
  • MySql 5.5.15(InnoDB)


phpunitではデフォルトでphpunit.xmlが実行するパスにあれば自動で読み込まれて環境変数などを設定できるそうです。


コマンドラインから実行すると$_SERVERの値がブラウザでアクセスする時と異なるので、
/app/phpunit.xmlを作成してそこに環境変数を設定しました。


bootstrap="Test/bootstrap.php"
>






SERVER_NAMEは指定しないとloaclhostになるようです。
SCRIPT_NAMEは指定しないとフォームヘルパーでURLがコマンドのパスになったりします。
今回は/blog/以下にアプリケーションを作成する前提でテストします。
bootstrapはPHPが実行できるのでConfigure::write('debug', 0);とか初期値を入れることができます。
app直下に phpunit.xmlを置かない場合は、オプションで指定できます。
cake testsuite  app Controller/PostsController --configuration=Test/phpunit.xml
まずは、bake all でひと通り生成。

AllTests、AllController、AllModel、を作成。
中身はこんな感じでそれぞれのテストファイルをまとめて実行という形です。

/**
* AllControllersTest class
*
* This test group will run cache engine tests.
*
* @package Cake.Test.Case
*/
class AllControllerTest extends PHPUnit_Framework_TestSuite {

/**
* suite method, defines tests for this suite.
*
* @return void
*/
public static function suite() {
$suite = new CakeTestSuite('All Controller related class tests');
$suite->addTestDirectory(TESTS .'Case'.DS. 'Controller');
$suite->addTestDirectory(TESTS .'Case'.DS. 'Controller/Component');
$suite->addTestDirectory(TESTS .'Case'.DS. 'View/Helper');
return $suite;
}
}
DBがMySQLInnoDBだと問題があるとの事ですので、@cakephperさんのTipsを参考にさせて頂きMyFixtureを作成
http://tipshare.info/view/4ed736af4b2122247e000004
スキーマが変わった場合を考えると public $import がいいのかなと思います。
そして、Fixtureのrecordsは僕はyaml形式でやりました。
これも配列よりかは見やすいかなと。。


テスト実行時のfunctionは日本語でいいとのことですので日本語も使いました。
参考:CakePHPのテストケースメソッド名は日本語でおK
http://php-tips.com/php/cakephp-php/2011/06/cakephp-testcase-method-allowed-japanese
phpunitでは@testのアノテーションがありますのであわせて使うと便利かと思います。

PostsController

※ControllerTestCaseを継承する方法もあります。

https://github.com/milds/cakeblogdemo/blob/master/Test/Case/Controller/PostsControllerTest.php
日本語だと表示がおかしくなりますね。
上から順に説明したいと思います。

class TestPost extends Post{

public $name = 'Post';
public $alias = 'Post';

public $conditions = array();

public function beforeFind($queryData) {
$queryData = parent::beforeFind($queryData);
$this->conditions = $queryData;
return false;
}

}

今回SearchPluginの検索もつけました。その時にコンディションをチェックしたいと思い。Postモデルを継承したTestPostモデルを定義して、$nameと$aliasはPostモデルと同じにしました。そして実際にレコードは検索せずに条件だけ別のプロパティに入れるようにして、実際のテストケースではController-PostをTestPostに指定して条件のassertEqualを行いました。
同じような方法で検索時とかpaginateメソッドだけを持つモデルを作ってもアリなんじゃないかと思いますがいかがでしょう。




/**
* TestPostsController
*/
class TestPostsController extends PostsController {

public $name = 'Posts';
/**
* Auto render
*
* @var boolean
*/
public $autoRender = false;

/**
* Redirect action
*
* @param mixed $url
* @param mixed $status
* @param boolean $exit
* @return void
*/
public function redirect($url, $status = null, $exit = true) {
$this->redirectUrl = $url;
}
}

/**
* PostsController Test Case
*
*/
class PostsControllerTestCase extends CakeTestCase {
/**
* Fixtures
*
* @var array
*/
public $fixtures = array(
'app.post',
'app.user',
'app.media',
'app.category',
'app.tag',
'app.posts_category',
'app.posts_tag'
);

/**
* setUp method
*
* @return void
*/
public function setUp() {
parent::setUp();

$request = new CakeRequest(null, false);
$response = new CakeResponse();
$this->Controller = new TestPostsController($request,$response);
$this->Controller->constructClasses();
$this->Controller->Components->init($this->Controller);
Router::reload();
require APP . 'Config' . DS . 'routes.php';

$this->Post = ClassRegistry::init('Post');
}

/**
* tearDown method
*
* @return void
*/
public function tearDown() {
$this->Controller->Session->destroy();
unset($this->Controller);
unset($this->Post);
ClassRegistry::flush();
parent::tearDown();
}

ここまでの定義はほぼどのコントローラーでも同じになるかと思います。
setUpは毎回テストを実行するたびに呼ばれて、tearDownはテストが終わるたびに実行されます。
$this->Controller->constructClasses();はComponentCollectionの初期化を行ってます。 $this->Controller->Components->init($this->Controller);ではcomponentクラスの初期化を行ってます。
Router::reload();はルーティングのリセットですか?よくわかってません。 require APP . 'Config' . DS . 'routes.php';はアプリケーションで定義したルーティングを読み込んでます。




/**
* @test
*
* @return void
*/
public function prefixがない場合はis_activeコンディションを追加() {
$TestPost= new TestPost();
$this->Controller->Post = $TestPost;
$this->Controller->request->addParams(Router::parse('/posts/index'));
Router::setRequestInfo($this->Controller->request);
$this->Controller->startupProcess();
$this->Controller->index();

$results = $this->Controller->Post->conditions['conditions'];

$this->assertArrayHasKey('Post.is_publish',$results);

}

このテストでは、先程のTestPostモデルをコントローラーに差し替えて、requestオブジェクトに現在のパスを割り当ててます。$this->Controller->request->addParams(Router::parse('/posts/index'));でrequest->params->controllerとかにパラメーターを割り当ててます。
Router::setRequestInfo($this->Controller->request);は、ヘルパーとかビューをレンダリングするさいに、ルーティング情報をRouterのself::$_requestsから取ってくるのでパスが付いてこない事になります。
$this->Controller->index();で実際にindexアクションを実行します。
その後、コンディションにis_publishが含まれているかをassertArrayHasKeyでチェックしています。




/**
* @test
*
* @return void
*/
public function indexのビューのテスト() {
$this->Controller->request->addParams(Router::parse('/'));
Router::setRequestInfo($this->Controller->request);
$this->Controller->startupProcess();
$this->Controller->index();

//ビューのテスト
$renderer = $this->Controller->render('index')->body();

$this->assertRegExp("/form action=\"\/blog\/posts\/search\"/", $renderer);
$this->assertRegExp("/name=\"subject\"/", $renderer);
$this->assertRegExp("/name=\"tags\"/", $renderer);
$this->assertRegExp("/name=\"time_from\[year\]\"/", $renderer);
$this->assertRegExp("/name=\"time_from\[month\]\"/", $renderer);
$this->assertRegExp("/name=\"time_from\[day\]\"/", $renderer);
$this->assertRegExp("/name=\"time_to\[year\]\"/", $renderer);
$this->assertRegExp("/name=\"time_to\[month\]\"/", $renderer);
$this->assertRegExp("/name=\"time_to\[day\]\"/", $renderer);

$this->assertNotRegExp("/posts\/edit/", $renderer);
$this->assertNotRegExp("/posts\/add/", $renderer);
$this->assertNotRegExp("/posts\/delete/", $renderer);



$this->assertArrayHasKey('category',$this->Controller->viewVars);
$this->assertArrayHasKey('tag',$this->Controller->viewVars);


}

ここでは実際にindexが呼ばれたときにビューをチェックしています。
先程のRouter::setRequestInfo($this->Controller->request);メソッドを実行することで、フォームヘルパーのURLに/blog/がついてきます。




/**
* @test search method
*
* @return void
*/
public function 検索コンディションのチェック() {
$TestPost= new TestPost();
$this->Controller->Post = $TestPost;

$this->Controller->request->addParams(Router::parse('/posts/search'));

$_SERVER['REQUEST_METHOD'] = 'GET';

//Test1
$this->Controller->request->query= array(
'subject'=>'テスト検索',
'time_from'=>array(
'year'=>2011,
'month'=>01,
'day'=>01
),
'time_to'=>array(
'year'=>2011,
'month'=>02,
'day'=>01
),
'tags'=>'php'
);
$this->Controller->startupProcess();
$this->Controller->search();
$expects = array(
'Post.subject LIKE' => '%テスト検索%',
0 => array(
0 => 'Post.id in (SELECT `Tag`.`id` FROM `blog_tags` AS `Tag` WHERE `Tag`.`name` = \'php\')',
),
'Post.is_publish' => 1,
'Post.created BETWEEN ? AND ?' => array(
0 => 1293807600,
1 => 1296572399,
),
);
$this->assertEqual($expects,$TestPost->conditions['conditions']);
}

$_SERVER['REQUEST_METHOD'] = 'GET';だと明示的に指定して、request->dataにGETで渡される値を入れてます。
CakeRequestオブジェクトのコンストラクタでget、postは処理されるので強引なやり方なのかもしれません。
後は先ほどと同じように期待したconditionsをチェックしてます。 @cakephperさんのTipsを大変参考になりました。(_ _)
配列の状態をPHPのコードとして出力し、テストケースの作成を楽にする




/**
* 非公開記事のデータプロバイダー
* @see PostFixture.yml
*/
public static function unPublishPostsID() {
return array(
array('2'),
array('3'),
);
}
/**
* @test view method
* @dataProvider unPublishPostsID
* @expectedException NotFoundException
*/
public function 非公開の記事にアクセス($action) {
$this->Controller->request->addParams(Router::parse('/posts/view/'.$action));
$this->Controller->startupProcess();
$this->Controller->view($action);
}

phpunitにはデータプロバイダーという便利な機能があります。
ここでは@expectedExceptionという例外のアノテーションで、is_publishでないデータはNotFoundExceptionを期待してます。 2,3はテストデータでis_publishではないデータです。




**
* @test admin_add method
*
* @return void
*/
public function admin_add_新規保存() {
$this->Controller->Session->write('Auth.User',array('id'=>1,'name'=>'adminuser'));
$this->Controller->request->addParams(Router::parse('/admin/posts/add'));
Router::setRequestInfo($this->Controller->request);
$this->Controller->startupProcess();

$_SERVER['REQUEST_METHOD'] = 'POST';
$this->assertTrue($this->Controller->request->is('post'));

$this->Controller->request->data = array(
'Post' =>array(
'subject'=>'記事のテスト',
'body'=>'記事のテスト本文',
'is_publish'=>1
),
'Category'=>array(
'Category'=>array(1)
),
'Tag'=>array(
'Tag'=>array(1)
)
);
$this->Controller->admin_add();
$this->assertFalse($this->Controller->validateErrors());
$actual = $this->Controller->Post->read(null,$this->Controller->Post->getLastInsertID());
unset($actual['Post']['created']);
unset($actual['Post']['modified']);
$expects = array(
'Post' => array(
'id' => '4',
'user_id' => '1',
'subject' => '記事のテスト',
'body' => '記事のテスト本文',
'is_publish' => true,
),
'User' => array(
'id' => '1',
'name' => '管理者',
),
'Category' => array(
array(
'id' => '1',
'parent_id' => NULL,
'lft' => NULL,
'rght' => NULL,
'name' => 'PHP',
'post_count' => '0',
'PostsCategory' => array(
'id' => '2',
'post_id' => '4',
'category_id' => '1',
),
),
),
'Tag' => array(
array(
'id' => '1',
'name' => 'タグ1',
'post_count' => '0',
'PostsTag' => array(
'id' => '2',
'post_id' => '4',
'tag_id' => '1',
),
),
),
);
$this->assertEqual($expects,$actual);

}

$this->Controller->Session->write('Auth.User',array('id'=>1,'name'=>'adminuser'));では直接セッションにAuthが使うセッションキーを保存してログイン状態にしています。
その後、明示的にPOSTにしてrequest->dataが登録されているかをチェックしています。



テストの実行は、


cake testsuite app AllTests --stderr
とか
cake testsuite app Controller/PostsController --stderr
cake testsuite app Model/Post

AuthComponentと使った場合というか多分セッションを使った場合に、コマンドラインからテストを実行するときはオプションで --stderrを使わないと失敗しますよね。

phpunitマニュアル 第5章 コマンドラインのテストランナー
http://www.phpunit.de/manual/3.5/ja/textui.html
オプションで、出力先を STDOUT ではなく STDERR にします。
↑ ↑
よく意味がわかりません。どなたかおしえて下さい。

ではコアのテストは?と思ってみるとcake core AllTestsにはCakeSessionTest.phpが入っていませんでした。ちなみに、単体でCakeSessionTest.phpを実行するときは --stderrを付けないと失敗しました。

以上ざっくりとですが、テストの方法について書かせて頂きました。
私自身これまで登録などは実際にブラウザから実行してみて…といったことを繰り返してました。。非効率です。
しかし、ちゃんとテストをかけばブラウザでちまちまフォーム入力しなくていいじゃん!と思いました。
モックを使った場合、ControllerTestCaseを使った場合などもありますし、プラグインを使った場合なども考慮すると書かせて頂いた方法でいいのか疑問に思うところですし、コントローラーはseleniumを使ったテストもあると思います。

Advent Calendar参加者の皆様お疲れさまでした。
では、みなさまメリークリスマス!