読者です 読者をやめる 読者になる 読者になる

DocCommentでPHPのユニットテストの書きやすさを劇的に改善する手法

こんにちは、pixivでPHPをやってる@tadsanです。好きなテスティングフレームワークはPHPUnitです! 好きな某CALOIDはテトさんです!

みなさまはユニットテストを書いていらっしゃいますか? 今回はDocCommentとPHPUnitのデータプロバイダーをうまく利用してテストの記述を省力化する手法を紹介いたします ヾ(〃><)ノ゙

提案手法

実装のDocCommentに「期待値」と「入力パラメータ」を記述することで、テストケースメソッドをいちいち追加せずともユニットテストできるようになります。また、入力(パラメータ)と出力(期待値)を明記することで、実行せずともコメントとしてわかりやすくする効果があります。

<?php

/**
 * @route\example http://touch.pixiv.net/member_illust.php?mode=medium&illust_id=12345 {illust_id: 12345}
 */
public static function fullTouchMemberIllustMedium(array $params)
{
    Util_Assert::num($params['illust_id']);
    $params = array_merge(['mode' => 'medium'], $params);
    return self::buildUrl(SYSTEM_URL_TOUCH, '/member_illust.php', ['mode', 'illust_id'], $params);
}

ドキュメント文字列(コメント)に含まれる情報を使ってテストを実行する手法にはPython標準ライブラリのdoctestなどがあります。

承前(テスト対象について)

この節では提案手法を導入するに至った事案について紹介します。テスト手法だけを知りたいかたは読み飛ばしてください。

テンプレートエンジンとURL

