こもろぐ @tenkoma

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

広告:本ブログで紹介している書籍等商品の紹介でAmazonアソシエイトを利用していることがあります。

CakePHP2 アプリでも PHPUnit と PhpStormを連携させる

CakePHP Advent Calendar 2018 - Qiita 3日目の記事になります。

去年のCakePHPアドベントカレンダーではCakePHP 3 のチュートリアルにユニットテストを追加する話を書きましたが、 今年はCake 2のテストの話を書きます。

CakePHP2 アプリは PhpStorm の恩恵が受けにくい

プロダクト開発でCakePHP2 を使ってアプリを構築していると、 PhpStorm 機能の恩恵を受けられない以下のような難点があります。

  • DBのデータを取り出すと配列で、コード補完ができない
  • 名前空間対応してないので、コード補完ができない
  • ユニットテスト実行が、phpunitコマンドではなく、独自のコマンド Console/cake test であり、PhpStorm と連携できない

1つ目と2つ目もつらい感じですが、テストを書くうえは3番目もつらいですね。 連携できれば、特定のテストメソッド実行や、行カバレッジをエディタに反映できてテストを書くのがはかどるのに!

ずっとそう思っていましたが、CakePHP のGitHubを見ていると次のIssueを見つけました。

[CakePHP2] Support for running phpunit directly? · Issue #12700 · cakephp/cakephp

But this means you can't integrate it with tooling like IDE/PhpStorm which expect to run phpunit directly and pass it appropriate flags.

This is very useful for re-running partial tests (i.e. only the failed tests), etc.

要は 「phpunit コマンドでテスト実行できないと、PhpStorm と連携できないけど、連携できると部分的にテスト再実行できたりして便利」という話です。

同じことを考えているCake2ユーザーは結構いるんじゃないかと思います。 ちなみにCakePHP 3は phpunit コマンドで実行できるので、PhpStorm 連携も簡単にできるはずです。

(2013年には独自コマンド Console/cake test のまま連携するためのノウハウがPhpStorm Blogで公開されていました(Running CakePHP2 Unit Tests in PhpStorm | PhpStorm Blog)が現在は動かないみたいです。)

さて、このIssueを見て僕は思いました。PhpStorm と連携できると、テストをとても書きやすくなるし、敷居も下がるはず。 そのためになんとかして phpunit コマンドでテストを動かせないか。

試行錯誤してみたところ、狙い通り動かすことができたので、記事としてまとめます。

目次

CakePHP 2 アプリのユニットテストをPhpStorm と連携させてできること

できたことを先に紹介します!

PhpStorm からテスト実行できる、結果がエディタに反映される

CakePHP2 アプリをインストールすると用意されている PagesController にテストを書いてみましょう。(app/Test/Case/Controller/PagesControllerTest.php)

<?php
App::uses('AppControllerTestCase', 'TestSuite');
class PagesControllerTest extends AppControllerTestCase
{
    public function testDisplay()
    {
        $this->testAction('/');
        $this->assertContains(
            'CakePHP is a rapid development framework for PHP',
            $this->contents
        );
    }

    public function testMyPage()
    {
        $this->testAction('/mypage');
    }
}

これを実行すると以下のような画面になります。

f:id:tenkoma:20181203023635p:plain

エディタ行番号の右にマークが付いていて、ここからテストを実行できます。 テスト結果は下のパネルに表示され、デバッグトレースで表示されるパスから、エディタにジャンプできます。

コードカバレッジがエディタに表示される

コードカバレッジを見れば、ユニットテストでよく分からないエラーになったときなど、どのコードまで実行されたのか簡単に追いかけることが出来ます。

class PagesControllerTest の左側のマークをクリックして、「Run 'PagesControllerTest (PHPUnit)' with Coverage」を選択してテストを実行してみましょう。 そして、テスト実行後に PagesController.php を開きます。

f:id:tenkoma:20181203024940p:plain

右側に Coverage パネルが表示され、エディタでは、実行された行(緑)と実行されてない行(赤)で色分けされています。

Xdebug 連携

Xdebug を使ったブレーク・ステップ実行自体は、phpunitコマンド化しなくても、なんとか可能と思います。 しかし、PHPUnit 連携すれば、テスト実行時にデバッグの有効無効を切り替えられるので、ブレークポイントの掃除が不要になります。

f:id:tenkoma:20181203030406p:plain

配列のアサーションDiff がエディタのDiffで見られる

assertEquals(), assertSame()*1 でテスト失敗時に「<Click to see difference>」をクリックすると以下のように表示されます。

f:id:tenkoma:20181203030717p:plain

以上のように便利になるので、ユニットテストが書きやすくなります。 それでは、連携を実現するための実装について説明します。

