Engineer as a Lifestyle @tenkoma

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

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などを使って自動化する方法をとると、楽になるでしょう。