Smartyのようなテンプレートエンジンでは、HTML内にaリンクを素直に記述すると以下のようになります。 (SYSTEM_URL_WWWhttp://www.pixiv.net/に展開されます)

<a href="{$smarty.const.SYSTEM_URL_WWW|escape}member_illust.php?id={$user.id|escape}">{$user.name|escape}</a>

HTMLテンプレートおよびPHPのコード内にそのまま文字列処理でURLを組み立てると、`URLを移動することになった際に変更が困難です。そのため、pixivではURLの管理にリバースルーティングと呼ばれる仕組みを利用して居ります。

<a href="{reverse_route page='fullWwwMemberProfile' id=$user.id}">{$user.name|escape}</a>

「リバースルーティング」はページの識別子とパラメータからURLを組み立てる手法です。Ruby on Railsのルーティングヘルパーと呼ばれる機能におよそ相当します(Rails のルーティング | Rails ガイド)。reverse routingの名前を採用した実装では、CakePHPのRoutingやPlay FrameworkのHTTP routingなどがあるようです。

(余談ですが、拙作のPHPのルーター実装TetoRoutingはリバースルーティング機能を持ちます。これを利用してテンプレートエンジンにURLヘルパーを実装する手法はインスパイヤされて掲示板を作りたくなった(5) - Qiitaを参照ください)

リバースルーティングの実装

pixiv.netの場合はもともとルータースクリプトを使ったルーティングは使用してないため、特定のルーターエンジンには依存せず、単にクラスの静的メソッドとして実装してます。(最近は簡単に捨てやすい構造を指向してるので、半年後には別の実装になってる可能性もあります)

このような大量のメソッドを持つユーティリティクラスの存在を是認すべきかといったことは議論があるについてはところですが、ここでは一つの手法として採用することとします。

<?php

final class ReverseRoute
{
    public static function fullWwwMemberProfile(array $params)
    {
        Util_Assert::num($params['id']);
        return ReverseRoute::buildUrl(SYSTEM_URL_WWW, '/member.php', ['id'], $params);
    }

    public static function fullWwwNovelSeries(array $params)
    {
        Util_Assert::num($params['id']);
        if (isset($params['p'])) {
            Util_Assert::num($params['p']);
        }
        return self::buildUrl(SYSTEM_URL_WWW, '/novel/series.php', ['id', 'p'], $params);
    }

    public static function wwwHelp(array $params)
    {
        return ReverseRoute::buildUrl('', '/help.php', [], []);
    }
}

Util_Assert::num()は実行時に型検査を行なって想定外の値を受け取ったら例外を投げるだけの社内ライブラリのメソッドです。Respect/Validationっぽく書くならv::intVal()->min(1)->assert($params['id']);ですね。また、定数SYSTEM_URL_WWWhttp://www.pixiv.net/に展開されます。

テストを書く

素直にテストを書くならば以下のようになりますでしょうか。

<?php

final class ReverseRouteTest extends \PHPUnit_Framework_TestCase
{
    public function test_fullWwwMemberProfile()
    {
        $this->assertSame('http://touch.pixiv.net/member_illust.php?mode=medium&illust_id=12345', \ReverseRoute::fullWwwMemberProfile(['illust_id' => 12345]));

        $raised = false;
        try {
            \ReverseRoute::fullWwwMemberProfile(['illust_id' => 12345]);
        } catch (\ErrorException $e) {
            $raised = true;
        } finally {
            $this->assertTrue($raised);
        }
    }

    // ...
}

こんなものを全メソッドに対して定義していくと思ったら、めまいがしてきますね。

DocCommentを使ったソリューションのご提案

PHPのDocCommentをご存じでしょうか? 以前、社内勉強会で利用したスライドで軽く触れましたので紹介いたします。

要約いたしますと、クラスやメソッド定義の直前に書いた /** … */ 形式のコメントはリフレクションAPIを使って実行時に取得することができるのです。

さて、これを使ってうまいことテストが書けないかな、と思ってたところに@HirakuPHPUnitとデータプロバイダとテストケース生成 - Qiitaの記事が飛び込んできました。PHPのジェネレータの機能を組み合せると、いい感じに実装できたではありませんか!

冒頭にも書きましたが、ここではメソッド単位で以下のようにDocComment形式で書くことにします。読みやすさと書きやすさを考慮した結果、期待値は文字列をそのまま、パラメータはYAML形式のオブジェクトで記述することにします。

<?php

/**
 * @route\example http://touch.pixiv.net/member_illust.php?mode=medium&illust_id=12345 {illust_id: 12345}
 */
public static function fullTouchMemberIllustMedium(array $params)
{
    Util_Assert::num($params['illust_id']);
    $params = array_merge(['mode' => 'medium'], $params);
    return self::buildUrl(SYSTEM_URL_TOUCH, '/member_illust.php', ['mode', 'illust_id'], $params);
}

テストクラスの作成

PHPUnitのテストクラスの実装全体は以下の通りです(YAMLパーサとしてsymfony\yamlに依存してます)。もうメソッドごとにテストメソッドとデータプロバイダーを定義しまくる地獄とはおさらばだ。

<?php

/**
 * DocCommentに書かれたルーティングのテストを実行する
 *
 * @copyright 2016 pixiv Inc.
 * @author USAMI Kenta <tadsan@zonu.me>
 * @license https://opensource.org/licenses/MIT MIT
 */
final class DocCommentTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @dataProvider dataProviderFor_test
     */
    public function test($method, $expected, $params, $params_str)
    {
        $this->assertNotEquals('YAML syntax error', $params, "パラメータの定義が不正です ({$params_str})");

        if (strpos($expected, 'Exception') !== false) {
            $this->expectException($expected);
        }

        $this->assertEquals($expected, \ReverseRoute::$method($params));
    }

    /**
     * ジェネレータを使ったデータプロバイダ
     *
     * 配列を返すのではなく、DocCommentのパースを継続しながら yield でデータセットを返す
     *
     * @see http://php.net/manual/language.generators.overview.php ジェネレータ
     */
    public function dataProviderFor_test()
    {
        $ref = new \ReflectionClass(\ReverseRoute::class);
        foreach ($ref->getMethods() as $method) {
            $lines = $method->getDocComment();
            if ($lines === false) {
                continue;
            }
            foreach (explode("\n", $lines) as $line) {
                $test = DocCommentTest::getDocTest($method, $line);
                if ($test) {
                    yield $method->getName() => $test;
                }
            }
        }
    }

    /**
     * DocCommentの行から @route\example のテスト行を取得する
     *
     * @param  \ReflectionMethod $ref
     * @param  string            $input
     * @return array|false
     */
    private static function getDocTest(\ReflectionMethod $ref, $input)
    {
        if (!preg_match('#\* @route\\\\example\s+(?<expected>\S+?)\s+(?<params>.+)$#', $input, $m)) {
            return false;
        }

        try {
            $data = [$ref->getName(), $m['expected'], \Symfony\Component\Yaml\Yaml::parse($m['params']), $m['params']];
        } catch (\Symfony\Component\Yaml\Exception\ParseException $e) {
            $data = [$ref->getName(), $m['expected'], 'YAML syntax error', $m['params']];
        }

        return $data;
    }

    /**
     * テストのテスト
     */
    public function test_getDocTest()
    {
        $expected = ['wwwHelp', 'xyz', 'YAML syntax error', '{obj: "val}'];
        // YAMLでsyntax errorになる入力 ("の閉じ忘れ)
        $input = ' * @route\example xyz {obj: "val}';
        $ref = (new \ReflectionClass(\ReverseRoute::class))->getMethod('wwwHelp');
        $this->assertEquals($expected, DocCommentTest::getDocTest($ref, $input));
    }
}

まとめ

  • この手法は「引数と返り値が画一的な大量のメソッドを持つクラス」のテストを単純化するために役立ちます
    • 引数の形式が異なる多数のメソッドを持つクラスや、複雑な状態を持つテストについては適用しにくそう
    • 今回の実装はパーサの実装をさぼってるので、期待値と入力パラメータは単一行でしか書けません
    • デメリットを把握した上で、用法容量を守って適度に使ってくださいね
  • この手法は@HirakuPHPUnitとデータプロバイダとテストケース生成 - Qiitaに着想を得ました

みなさんもテストコードをいっぱい書いてプログラムに秩序をもたらしましょう ヾ(〃><)ノ゙

次回予告

きたる2016年11月3日(木・文化の日)にPHPカンファレンス2016にてPhanを使ったPHPの静的解析手法についてお話しいたします。

phpcon.php.gr.jp