実装1: テストクラス・テスト対象クラスを実行するのに必要な前処理を app/Test/bootstrap.php に書く

Cake2アプリのユニットテストを実行するにはフレームワークとアプリで定義する定数、Configure、App クラスやその他の関数を読み込む必要があります。 そこで phpunit 向けのブートストラップファイルを用意し、CakePHP 2 TestShell がやってくれていた処理を書きます。

app/Test/bootstrap_for_phpunit_command.php

<?php
/**
 * Bootstrap for phpunit command
 */
/**
 * copy from app/Console/cake.php
 */
$dispatcher = 'Cake' . DS . 'Console' . DS . 'ShellDispatcher.php';
$root = dirname(dirname(dirname(__FILE__)));
$appDir = basename(dirname(dirname(__FILE__)));
$install = $root . DS . 'lib';
$composerInstall = $root . DS . $appDir . DS . 'Vendor' . DS . 'cakephp' . DS . 'cakephp' . DS . 'lib';
// the following lines differ from its sibling
// /lib/Cake/Console/Templates/skel/Console/cake.php
if (file_exists($composerInstall . DS . $dispatcher)) {
    $install = $composerInstall;
}
ini_set('include_path', $install . PATH_SEPARATOR . ini_get('include_path'));
if (!include $dispatcher) {
    trigger_error('Could not locate CakePHP core files.', E_USER_ERROR);
}
unset($dispatcher);
define('ROOT', $root);
define('APP_DIR', $appDir);
define('APP', ROOT . DS . APP_DIR . DS);
// ShellDispatcher内の、定数と環境変数を初期化するメソッドは利用するが、シェルは実行しない
new ShellDispatcher(array(getenv('_'), '-working', $appDir));
unset($root, $appDir, $install, $composerInstall);

// FixtureManager セットアップに必要
App::uses('AppFixtureManager', 'TestSuite/Fixture');

// cakeアプリならどこでも呼び出すのであえて App::uses() しないクラス。エラーになるので書く
App::uses('ClassRegistry', 'Utility');

app/Test/bootstrap.php

<?php
// phpunit コマンドから実行された場合に追加のbootstrap.php を読み込む
if (!defined('DS')) {
    define('DS', DIRECTORY_SEPARATOR);
    include_once dirname(__FILE__) . DS . 'bootstrap_for_phpunit_command.php';
}

bootstrap_for_phpunit_command.php に書かれていることは、app/Console/cake.php の記述をコピーして、必要な改変を加えたものです。 app/Test/bootstrap.php では DS 定数が定義されていたら*2読み込まないようにして、 Console/cake test でもテスト実行できるようにしています。 phpunit コマンドでこの app/Test/bootstrap.php を読み込むように設定します。

phpunit.xml.dist

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    colors="true"
    bootstrap="./app/Test/bootstrap.php"
    >
    <filter>
        <whitelist>
            <directory suffix=".php">app/Config</directory>
            <directory suffix=".php">app/Console</directory>
            <directory suffix=".php">app/Controller</directory>
            <directory suffix=".php">app/Lib</directory>
            <directory suffix=".php">app/Model</directory>
            <directory suffix=".php">app/Routing</directory>
            <directory suffix=".php">app/TestSuite</directory>
            <directory suffix=".php">app/View</directory>
        </whitelist>
    </filter>
</phpunit>

4行目の bootstrap="./app/Test/bootstrap.php" がその設定になります。 <whitelist> の部分は、コードカバレッジの連携時に必要です。

実装2: CakeTestRunner がやっていたFixtureManagerを準備する処理を実装する

phpunit コマンドでテスト実行すると、CakeTestRunner が面倒を見ていた TestCase::$fixtureManager が用意されなくなりますので、自前で準備します。 ここでは、テストケースの run() メソッドをオーバーライドして、$fixtureManager を用意しました。

コントローラーテスト用の基底クラス AppControllerTestCase とその他のテスト用の基底クラス AppTestCase にそれぞれ追加します。 追加するコードは同じなので、トレイトにまとめてもいいでしょう。

app/TestSuite/AppControllerTestCase.php

<?php
App::uses('ControllerTestCase', 'TestSuite');
class AppControllerTestCase extends ControllerTestCase
{
    public function run(PHPUnit_Framework_TestResult $result = null)
    {
        $this->setUpFixtureManagerForPhpunitCommand();
        return parent::run($result);
    }
    /**
    * 正式なユニットテスト実行コマンドではCakeTestRunnerからテスト実行するが
    * phpunit コマンドから実行したときは準備されないので TestCase::run() で準備できるようにする
    */
    private function setUpFixtureManagerForPhpunitCommand()
    {
        if (is_null($this->fixtureManager)) {
            App::uses('AppFixtureManager', 'TestSuite');
            if (class_exists('AppFixtureManager')) {
                $this->fixtureManager = new AppFixtureManager();
            } else {
                App::uses('CakeFixtureManager', 'TestSuite/Fixture');
                $this->fixtureManager = new CakeFixtureManager();
            }
            $this->fixtureManager->fixturize($this);
        }
    }
}

