これは CakePHP Advent Calendar 2017 2日目の記事です。 1日目は @kunitさんのCakePHPの過去、現在、そして未来 - Qiitaでした。
1ヶ月ほど前に CakePHP 3.x ドキュメントにCMS チュートリアルというページが追加されました。以前ブログチュートリアルとブックマークチュートリアルというコンテンツがありましたが、説明に重複する部分があるので統合されたもののようです。 CakePHP 3の機能をざっくり把握するためによい資料かと思います。 ところでこのチュートリアルではユニットテストに触れていませんが、高品質なアプリケーション開発のためには必須です。 自分の知識を整理するために、CMSチュートリアルにユニットテストを書いてみました。 今回はコントローラーの統合テスト以外について説明します。
(現在でもナビゲーションからは辿れませんが、見ることは可能です: ブックマークチュートリアル - 3.x, ブログチュートリアル - 3.x)
ユニットテストを実行する準備
PHPUnit をインストールする
$ composer require --dev "phpunit/phpunit"
composer.phar
がある場合は、 php composer.phar require --dev "phpunit/phpunit"
でインストールします
ユニットテスト環境向けデータベース作成
開発環境と同じMySQLサーバーにテスト環境向けデータベースを作成します。
CREATE DATABASE test_cake_cms CHARACTER SET utf8mb4; GRANT ALL ON test_cake_cms.* TO cakephp@localhost IDENTIFIED BY "AngelF00dC4k3~"; FLUSH PRIVILEGES;
app.php
変更
作成したデータベースと権限を app.php
に反映します。
<?php // config/app.php // 略 'Datasources' => [ // 'default' => [ ... は省略 /** * The test connection is used during the test suite. */ 'test' => [ 'className' => 'Cake\Database\Connection', 'driver' => 'Cake\Database\Driver\Mysql', 'persistent' => false, 'host' => 'localhost', 'username' => 'cakephp', // ここを変更 'password' => 'AngelF00dC4k3~', // ここを変更 'database' => 'test_cake_cms', // ここを変更 'encoding' => 'utf8mb4', 'timezone' => 'UTC', 'cacheMetadata' => true, 'quoteIdentifiers' => false, 'log' => false, //'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'], 'url' => env('DATABASE_TEST_URL', null), ], ],
どんなテストを書くか
CMS チュートリアルで実装した機能を箇条書きにしました。(番号は独自で付けたものです)
- 1-1
コントローラー
ArticlesController:index - 1-2
コントローラー
ArticlesController:view - 1-3
コントローラー
ArticlesController:add (2-2, 3-3, 3-4 で拡張) - 1-4
コントローラー
ArticlesController:edit (2-2, 3-3, 3-4 で拡張) - 1-5
テーブル
ArticlesTable save でスラグ生成 - 1-6
テーブル
ArticlesTable バリデーション - 1-7
コントローラー
ArticlesController:delete
2 タグとユーザー
- 2-1
エンティティー
User のパスワードをハッシュ化 - 2-2
コントローラー
ArticlesController:add/edit 記事追加・編集時にタグ付け - 2-3
ルーティング
tags アクションのためのカスタムルーティング - 2-4
コントローラー
ArticlesController:tags タグによる記事の検索 - 2-5
テーブル
ArticlesTable:findTagged(ファインダーメソッド) - 2-6
エンティティー
Article:_getTagString(計算フィールド) - 2-7
テーブル
Article:_buildTags(タグ文字列の永続化)
3 認証
- 3-1
コントローラー
UsersController:login ログイン - 3-2
コントローラー
UsersController:logout ログアウト - 3-3
コントローラー
ArticlesController::isAuthorized ... add, tags, edit, などでの認可ロジック - 3-4
コントローラー
ArticlesController::add と edit アクションで固定値だったuser_id
を実際のユーザーIDに
上のようにまとめたところ、記事の一覧・詳細・追加・編集・削除、記事に関連するタグ、認証(ログイン・ログアウト)、認可の機能を実装していますので、それに伴うユニットテストを以下の順で実装していくことにします。
- テーブル
ArticlesTable
のテスト (1-5, 1-6, 2-5, 2-7) - エンティティー
User
のテスト (2-1) - エンティティー
Article
のテスト (2-6) - ルーティングのテスト (2-3)
- ログイン・ログアウトのテスト (3-1, 3-2)
ArticlesController
のテスト (上記以外全部)
不足するFixture 作成
そのままテストを実行すると、フィクスチャーファイルが無くてエラーになるので、生成しておきます。
$ bin/cake bake fixture Articles $ bin/cake bake fixture ArticlesTags
vendor/bin/phpunit
でテスト実行して、以下のように表示されたら準備完了です。
$ vendor/bin/phpunit PHPUnit 6.4.4 by Sebastian Bergmann and contributors. ......IIIIIIIIIIIIIIII 22 / 22 (100%) Time: 1.8 seconds, Memory: 14.00MB OK, but incomplete, skipped, or risky tests! Tests: 22, Assertions: 34, Incomplete: 16.
実行ログで I
と表示されているのは、実装していないテストがあることを示しています。テストケースクラスを bake で生成したときは、実装済みのメソッドに対してテストメソッドが生成されますが、内容が以下のようになっています。
<?php // 省略 $this->markTestIncomplete('Not implemented yet.');
テスト失敗ではないのでこのチュートリアルでは無視します。 この記事で書いたコードについてはすべてGitHub tenkoma/cakephp_cmsで公開しています。
テスト実装
ArticlesTable
のテスト (1-5, 1-6, 2-5, 2-7)
(テスト実装はArticlesTable のテスト Pull Request #13 で確認できます)
バリデーションのテスト
ArticlesTable
にバリデーションルールを定義した(1-6)のでテストします。
<?php // 省略 public function validationDefault(Validator $validator) { $validator ->notEmpty('title') ->minLength('title', 10) ->maxLength('title', 255) ->notEmpty('body') ->minLength('body', 10); return $validator; }
src/Model/Table/ArticlesTable.php
のテストは慣習として tests/TestCase/Model/Table/ArticlesTableTest.php
に書きます。 UsersTableTest.php
がすでにありますが、 ArticlesTableTest.php
はありません。これは ArticlesTable.php
を bake コマンドを使わずに作成したからです。 テストケースクラスだけを生成することもできるので、生成します。
$ bin/cake bake test Table ArticlesTable
生成されたテストケースクラスを見ると、setUp()
で ArticlesTable
が初期化されています。
<?php // 省略 /** * setUp method * * @return void */ public function setUp() { parent::setUp(); $config = TableRegistry::exists('Articles') ? [] : ['className' => ArticlesTable::class]; $this->ArticlesTable = TableRegistry::get('Articles', $config); }
この $this->ArticlesTable
を使ってバリデーションのテストを実装していきます。
ArticlesTableTest
にこのままテストを追加してもよいですが、テスト結果がずっと黄色なのも面白くないので、中身が markTestIncomplete(...)
なメソッドはすべて削除して以下のようにします。(実際は、テストに置き換えることで未実装のテストを無くす方がよいです)
<?php // tests/TestCase/Model/Table/ArticlesTableTest.php namespace App\Test\TestCase\Model\Table; use App\Model\Table\ArticlesTable; use Cake\ORM\TableRegistry; use Cake\TestSuite\TestCase; /** * App\Model\Table\ArticlesTable Test Case */ class ArticlesTableTest extends TestCase { /** * Test subject * * @var \App\Model\Table\ArticlesTable */ public $ArticlesTable; /** * Fixtures * * @var array */ public $fixtures = [ 'app.articles', 'app.tags', 'app.articles_tags' ]; /** * setUp method * * @return void */ public function setUp() { parent::setUp(); $config = TableRegistry::exists('Articles') ? [] : ['className' => ArticlesTable::class]; $this->ArticlesTable = TableRegistry::get('Articles', $config); } /** * tearDown method * * @return void */ public function tearDown() { unset($this->ArticlesTable); parent::tearDown(); } }
準備できたのでバリデーションのためのテストを実装します。
<?php // tests/TestCase/Model/Table/ArticlesTableTest.php // 省略 public function testValidationDefault() { // エラーが無いとき $article = $this->ArticlesTable->newEntity([ 'title' => str_repeat('a', 10), 'body' => str_repeat('b', 256), ]); $expected = []; $this->assertSame($expected, $article->getErrors()); // 必須項目が空のとき $emptyArticle = $this->ArticlesTable->newEntity([ 'title' => '', 'body' => '', ]); $expected = [ 'title' => ['_empty' => 'This field cannot be left empty'], 'body' => ['_empty' => 'This field cannot be left empty'], ]; $this->assertSame($expected, $emptyArticle->getErrors()); // 文字数が少ないとき $lessArticle = $this->ArticlesTable->newEntity([ 'title' => str_repeat('a', 9), 'body' => str_repeat('b', 9), ]); $expected = [ 'title' => ['minLength' => 'The provided value is invalid'], 'body' => ['minLength' => 'The provided value is invalid'], ]; $this->assertSame($expected, $lessArticle->getErrors()); // 文字数が多いとき $moreArticle = $this->ArticlesTable->newEntity([ 'title' => str_repeat('a', 256), 'body' => str_repeat('b', 256), ]); $expected = [ 'title' => ['maxLength' => 'The provided value is invalid'], ]; $this->assertSame($expected, $moreArticle->getErrors()); }
バリデーション結果は newEntity
や patchEntity
を呼び出して取得したエンティティーから getErrors()
で取り出します。
バリデーションにパスする場合としない場合3パターンの計4パターンをテストしています。
テストを実行した結果は以下になります。
グリーンバーが表示されたらテスト成功です。
記事保存のテスト
記事 (Article
エンティティー)を保存するときは以下の処理を行っています
- タグ文字列をエンティティーに変換
- 記事スラグ生成
記事更新時はスラグが変更されない、という違いがあるので、testSaveInsert
と testSaveUpdate
に分けて実装します。
<?php // tests/TestCase/Model/Table/ArticlesTableTest.php // 省略 /** * articles 追加 */ public function testSaveInsert() { $newArticle = $this->ArticlesTable->newEntity([ 'user_id' => 1, 'title' => 'CakePHP テスト', 'body' => str_repeat('🍺', 10), 'tag_string' => 'PHP', ]); $this->ArticlesTable->save($newArticle); $article = $this->ArticlesTable->get($newArticle->id, [ 'contain' => ['tags'], ]); // スラグ $this->assertSame('CakePHP-tesuto', $article->slug); // タグに変換 $this->assertSame('PHP', $article->tags[0]->title); }
testSaveInsert()
を追加してテスト実行すると以下のようにエラーなります。
There was 1 error: 1) App\Test\TestCase\Model\Table\ArticlesTableTest::testSaveInsert PDOException: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`test_cake_cms`.`articles`, CONSTRAINT `articles_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION) /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/Database/Statement/MysqlStatement.php:39 /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/Database/Connection.php:314 /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/Database/Query.php:214 /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/ORM/Table.php:1925 /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/ORM/Table.php:1819 /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/ORM/Table.php:1732 /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/ORM/Table.php:1455 /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/Database/Connection.php:681 /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/ORM/Table.php:1456 /Users/tenkoma/projects/sample_projects/cms/vendor/cakephp/cakephp/src/ORM/Table.php:1733 /Users/tenkoma/projects/sample_projects/cms/tests/TestCase/Model/Table/ArticlesTableTest.php:108 ERRORS! Tests: 2, Assertions: 4, Errors: 1.
articles
テーブルには外部キー制約があり、指定の users
レコードがないのでレコード追加ができない、というエラーです。users
テーブルと、users.id = 1
のレコードがあればよいので、 $fixtures
に 'app.users'
を追加します。 フィクスチャーとはユニットテストのためにテーブルとテストデータを用意するための仕組みです。
<?php // tests/TestCase/Model/Table/ArticlesTableTest.php // 省略 public $fixtures = [ 'app.articles', 'app.tags', 'app.articles_tags', 'app.users', // この行を追加 ];
これでテストは成功します。 更新時にスラグが変更されないことを以下のように実装します。 現時点ではフィクスチャーのデータにはデフォルトのダミー文字列が入っているので、アプリケーション向きのデータに変更します。
<?php // tests/Fixture/ArticlesFixture.php // 省略 public $records = [ [ 'id' => 1, 'user_id' => 1, 'title' => 'CakePHP3 チュートリアル', 'slug' => 'CakePHP3-chutoriaru', 'body' => 'このチュートリアルは簡単な CMS アプリケーションを作ります。 はじめに CakePHP のインストールを行い、データベースの作成、 そしてアプリケーションを素早く仕上げるための CakePHP が提供するツールを使います。', 'published' => 1, 'created' => '2017-11-19 11:04:25', 'modified' => '2017-11-19 11:04:25' ], ]; // 省略
<?php // tests/TestCase/Model/Table/ArticlesTableTest.php // 省略 public function testSaveUpdate() { $article = $this->ArticlesTable->get(1); $this->assertSame('CakePHP3-chutoriaru', $article->slug); $article = $this->ArticlesTable->patchEntity($article, [ 'title' => 'CakePHP3 Tutorial', ]); $this->ArticlesTable->save($article); $newArticle = $this->ArticlesTable->get(1); // title が変わってもスラグは変化しない $this->assertSame('CakePHP3 Tutorial', $newArticle->title); $this->assertSame('CakePHP3-chutoriaru', $newArticle->slug); }
カスタムファインダーのテスト
2-5 で作成した findTagged
メソッドは find('tagged', [...])
という感じで呼び出して利用します。
これをテストするにはテストデータの準備ができていないので、フィクスチャーを以下のようにカスタマイズします。
<?php // tests/Fixture/ArticlesFixture.php // 省略 public $records = [ [ // タグあり 'id' => 1, 'user_id' => 1, 'title' => 'CakePHP3 チュートリアル', 'slug' => 'CakePHP3-chutoriaru', 'body' => 'このチュートリアルは簡単な CMS アプリケーションを作ります。 はじめに CakePHP のインストールを行い、データベースの作成、 そしてアプリケーションを素早く仕上げるための CakePHP が提供するツールを使います。', 'published' => 1, 'created' => '2017-11-19 11:04:25', 'modified' => '2017-11-19 11:04:25' ], [ // タグなし 'id' => 2, 'user_id' => 1, 'title' => 'Happy new year', 'slug' => 'Happy-new-year', 'body' => '2018🍺🍺🍺🍺🍺', 'published' => 1, 'created' => '2017-11-19 11:04:25', 'modified' => '2017-11-19 11:04:25' ], ]; // 省略
<?php // tests/Fixture/TagsFixture.php // 省略 public $records = [ [ 'id' => 1, 'title' => 'PHP', 'created' => '2017-11-18 12:15:34', 'modified' => '2017-11-18 12:15:34' ], [ 'id' => 2, 'title' => 'CakePHP', 'created' => '2017-11-18 12:15:34', 'modified' => '2017-11-18 12:15:34' ], [ 'id' => 3, 'title' => 'Bakery', 'created' => '2017-11-18 12:15:34', 'modified' => '2017-11-18 12:15:34' ], ]; // 省略
<?php // tests/Fixture/ArticlesTagsFixture.php // 省略 public $records = [ [ 'article_id' => 1, 'tag_id' => 1 ], [ 'article_id' => 1, 'tag_id' => 2 ], ]; // 省略
タグありの記事検索とタグなしの記事検索を以下のようにテストします。
<?php // tests/TestCase/Model/Table/ArticlesTableTest.php // 省略 public function testFindTagged() { // タグなし $notTaggedArticle = $this->ArticlesTable ->find('tagged', ['tags' => []]) ->contain(['Tags']) ->first(); $this->assertEmpty($notTaggedArticle->tags); // タグあり $taggedArticle = $this->ArticlesTable ->find('tagged', ['tags' => ['PHP']]) ->contain(['Tags']) ->first(); $tags = new \Cake\Collection\Collection($taggedArticle->tags); $this->assertNotEmpty($tags->filter(function($tag) { return $tag->title === 'PHP'; })); }
以上で ArticlesTable
に実装したコードのテストは終わります。
User
エンティティーのテスト (2-1)
(テスト実装はUser エンティティーのテスト Pull Request #14 で確認できます)
ユーザー追加時に、パスワードハッシュ化の追加の処理を User
エンティティーに実装しましたのでこちらをテストします。
User
エンティティーのテストケースクラスを以下のコマンドで生成します。
$ bin/cake bake test Entity User
テストコードを実装します。
<?php // tests/TestCase/Model/Entity/UserTest.php // 省略 public function testSetPassword() { $rawPassword = 'secret'; $this->User->password = $rawPassword; $hashedPassword = $this->User->password; // ハッシュ化済み $this->assertNotSame($rawPassword, $hashedPassword); $hasher = new DefaultPasswordHasher(); $this->assertTrue($hasher->check($rawPassword, $hashedPassword)); }
User::_setPassword()
では DefaultPasswordHasher::hash()
でハッシュ化文字列を求めていますが、このハッシュ化文字列は毎回変化するため、テストコードでは使えません。代わりに DefaultPasswordHasher::check()
を使って検証します。ログイン機能を実装するときは AuthComponent
がデフォルトでやってくれています。
Article
エンティティーのテスト (2-6)
(テスト実装はArticle エンティティーのテスト Pull Request #15で確認できます)
計算フィールドの追加で、Article
に関連付いたタグ文字列を直接取得できるようにしましたので、テストを追加します。まず bake
します。
$ bin/cake bake test Entity Article
生成された ArticleTest.php
から testInitialize
を削除して以下を追加します。
<?php // tests/TestCase/Model/Entity/ArticleTest.php // 省略 /** * @dataProvider dataTestTagString */ public function testTagString($tags, $expected) { $tagEntities = []; foreach ($tags as $tagTitle) { $tagEntities[] = new Tag(['title' => $tagTitle]); } $article = new Article(['tags' => $tagEntities]); $this->assertSame($expected, $article->tag_string); } public function dataTestTagString() { return [ [[''], ''], [['Torte'], 'Torte'], [['Torte', 'Financier', 'Macaron'], 'Torte, Financier, Macaron'], ]; }
タグが0個、1個、複数個の場合をテストするためにPHPUnitのデータプロバイダ機能で、テストデータをまとめてみました。
ルーティングのテスト (2-3)
(テスト実装はルーティングのテスト Pull Request #16 で確認できます)
タグによる記事の検索では、タグ付けされた記事をURLから検索できるようにするために config/routes.php
でカスタマイズしています。このファイルはクラスではありませんがテストできます。チュートリアルではまだ個数が少ないのでバグになりにくいですが、書いておくと、記述を増やすとき、既存のルーティングを壊さないかの確認ができます。
<?php // tests/TestCase/Routing/RoutingTest.php // 省略 <?php namespace App\Test\TestCase\Routing; use Cake\Http\ServerRequest; use Cake\Routing\Router; use Cake\TestSuite\TestCase; class RoutingTest extends TestCase { /** * 正引き ('/url' => 配列) * @dataProvider dataTestRouting * @param string $url * @param array $expected * @param array $expectedPass */ public function testRoute($url, $expected, $expectedPass=[]) { $expected['pass'] = $expectedPass; $actual = Router::parseRequest(new ServerRequest($url)); $this->assertSame($actual['controller'], $expected['controller']); $this->assertSame($actual['action'], $expected['action']); $this->assertSame($actual['pass'], $expected['pass']); } /** * 逆引き (配列 => '/url') * @dataProvider dataTestRouting * @param string $expected * @param array $parsedArray */ public function testReverseRoute($expected, $parsedArray) { $this->assertSame($expected, Router::url($parsedArray)); } public function dataTestRouting() { return [ [ '/articles/tagged', ['controller' => 'Articles', 'action' => 'tags'], ], [ '/articles/tagged/funny/cat/gifs', ['controller' => 'Articles', 'action' => 'tags', 'funny', 'cat', 'gifs'], ['funny', 'cat', 'gifs'], ], [ '/articles', ['controller' => 'Articles', 'action' => 'index'], ], [ '/articles/add', ['controller' => 'Articles', 'action' => 'add'], ], [ '/', ['controller' => 'Pages', 'action' => 'display', 'home'], ['home'], ], ]; } }
config/routes.php
をテストするときは Router
クラスを使ってテストします。
testRoute()
でURLから配列への変換を、 testReverseRoute()
で配列からURLへの変換をテストしています。
一度仕組みを作れば、その後はテストデータを追加するだけなので、おすすめです。
参考文献: CakePHP routes.phpの確認はユニットテストで - Shin x blog
もし、プラグインルーティングを使ったり、GETとPOSTでルートを分ける場合は、追加のテストコードが必要になるでしょう。
まとめ
最近改訂されたCakePHP 3.x チュートリアルに、テーブル、エンティティー、ルーティングのテストを追加する手順をまとめました。どのようなユニットテストを重視するかは、開発者それぞれ異なると思うので、コメントもらえるとありがたいです。
コントローラーの統合テストについてもまとめる予定です。(12/19予約しました!)
CakePHP Advent Calendar 2017 2日目の記事でした。