Engineer as a Lifestyle @tenkoma

What We Find Changes Who We Become -- Peter Morville著『アンビエント・ファインダビリティ 』

CakePHP 3 のチュートリアルにユニットテストを追加する (1)

これは 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 Articles コントローラーの作成

  • 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に

上のようにまとめたところ、記事の一覧・詳細・追加・編集・削除、記事に関連するタグ、認証(ログイン・ログアウト)、認可の機能を実装していますので、それに伴うユニットテストを以下の順で実装していくことにします。

  1. テーブル ArticlesTable のテスト (1-5, 1-6, 2-5, 2-7)
  2. エンティティー User のテスト (2-1)
  3. エンティティー Article のテスト (2-6)
  4. ルーティングのテスト (2-3)
  5. ログイン・ログアウトのテスト (3-1, 3-2)
  6. 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.

f:id:tenkoma:20171202153727p:plain

実行ログで 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());
    }

バリデーション結果は newEntitypatchEntity を呼び出して取得したエンティティーから getErrors() で取り出します。 バリデーションにパスする場合としない場合3パターンの計4パターンをテストしています。 テストを実行した結果は以下になります。

f:id:tenkoma:20171202154242p:plain

グリーンバーが表示されたらテスト成功です。

記事保存のテスト

記事 (Article エンティティー)を保存するときは以下の処理を行っています

  • タグ文字列をエンティティーに変換
  • 記事スラグ生成

記事更新時はスラグが変更されない、という違いがあるので、testSaveInserttestSaveUpdate に分けて実装します。

<?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 チュートリアルに、テーブル、エンティティー、ルーティングのテストを追加する手順をまとめました。どのようなユニットテストを重視するかは、開発者それぞれ異なると思うので、コメントもらえるとありがたいです。

コントローラーの統合テストについてもまとめる予定です。→CakePHP 3 のチュートリアルにユニットテストを追加する (2) - Engineer as a Lifestyle @tenkomaにまとめました。

CakePHP Advent Calendar 2017 2日目の記事でした。