app/TestSuite/AppTestCase.php

<?php
App::uses('CakeTestCase', 'TestSuite');
class AppTestCase extends CakeTestCase
{
    public function run(PHPUnit_Framework_TestResult $result = null)
    {
        $this->setUpFixtureManagerForPhpunitCommand();
        return parent::run($result);
    }
    /**
    * 正式なユニットテスト実行コマンドではCakeTestRunnerからテスト実行するが
    * phpunit コマンドから実行したときは準備されないので TestCase::run() で準備できるようにする
    */
    private function setUpFixtureManagerForPhpunitCommand()
    {
        if (is_null($this->fixtureManager)) {
            App::uses('AppFixtureManager', 'TestSuite');
            if (class_exists('AppFixtureManager')) {
                $this->fixtureManager = new AppFixtureManager();
            } else {
                App::uses('CakeFixtureManager', 'TestSuite/Fixture');
                $this->fixtureManager = new CakeFixtureManager();
            }
            $this->fixtureManager->fixturize($this);
        }
    }
}

app/TestSuite/Fixture/AppFixtureManager.php

テストを作り込んでいくと、FixtureManager もカスタマイズしたくなると思うので、先に継承しておきましょう。

<?php
App::uses('CakeFixtureManager', 'TestSuite/Fixture');
class AppFixtureManager extends CakeFixtureManager
{
}

以上で、基本的な追加の設定は終わりです。

PhpStorm の設定

多少設定を変えているので自信はありませんが、「PHP CLI」の設定と「Test Frameworks」の設定をすると動くと思います。 手元の環境の設定をスクリーンショットで紹介します。

PHP CLI 設定

f:id:tenkoma:20181203020457p:plain

php-build を使ってビルドした、Xdebugが使えるPHP を指定しています。

PHP Test Frameworks 設定

f:id:tenkoma:20181203020957p:plain

Configuration TypeがLocalの設定を追加して、 phpunitコマンドと phpunit.xml.dist の場所を指定しています。

既存のテストやプロダクションコードでテスト時にエラーが出るようになった場合は

phpunit コマンドでテストを実行すると、既存のテストやアプリケーションコードがエラーになるかもしれません。 理由として考えられるのが、CakePHP TestShell 経由だとロード済みのクラスが読み込まれてない可能性です。 たとえば ClassRegistry クラスがそうです。 その場合、使っているクラスファイル内で App::uses('ClassRegistry', 'Utility'); とちゃんと書くか、 bootstrap_for_phpunit_command.php に書けばテスト実行できるようになるはずです。

おまけ: FixtureManager の準備をもう少し改善する

CI 含めて Console/cake test によるテスト実行を捨て、 phpunit コマンドに移行出来る場合は、FixtureManager の準備をもう少し改善することができます。 CakePHP 3 の phpunit.xml.dist が参考になります。 それを見ると、PHPUnit のテストリスナー機能を利用してテスト前後のイベントにフックしてテストケースに FixtureManager オブジェクトを渡していました。

移植できるか試してみたところ、CakePHP 2.10 + PHPUnit 3.7 または 5.7 で動作しました。

参考: PHPUnit_Framework_TestListener の実装

app/TestSuite/Fixture/AppFixtureInjector.php

<?php
App::uses('CakeTestCase', 'TestSuite');

class AppFixtureInjector implements PHPUnit_Framework_TestListener
{
    /** @var CakeFixtureManager */
    protected $fixtureManager;

    /** @var PHPUnit_Framework_TestSuite */
    protected $first;

    public function __construct(CakeFixtureManager $manager)
    {
        $this->fixtureManager = $manager;
        $this->fixtureManager->shutDown();
    }

    public function startTestSuite(PHPUnit_Framework_TestSuite $suite)
    {
        if (empty($this->first)) {
            $this->first = $suite;
        }
    }

    public function endTestSuite(PHPUnit_Framework_TestSuite $suite)
    {
        if ($this->first === $suite) {
            $this->fixtureManager->shutDown();
        }
    }

    public function startTest(PHPUnit_Framework_Test $test)
    {
        $test->fixtureManager = $this->fixtureManager;
        if ($test instanceof CakeTestCase) {
            $this->fixtureManager->fixturize($test);
            $this->fixtureManager->load($test);
        }
    }

    public function endTest(PHPUnit_Framework_Test $test, $time)
    {
        if ($test instanceof CakeTestCase) {
            $this->fixtureManager->unload($test);
        }
    }

