CodeceptionとAspectMockを使って和田卓人さんの現在時刻に依存するテストを書いてみた

TLで話題になっているCodeIQでの問題と解説はこちらです。

これであなたもテスト駆動開発マスター!?和田卓人さんがテスト駆動開発問題を解答コード使いながら解説します~現在時刻が関わるテストから、テスト容易性設計を学ぶ #tdd|CodeIQ MAGAZINE

大変丁寧な解説、とても参考になりました。

ただ、参加されている方の採用したテスティングフレームワークにCodeceptionがない!
日本のPHP開発者にCodeceptionを流行らせたいワタクシとしては看過できない事態(=チャンス)です!

ってことで、Codeceptionを使って書いてみました。

といいつつも、今回はCodeceptionがメインではなく、AspectMockがメイン。

AspectMockとは

Codeceptionの開発者の@davertさんによる「ちょーすげー(not an ordinary)」PHP向けのモッキングフレームワーク
内部的にはGo! AOPが提供するアスペクト指向の概念を利用し、

  • スタティックメソッドに対するテストダブルを作成できる
  • あらゆるインスタンスメソッドに対するテストダブルを作成できる
  • 処理中(on the fly)にメソッドを再定義できる
  • 憶えやすいシンプルな構文

を実現しています。

AspectMockの裏側

詳しくは Understanding AspectMock に解説されていますので簡単に。

まず、GO! AOPの仕組みですが、独自のストリームラッパーを利用してincludeやrequireしたファイルをその場で解析、定義されているメソッドを抽出し、横断的関心事をウィービングするようです。
(これをPHPのみで実現しているのはすごいですね。処理重そうな感じしちゃいますが。。。)

AspectMockはこのGO! AOPの仕組みを利用し、全てのメソッドの一番最初に「Stubがあればそれ返してよ!」という処理をブッコミます。

<?php
class User {
    
    function setName($name)
    {
        $this->name = $name;
    }    

これが、、、

<?php
class User {
    
    function setName($name)
    { if (($__am_res = __amock_before($this, __CLASS__, __FUNCTION__, array($name), false)) !== __AM_CONTINUE__) return $__am_res; 
        $this->name = $name;
    }   

こうなる。

アプローチ

AspectMockの仕組み上、PHPの標準関数に対するテストダブルは作成できないので、getHourのようなメソッドを作成し、それに対するテストダブルを作成します。

言語 テスティングフレームワーク アプローチ アプローチ詳細
PHP5.4 Codeception, AspectMock 時刻ライブラリに介入とまではいかないけどむにゃむにゃ... 標準関数のラッパーをAspectMockで stub

といったところでしょうか.....

準備

1. Composer

以下の内容でcomposer.jsonを用意してphp composer.phar installします。

{
    "require-dev": {
        "codeception/codeception": "1.8.*",
        "codeception/aspect-mock": "*"
    },
    "autoload": {
        "psr-0": {"Greeter\\": "src/"}
    },
    "config": {
        "bin-dir": "bin"
    }
}

2. Codeception、AspectMockの初期化

bin/codecept bootstrapします。
testsディレクトリ配下に必要なファイル群が作成されますので、tests/_bootstrap.phpを以下のように編集し、AspectMockを初期化します。

<?php
// This is global bootstrap for autoloading
$kernel = AspectMock\Kernel::getInstance();
$kernel->init();

3. クリーンアップ処理の定義

和田卓人さんも「組み込みクラス/メソッド/関数に介入する」アプローチの注意点として、以下のようにお書きでした。

このアプローチのデメリットは、強力さの裏返しです。力には責任が伴います。組み込みクラス/メソッド/関数の動きを変えるというのは、影響の大きい変更です。
(中略)
ゆえに、このアプローチを使う場合には、テスト後の始末を忘れずに行うことが重要です。テストの中で振る舞いを変えた標準クラス/メソッド/関数を元に戻しておかないと、他のテストにも影響が残ります。

tests/_helpers/CodeHelper.phpに後始末を以下のように記述します。

<?php
namespace Codeception\Module;

// here you can define custom functions for CodeGuy 

class CodeHelper extends \Codeception\Module
{
    public function _after(\Codeception\TestCase $test)
    {
        \AspectMock\Test::clean();
    }
}

4. Unitテストの作成

bin/codecept genereate:test unit Greeterを実行し、Greeterクラスに対する単体テストtests/unit/GreeterTest.phpに作成します。


これで準備完了です。

Greeter

src/Greeter/Greeter.phpを以下のように実装しました。

<?php
namespace Greeter;

class Greeter
{
    public function greet()
    {
        $hour = self::getHour();
        if (5 <= $hour && $hour < 12) {
            return 'おはようございます';
        }
        if (12 <= $hour && $hour < 18) {
            return 'こんにちは';
        }
        return 'こんばんは';
    }

