こもろぐ @tenkoma

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

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

CakePHP3 アプリケーションのためのDocker開発環境を作ってみた

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

先日、第133回 PHP勉強会@東京で「PhpStormとPHPUnitを連携してユニットテスト作成を楽にする」というテーマで発表しました という記事を書きました。 そのとき、利用したサンプルアプリケーションとして tenkoma/cakephp_cms を使ったのですが、デモを簡単にするためにDocker環境を作ったので紹介します。

サンプルアプリケーション tenkoma/cakephp_cmsは去年のCakePHP Advent Calendar 2017 - Qiitaに投稿したCakePHP 3 のチュートリアルにユニットテストを追加する (1), (2)で使ったものです。

Docker設定の概要

Docker環境の設定はshin1x1さんのPhpStorm + Docker for Mac(docker-compose)での PHPUnit と Remote Debug の設定 - Shin x Blogをベースとして以下の変更を加えてあります。

  • CakePHP3アプリケーションに必要な intl拡張をインストール
  • MySQLコンテナを追加
  • 設定ファイルを手動で編集しなくても、環境が作れるように環境変数を docker-compose.yml に記述
  • その他細かい調整

使い方

git clone https://github.com/tenkoma/cakephp_cms.git
cd cakephp_cms

でリポジトリを取得して使います。以下のコマンドが使えます。

  • make 起動してcomposerパッケージインストール(make up + make install)
  • make up 起動
  • make install composerパッケージインストール
  • make migrate データベースマイグレーション
  • make test テスト実行
  • make clean 終了

MySQLデータベースのデータは dbdata/ 以下に保存されるので、リセットしたい場合は、make clean して dbdata/ を削除してください。

設定ファイル

以下、設定ファイル一式です。

docker-compose.yml

docker-compose up -d, docker-compose down に必要な大本の設定です。

version: "3.7"
services:
  web:
    build: ./docker/web
    environment: &app-environment
      DATABASE_URL: "mysql://my_app:secret@dev-db/my_app?encoding=utf8mb4&timezone=UTC&cacheMetadata=true"
      DATABASE_TEST_URL: "mysql://my_app:secret@dev-db/test_myapp?encoding=utf8mb4&timezone=UTC&cacheMetadata=true"
      PHP_IDE_CONFIG: "serverName=localhost"
    volumes:
      - ./:/var/www/html:cached
      - ./docker/web/php.ini:/usr/local/etc/php/php.ini:cached
    working_dir: /var/www/html
    ports:
      - "8000:80"
  php-cli:
    build: ./docker/web
    environment:
      <<: *app-environment
    volumes:
      - ./:/var/www/html
      - ./docker/web/php.ini:/usr/local/etc/php/php.ini
    working_dir: /var/www/html
  dev-db:
    image: mysql:5.7
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
    hostname: dev-db
    container_name: dev-db
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: my_app
      MYSQL_USER: my_app
      MYSQL_PASSWORD: secret
    volumes:
      - ./docker/mysql:/docker-entrypoint-initdb.d:cached
      - ./dbdata:/var/lib/mysql:cached
    ports:
      - "3306:3306"
  composer:
    build: ./docker/composer
    volumes:
      - ./:/app:cached
      - ./docker/web/php.ini:/usr/local/etc/php/php.ini:cached
    working_dir: /app

docker/composer/Dockerfile

Composer パッケージのインストールが早くなるよう、composerコンテナにhirak/prestissimo: composer parallel install pluginを追加しました。

FROM composer

RUN composer global require hirak/prestissimo

docker/mysql/001_initialize_database.sh

MySQL の公式コンテナは1つ目のデータベースは環境変数で指定できるのですが、それ以上は指定する方法がなさそうだったので初回起動時に作成するためのものです。

#!/bin/sh

echo "CREATE DATABASE IF NOT EXISTS \`test_myapp\` ;" | "${mysql[@]}"
echo "GRANT ALL ON \`test_myapp\`.* TO '${MYSQL_USER}'@'%' ;" | "${mysql[@]}"
echo 'FLUSH PRIVILEGES ;' | "${mysql[@]}"

docker/web/Dockerfile

webとphp-cliコンテナのためのDockerfileです。CakePHPアプリ向けにintl拡張、pdo_mysql拡張を追加して、ドキュメントルートを変更しました。

FROM php:7.2-apache

RUN apt-get update && apt-get install -y --no-install-recommends \
        nano \
        libicu-dev \
    && rm -rf /var/lib/apt/lists/*

RUN docker-php-ext-install intl \
    && docker-php-ext-install pdo_mysql \
    && pecl install xdebug \
    && docker-php-ext-enable xdebug

ENV APACHE_DOCUMENT_ROOT /var/www/html/webroot

RUN a2enmod rewrite

RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf
RUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf

docker/web/php.ini

デバッグの切り替えをBookmarkletで制御したかったのでautostart をOffにして、idekeyを有効にしてあります。

; timezone
date.timezone = Asia/Tokyo

; error reporing
log_errors = On
error_log = /dev/stderr

; xdebug
xdebug.remote_enable = On
xdebug.remote_autostart = Off
xdebug.remote_connect_back = Off
xdebug.remote_host = docker.for.mac.localhost
;xdebug.remote_port=9000
xdebug.idekey=phpstorm

Makefile

migratetest を追加しました。

all: install up
.PHONY: all

up:
  docker-compose up -d
.PHONY: up

install:
  docker-compose run composer install --ignore-platform-reqs --no-interaction
.PHONY: install

migrate:
  docker-compose run php-cli bin/cake migrations migrate
.PHONY: migrate

test:
  docker-compose run php-cli ./vendor/bin/phpunit
.PHONY: test

clean:
  docker-compose down
.PHONY: clean

第133回 PHP勉強会@東京で「PhpStormとPHPUnitを連携してユニットテスト作成を楽にする」というテーマで発表しました

CakePHP Advent Calendar 2018の3日目でCakePHP2 アプリでも PHPUnit と PhpStormを連携させるという記事を書きました。 実はこの連携と、連携することによってテスト作成が楽になる、という話は意外と知られていないのでは、と思い、PHP勉強会では初めての20分枠で発表することにしました。(PHPカンファレンス2018の会場から参加登録しました 笑)

20分枠でしたが内容を詰めていく内に以下の内容を削りました。

少し早口だったと思いますが20分に収まって良かったです。

日本PHPユーザ会のみなさん、参加してくださったみなさん、会場を提供していただいたGMOインターネットさん 発表の場をいただき、ありがとうございました!

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