    /**
    * {@inheritdoc}
    */
    public function addError(PHPUnit_Framework_Test $test, Exception $e, $time)
    {
    }

    /**
    * {@inheritdoc}
    */
    public function addFailure(PHPUnit_Framework_Test $test, PHPUnit_Framework_AssertionFailedError $e, $time)
    {
    }

    /**
    * {@inheritdoc}
    */
    public function addIncompleteTest(PHPUnit_Framework_Test $test, Exception $e, $time)
    {
    }

    /**
    * {@inheritdoc}
    */
    public function addSkippedTest(PHPUnit_Framework_Test $test, Exception $e, $time)
    {
    }
    
    /**
    * {@inheritdoc}
    */
    public function addRiskyTest(PHPUnit_Framework_Test $test, Exception $e, $time)
    {
    }
}

定義した AppFixtureInjector をテストリスナーとして追加するには phpunit.xml.dist<listeners> の部分を追加します。

phpunit.xml.dist

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    colors="true"
    bootstrap="./app/Test/bootstrap.php"
    >
    <listeners>
        <listener class="AppFixtureInjector" file="./app/TestSuite/Fixture/AppFixtureInjector.php">
            <arguments>
                <object class="AppFixtureManager" />
            </arguments>
        </listener>
    </listeners>
    <filter>
        <whitelist>
            <directory suffix=".php">app/Config</directory>
            <directory suffix=".php">app/Console</directory>
            <directory suffix=".php">app/Controller</directory>
            <directory suffix=".php">app/Lib</directory>
            <directory suffix=".php">app/Model</directory>
            <directory suffix=".php">app/Routing</directory>
            <directory suffix=".php">app/TestSuite</directory>
            <directory suffix=".php">app/View</directory>
        </whitelist>
    </filter>
</phpunit>

以上です。

4日目は @tsyama さんです。

*1:PHPUnit 7以上

*2:CakePHP TestShellから実行された場合は定義済み

Read the Docs にあるドキュメント(Sphinx) で引用符が "「", "」" などに変換されるのを防止する

PHPUnit のドキュメントは reStructuredTextというマークアップ言語で記述され、Sphinx というビルドツールでHTMLなどのフォーマットに変換されている。Sphinx によるビルドはRead the Docsというドキュメントホスティングサービス上で実行される。

Sphinx で使っている docutils の機能かと思うが、引用符 "' を各自然言語ごとにローカライズされた引用符に変換する機能があり、既定で有効になっている。日本語だと , , に変換される。

便利な場面もあるが、英単語、英文の場合は英語版と同じ , が見やすく感じるし、全体的にそれでもよさそうに思う。, を使おうと思えば翻訳時に使えるので。

そこで調査してプルリクエストを作った

必要な修正は

  • docutils は 0.14以上を使うようにする
  • docutils.conf に日本語の引用符スタイルに使う文字の設定を書く

となる。

以上。

CakePHPのカンファレンス "CakeFest 2019" の候補地に日本が!

  • Japan,
  • Atlanta GA, USA
  • France
  • Germany

全世界のCakePHP コミッターやユーザーが集まるカンファレンス CakeFest 🍰 - CakePHP Conference - Home Page 2019 の候補地に日本が選ばれています。

2008年からおよそ年に1回開催されていますが、日本で開催されたことはありません。CakeFestが日本で開催してほしいと思っている方は、是非一緒に投票しましょう!

投票ページ(cakefest.org)にある "vote!" ボタンを押すと My CakePHPというサイトへのログインを求められます。 アカウントがない方は下の "Sine-up" をクリックしてアカウントを作成してください。 CakePHPユーザーとしてのプロフィール表示機能以外はなさそうなサイトですが、投票には必要です。

よくわからないことがあれば、この記事のコメント欄か CakePHP公式の日本語話者向けSlackチャンネル などで聞いてもらえればと思います。よろしくお願いします。

dnscrypt-proxy を使って DNS over HTTPS環境にする

自宅のネットがマンションのLAN内のゲートウェイが中間者攻撃してくる環境だった。たまにリダイレクトする。HTTPSサイトでも起こるので、DNS応答を書き換えられてるっぽい。でもネット接続に追加投資したくないなぁ、という感じだったので、DNS over HTTPSを試すことにした。

手順

Homebrew でインストール

$ brew install dnscrypt-proxy

/usr/local/etc/dnscrypt-proxy.toml の31行目あたりに、server_names 行を追加

 ## Remove the leading # first to enable this; lines starting with # are ignored.
 
 # server_names = ['scaleway-fr', 'google', 'yandex', 'cloudflare']