    private static function getHour()
    {
        return date("G", time());
    }
} 

getHourは標準関数timeを使い、時間に変換して返します。
ここをAspectMockでstubすれば、テスタブルになります。

GreeterTest

tests/unit/GreeterTest.phpに以下のようにテストを記述しました。

<?php
use AspectMock\Test as test;

class GreeterTest extends \Codeception\TestCase\Test
{
    protected $codeGuy;

    /**
     * @dataProvider greetProvider
     */
    public function testGreet($hour, $message)
    {
        test::double('\Greeter\Greeter', ['getHour' => $hour]);
        $greeter = new \Greeter\Greeter();
        $this->assertEquals($message, $greeter->greet());
    }

    public function greetProvider()
    {
        return array(
            array(4, 'こんばんは'),
            array(5, 'おはようございます'),
            array(6, 'おはようございます'),
            array(11, 'おはようございます'),
            array(12, 'こんにちは'),
            array(13, 'こんにちは'),
            array(17, 'こんにちは'),
            array(18, 'こんばんは'),
            array(19, 'こんばんは'),
        );
    }
}

[ポイント1] CodeceptionのUnitテストではPHPUnitのメソッドを利用できる!

\Codeception\TestCase\TestPHPUnit_Framework_TestCaseを継承しているため、
$this->assertEquals($greet, $greeter->greet());等、普通に使えます。
もう、さっさとPHPUnitからCodeceptionに乗り換えましょう。

[ポイント2] パラメタライズドテスト(Parameterized Test)

和田卓人さんも言及していた、PHPUnitデータプロバイダ、Codeceptionのバージョン1.8から利用できるようになりました!
だから、さっさとPHPUnitからCodeceptionに乗り換えましょう。

[ポイント3] テストダブル

今回の本命AspectMockを使い、

test::double('\Greeter\Greeter', ['getHour' => $hour]);

で、GreeterクラスのgetHourメソッドを stub してます。

実際のテスト結果

bin/codecept run unitします。

Codeception PHP Testing Framework v1.8.0
Powered by PHPUnit 3.7.28 by Sebastian Bergmann.

Unit Tests (1) ------------------------------------------------------
Trying to test greet with data set #0 (GreeterTest::testGreet)  Ok
Trying to test greet with data set #1 (GreeterTest::testGreet)  Ok
Trying to test greet with data set #2 (GreeterTest::testGreet)  Ok
Trying to test greet with data set #3 (GreeterTest::testGreet)  Ok
Trying to test greet with data set #4 (GreeterTest::testGreet)  Ok
Trying to test greet with data set #5 (GreeterTest::testGreet)  Ok
Trying to test greet with data set #6 (GreeterTest::testGreet)  Ok
Trying to test greet with data set #7 (GreeterTest::testGreet)  Ok
Trying to test greet with data set #8 (GreeterTest::testGreet)  Ok
---------------------------------------------------------------------


Time: 11.3 seconds, Memory: 44.75Mb

OK (9 tests, 9 assertions)

パスしました。

最後に

  • AspectMock、初めて使用しましたが「テストできぬものなどない」という感じにさせてくれますね。
  • Codeceptionとの相性も、同じ開発者だけあってバッチリそうです。
  • 今回は「現在時刻」という課題でしたが、開発者の手の届かない外部APIを利用している箇所のテスト等、より簡単に書けそうですね。
  • はじめに書きましたが和田卓人さんの記事はコード付かつとても丁寧な解説で、言語問わず読む価値ありです。
  • 今回のソース一式はGitHubにあります。