+server_names = ['google', 'cloudflare']
 
 
 ## List of local addresses and ports to listen to. Can be IPv4 and/or IPv6.
 ## Note: When using systemd socket activation, choose an empty set (i.e. [] ).

サービスを起動する

$ sudo brew services restart dnscrypt-proxy

あとはネットワーク設定でDNSを 127.0.0.1 にすればOK

f:id:tenkoma:20180427003334p:plain

以上で設定完了です。

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

これは CakePHP Advent Calendar 2017 19日目の記事です。遅れてごめんなさい。 18日目は @neeton_iwasakiさんのCakePHP SocialAuth Pluginプラグイン使用例 | ハックノートでした。

前回の記事 CakePHP 3 のチュートリアルにユニットテストを追加する (1) では、構築したアプリケーションに対して、ユニットテストを書くための準備を行い、テーブル、エンティティー、ルーティングのテストを書きました。

今回は残りのコントローラークラスにテストコードを書いていきましょう。

どんなテストを書くか

チュートリアルでは、 ArticlesController, TagsController, UsersController を作成しました。 すべてのアクションについてテストを書くと説明が重複してしまいます。 そこで、 bake 後のカスタマイズで手を加えた ArticlesController のすべてのアクションと UsersControllerlogin(), logout() アクションにテストを追加します。

  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 のテスト (上記以外全部) (この記事でテストを書きます)

UsersController のテスト

(実装は 認証機能のテストPull Request #17 で確認できます)

ログイン login() のテストを書く

ログインのテストでは以下のテストを書いてみます。

  • ログインページが表示されること
  • 認証失敗したらエラーメッセージを表示する
  • 認証成功したら認証セッションを書き込み、元のページにリダイレクトする

ログインのテストをするためには ユーザー情報の準備が必要です。UsersFixture はダミーのテキストが入ったままなので、テストできるデータに変更します。 ここでは次の init() メソッドを追加してテストデータを生成します。

<?php
// tests/Fixture/UserFixture.php
    public function init()
    {
        $hasher = new DefaultPasswordHasher();
        $this->records = [
            [
                'id' => 1,
                'email' => 'myname@example.com',
                'password' => $hasher->hash('password'),
                'created' => '2017-11-18 11:45:40',
                'modified' => '2017-11-18 11:45:40'
            ],
        ];
        parent::init();
    }

init() メソッドを定義するとプロパティ初期代入より自由にテストデータを生成できます。 UserFixture 全体は以下の通りです。

<?php
// tests/Fixture/UserFixture.php
namespace App\Test\Fixture;

use Cake\Auth\DefaultPasswordHasher;
use Cake\TestSuite\Fixture\TestFixture;

/**
 * UsersFixture
 *
 */
class UsersFixture extends TestFixture
{

    /**
     * Fields
     *
     * @var array
     */
    // @codingStandardsIgnoreStart
    public $fields = [
        'id' => ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null],
        'email' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8mb4_general_ci', 'comment' => '', 'precision' => null, 'fixed' => null],
        'password' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'collate' => 'utf8mb4_general_ci', 'comment' => '', 'precision' => null, 'fixed' => null],
        'created' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null],
        'modified' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null],
        '_constraints' => [
            'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []],
        ],
        '_options' => [
            'engine' => 'InnoDB',
            'collation' => 'utf8mb4_general_ci'
        ],
    ];
    // @codingStandardsIgnoreEnd

    /**
     * Records
     *
     * @var array
     */
    public $records = [];

    public function init()
    {
        $hasher = new DefaultPasswordHasher();
        $this->records = [
            [
                'id' => 1,
                'email' => 'myname@example.com',
                'password' => $hasher->hash('password'),
                'created' => '2017-11-18 11:45:40',
                'modified' => '2017-11-18 11:45:40'
            ],
        ];
        parent::init();
    }
}

UsersController のテストを書きましょう。 ログイン機能のテストを3つのテストメソッドに定義しました。

<?php
// tests/TestCase/Controller/UsersController.php
namespace App\Test\TestCase\Controller;

use App\Controller\UsersController;
use Cake\ORM\TableRegistry;
use Cake\TestSuite\IntegrationTestCase;

/**
 * App\Controller\UsersController Test Case
 */
class UsersControllerTest extends IntegrationTestCase
{

    /**
     * Fixtures
     *
     * @var array
     */
    public $fixtures = [
        'app.users',
        'app.articles'
    ];

    /**
     * ログインページが表示される
     */
    public function testLoginShow()
    {
        $this->get('/users/login');
        $this->assertResponseOk();
        $this->assertResponseContains('ログイン');
    }

    /**
     * ログイン失敗
     */
    public function testLoginFailed()
    {
        $this->post('/users/login', [
            'email' => 'myname@example.com',
            'password' => 'wrongpassword',
        ]);
        $this->assertResponseOk();
        $this->assertResponseContains('ユーザー名またはパスワードが不正です。');
    }

    /**
     * ログイン成功
     */
    public function testLoginSucceed()
    {
        $this->post('/users/login?redirect=%2Farticles%2Fadd', [
            'email' => 'myname@example.com',
            'password' => 'password',
        ]);
        $this->assertRedirect('/articles/add');
        $this->assertSession(1, 'Auth.User.id');
    }
}

get($url)post($url, $data) を使ってアクションメソッドを実行し、 assertResponseOk()assertResponseContains(...) で結果(応答ヘッダやボディ)を検証しています。

コントローラーのテストについて

このテストコードはコントローラーの統合テストを使って実装しました。いままで実装したテーブルやエンティティーのユニットテストと比べると、テスト対象のオブジェクト生成や、変数への代入がありません。それらはすべてコントローラーの統合テストのために用意されたメソッド( get()post() 、各種アサーション)でラップされています。

このような仕組みは、テストコードを読みやすく簡潔にしやすい利点があります。一方で、処理やデータが隠されているため、テストコードを書きにくく感じることがあるかもしれません。その場合、 IntegrationTestCase のコードを読むと、軽減できるかと思います。

ログアウト logout() のテストを書く

<?php
// tests/TestCase/Controller/UsersController.php
// ... 略
    /**
     * ログアウト
     */
    public function testLogout()
    {
        $this->session(['Auth.User.id' => 1]);

        $this->get('/users/logout');

        $this->assertSession([], 'Auth');
        $this->assertRedirect('/users/login');
    }
// ... 略

$this->session(...) で認証済み時のセッションデータを作り、 logout アクションを実行後の振る舞いを検証します。

ArticlesController のテスト

ArticlesController には6つのアクションメソッドがあります。まずはbakeでテストケースクラスを生成します。

$ bin/cake bake test Controller ArticlesController

記事一覧 index()

(実装は テスト追加: 記事一覧を表示 Pull Request #18 で確認できます)

  • 各記事のタイトルが表示されること

をテストします。元々あった testIndex() メソッドを以下に置き換えてください。

<?php
// tests/TestCase/Controller/ArticlesControllerTest.php

    public function test記事一覧を表示()
    {
        $this->get('/articles');
        $this->assertResponseOk();
        $this->assertResponseContains('CakePHP3 チュートリアル');
        $this->assertResponseContains('Happy new year');
    }

記事詳細 view()

(実装は テスト追加: 記事詳細を表示 Pull Request #19 で確認できます)

  • 詳細ページが表示されること
  • 詳細ページが存在しないとき404になること

をテストします。 testView() を以下でいれかえてください。

<?php
// tests/TestCase/Controller/ArticlesControllerTest.php
    public function test記事詳細ページを表示()
    {
        $this->get('/articles/view/CakePHP3-chutoriaru');

        $this->assertResponseOk();
        $this->assertResponseContains('CakePHP3 チュートリアル'); // title
        $this->assertResponseContains('このチュートリアルは簡単な ' .
            'CMS アプリケーションを作ります。'); // body
    }

    public function test記事詳細ページが存在しない()
    {
        $this->get('/articles/view/not-found-articles');

        $this->assertResponseCode(404);
    }

ちなみに、 articles テーブルには published カラムがありますが、チュートリアルではフィルタを実装していません。 published = 1 の記事だけ表示するロジックを追加したら、それに対するテストを書いてもいいでしょう。

記事追加 add()

(実装は テスト追加: 記事追加 Pull Request #20 で確認できます)

  • 記事追加ページにアクセスできる
  • 記事が追加されると、記事一覧にリダイレクトする
  • バリデーションエラーだと追加できず、エラーメッセージが表示される

をテストします。

記事を追加するには、 users テーブルが必要なため、フィクスチャーに追加しておきます。

<?php
// tests/TestCase/Controller/ArticlesControllerTest.php
    public $fixtures = [
        'app.users', // この行を追加
        'app.articles',
        'app.tags',
        'app.articles_tags'
    ];

準備できたので、 testAdd() を以下でいれかえてください。

<?php
// tests/TestCase/Controller/ArticlesControllerTest.php
    public function test記事追加ページにアクセスできる()
    {
        $this->session(['Auth.User.id' => 1]);
        $this->get('/articles/add');

        $this->assertResponseOk();
        $this->assertResponseContains('記事の追加');
    }

    public function test記事が追加されると記事一覧にリダイレクトする()
    {
        $this->session(['Auth.User.id' => 1]);
        $this->post('/articles/add', [
            'title' => 'Nintendo Switch を購入!',
            'body' => 'クリスマスプレゼントとして買った',
            'tag_string' => 'game,2017',
        ]);

        $this->assertSession('Your article has been saved.', 'Flash.flash.0.message');
        $this->assertRedirect('/articles');

        $this->get('/articles');
        $this->assertResponseContains('Nintendo Switch を購入!');
    }

    public function testバリデーションエラーだと追加できずエラーメッセージが表示される()
    {
        $this->session(['Auth.User.id' => 1]);
        $this->post('/articles/add', [
            'title' => 'Nintendo Switch を購入!',
            'body' => '',
            'tag_string' => '',
        ]);

        $this->assertResponseOk();
        $this->assertResponseContains('Unable to add your article.');

        $this->get('/articles');
        $this->assertResponseNotContains('Nintendo Switch を購入!');
    }

記事の追加はログインしないとできません。 $this->session(['Auth.User.id' => 1]); で認証済みの状態にします。

記事編集 edit()

(実装は テスト追加: 記事編集 Pull Request #21 で確認できます)

  • 記事編集ページにアクセスできる
  • 記事を更新し、その後、記事一覧にリダイレクトする
  • バリデーションエラーだと更新できず、エラーメッセージが表示される

をテストします。testEdit() を以下でいれかえてください。

<?php
// tests/TestCase/Controller/ArticlesControllerTest.php
    public function test記事編集ページにアクセスできる()
    {
        $this->session(['Auth.User.id' => 1]);
        $this->get('/articles/edit/CakePHP3-chutoriaru');

        $this->assertResponseContains('記事の編集');
        $this->assertResponseContains('CakePHP3 チュートリアル');
    }

    public function test記事を更新し記事一覧にリダイレクトする()
    {
        $this->session(['Auth.User.id' => 1]);
        $this->post('/articles/edit/CakePHP3-chutoriaru', [
            // タイトルを変更する
            'title' => '1時間で分かるCakePHP3 チュートリアル',
            'body' => 'このチュートリアルは簡単な CMS アプリケーションを作ります。 はじめに CakePHP のインストールを行い、データベースの作成、 そしてアプリケーションを素早く仕上げるための CakePHP が提供するツールを使います。',
            'tag_string' => 'PHP,CakePHP',
        ]);
        $this->assertRedirect('/articles');
        $this->assertSession('Your article has been updated.', 'Flash.flash.0.message');

        $this->get('/articles');
        $this->assertResponseContains('1時間で分かるCakePHP3 チュートリアル');
    }

    public function testバリデーションエラーだと更新できずエラーメッセージが表示される()
    {
        $this->session(['Auth.User.id' => 1]);
        $this->post('/articles/edit/CakePHP3-chutoriaru', [
            // タイトルを変更する
            'title' => '1時間で分かるCakePHP3 チュートリアル',
            'body' => '',
        ]);
        $this->assertResponseOk();
        $this->assertResponseContains('Unable to update your article.');

        $this->get('/articles');
        $this->assertResponseNotContains('1時間で分かるCakePHP3 チュートリアル');
    }

ほとんど記事の追加と一緒です。

記事削除 remove()

(実装は テスト追加: 記事削除 Pull Request #22 で確認できます)

  • 記事を削除する。その後、記事一覧にリダイレクトする
  • getリクエストの場合削除しない

をテストします。testDelete() を以下でいれかえてください。

<?php
// tests/TestCase/Controller/ArticlesControllerTest.php
    public function test記事を削除してその後記事一覧にリダイレクトする()
    {
        $this->session(['Auth.User.id' => 1]);
        $this->post('/articles/delete/CakePHP3-chutoriaru');

        $this->assertRedirect('/articles');

        $this->get('/articles');
        $this->assertResponseNotContains('CakePHP3 チュートリアル');
    }

    public function testGetリクエストの場合削除しない()
    {
        $this->session(['Auth.User.id' => 1]);
        $this->get('/articles/delete/CakePHP3-chutoriaru');

        $this->assertResponseCode(405);
        $this->get('/articles');
        $this->assertResponseContains('CakePHP3 チュートリアル');
    }

タグ検索 tags()

(実装は テスト追加: タグ検索 Pull Request #23 で確認できます)

  • 複数タグを指定してアクセス
  • 存在しないタグを指定してアクセス

をテストします。testTags() を以下でいれかえてください。

<?php
// tests/TestCase/Controller/ArticlesControllerTest.php
    public function test複数タグを指定してアクセス()
    {
        $this->get('/articles/tagged/php/cakephp');

        $this->assertResponseOk();
        $this->assertResponseRegExp('/Articles tagged with\s+php or cakephp/m');
        $this->assertResponseContains('CakePHP3 チュートリアル');
    }

    public function test存在しないタグを指定してアクセス()
    {
        $this->get('/articles/tagged/undefined-tag');

        $this->assertResponseOk();
        $this->assertResponseRegExp('/Articles tagged with\s+undefined-tag/m');
        $this->assertResponseContains('記事が見つかりませんでした。');
    }

この記事では基本的にプロダクションコードを変更してません。タグ検索の場合、存在しないタグを指定した場合に、記事一覧の部分に何も表示されないので、「記事が見つかりませんでした。」と表示して、その表示をテストしました。テンプレートを以下のように修正してください。

<?php
// src/Template/Articles/tags.ctp
/**
 * @var array $tags
 * @var App\Model\Entity\Article[] $articles
 */
?>
<h1>
    Articles tagged with
    <?= $this->Text->toList(h($tags), 'or') ?>
</h1>

<section>
    <?php if ($articles->count() === 0): ?>
    <p>記事が見つかりませんでした。</p>
    <?php endif; ?>
    <?php foreach ($articles as $article): ?>
        <article>
            <!-- リンクの作成に HtmlHelper を使用 -->
            <h4><?= $this->Html->link(
                    $article->title,
                    ['controller' => 'Articles', 'action' => 'view', $article->slug]
                ) ?></h4>
            <span><?= h($article->created) ?>
        </article>
    <?php endforeach; ?>
</section>

まとめ

CMSチュートリアルで実装したコントローラーにテストを書く方法を紹介しました。

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

付録・CircleCI で継続的インテグレーションする設定

(実装は CircleCI セットアップ Pull Request #11 で確認できます)

今回、テスト実装はメソッドごとにPull Requestで分けていました。(Pull Requests · tenkoma/cakephp_cms)そして、テストを実装し始める前にCircleCI でPush毎に全テストを実行する準備をしました。

CircleCIのセットアップ手順は省略しますが、CakePHP3プロジェクトをCircleCIでテストするための設定ファイルはcakephp_cms/config.yml at masterで確認できます。

テストコードが増えていくと、毎回ローカルで全テストを実行するのは大変です。CircleCIやTravisCIなどを使って自動化する方法をとると、楽になるでしょう。

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日目の記事でした。

ssh設定アップデート(ed25519鍵を使う)

新しい環境を作る機会もあるので、sshの設定も見直すことにしました。 いままで2048bit RSA鍵を使っていましたがed25519鍵を使うことにします。 ローカルPCには.ssh/id_rsa, .ssh/id_rsa.pub が作成されていることとします。

$ ls -al .ssh
total 64
drwx------   9 tenkoma  staff   306  2 18  2017 .
drwxr-xr-x+ 91 tenkoma  staff  3094  8 26 11:04 ..
-rw-r--r--   1 tenkoma  staff   117  1 14  2017 config
-rw-------   1 tenkoma  staff  3326 11 19  2016 id_rsa
-rw-r--r--   1 tenkoma  staff   752 11 19  2016 id_rsa.pub
-rw-r--r--   1 tenkoma  staff  6622  4  2 20:23 known_hosts

次のコマンドでed25519鍵を生成します。

$ ssh-keygen -t ed25519
Generating public/private ed25519 key pair.
Enter file in which to save the key (/Users/tenkoma/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /Users/tenkoma/.ssh/id_ed25519.
Your public key has been saved in /Users/tenkoma/.ssh/id_ed25519.pub.
The key fingerprint is:
(略)
The key's randomart image is:
(略)

.ssh/id_ed25519, .ssh/id_ed25519.pub が作成されました。

https://github.com/settings/keys にアクセスして、既存の鍵を削除し、.ssh/id_ed25519.pubの中身のテキストを登録します。

登録したらGitHubに接続テストします。ssh-add でsshエージェントに登録し、毎回パスフレーズを聞かれないようにします。

$ ssh-add -D
All identities removed.
$ ssh-add .ssh/id_ed25519
Enter passphrase for .ssh/id_ed25519:
Identity added: .ssh/id_ed25519 (tenkoma@KojinoMacBook-Pro.local)
$ ssh -T git@github.com
Hi tenkoma! You've successfully authenticated, but GitHub does not provide shell access.

メッセージが表示されたら成功です。

古い鍵を順次アップデートするために

ssh 接続するとき、自動的に.ssh/id_rsaを利用するため、古い設定が残り続けていても気づかない恐れがあります。.ssh/id_rsa, .ssh/id_rsa.pubをリネームして、接続できなかった場合に ssh -i ~/.ssh/id_rsa_deprecated username@example.com でログインしてリモートの鍵を更新します。やむをえず古い鍵を使う場合は、.ssh/config に設定しておきます。

Host deprecated-ssh-key-host.example.com
    IdentityFile ~/.ssh/id_rsa_deprecated