runkit で time() 関数をアレするやつ

PHP の暗黒面に堕ちました。

.

.

.

`time()` 関数をベタ書きしているコードをテストするために `sleep()` して `time()` 関数の戻り値を無理やり制御している箇所があり、無駄にテストの実行時間が増えていたのでこれをどうにかするために CI 環境に runkit をインストールしました。誰だこんなコード書いたのは、俺か。

## runkit のインストール

PHP は CentOS 6 の PHP-5.3.3 なのですが、epel から `yum install  php-pecl-runkit` で入れるとセグりまくりました。

`pecl install runkit-0.9` で入れようとすると今度はビルドが通りませんでした。

GitHub からソースを持ってきたら、ビルドも通るしセグりもしませんでした。

```console
$ git clone https://github.com/zenovich/runkit.git
$ cd runkit
$ git checkout 1.0.2
$ phpize
$ ./configure
$ make
$ sudo make install
$ cat <<EOS | sudo tee /etc/php.d/runkit.ini
extension = runkit.so
runkit.internal_override = On
EOS
```

## `runkit_function_rename()` が機能しない？

`runkit_function_rename()` で `time()` をリネームした先の関数を呼ぶと謎な Fatal error になりました。

```php
<?php
runkit_function_rename('time', 'runkit_orig_time');
var_dump(runkit_orig_time());
```

```
$ php z.php
PHP Fatal error:  Non-static method (null)::[]1\() cannot be called statically in /home/ore/x.php on line 3
```

`runkit_function_rename()` 以外は普通に動いているので `runkit_function_rename()` を使わずにどうにかすることにします。

都合の良いことにプロダクションコードで `gettimeofday()` は一切使っていなかったので `(int)gettimeofday(true)` でオリジナルの `time()` の代わりになるでしょう。

## テスト対象のコード

次のコードをテストします。

```php
<?php
class Sample
{
    public function func($time)
    {
        return $time - time();
    }
}
```

`Sample::func($time)` を呼ぶと現在日時を引き算して返します。

## テストコードその１

```php
<?php
class SampleTest extends PHPUnit_Framework_TestCase
{
    public function test()
    {
        runkit_function_remove('time');
        function time() {
            return 100;
        }

        $obj = new Sample();
        $this->assertEquals(23, $obj->func(123));
    }
}
```

これは良くありません。このテストを実行した後も `time()` 関数が固定値を返し続けてしまうので他のテストに影響があるかもしれません。

また PHPUnit もわけのわからない値を実行時間として報告してきます。

```
$ vendor/bin/phpunit SampleTest.php 
PHPUnit 3.6.12 by Sebastian Bergmann.

.

Time: -1410525728 seconds, Memory: 5.00Mb

OK (1 test, 1 assertion)
```

## テストコードその２

```php
<?php
class SampleTest extends PHPUnit_Framework_TestCase
{
    public function test()
    {
        runkit_function_remove('time');
        function time() {
            return 100;
        }

        try {
            $obj = new Sample();
            $this->assertEquals(23, $obj->func(123));

            runkit_function_remove('time');
            function time() {
                return (int)gettimeofday(true);
            }

        } catch (Exception $ex) {
            runkit_function_remove('time');
            function time() {
                return (int)gettimeofday(true);
            }
            throw $ex;
        }
    }
}
```

テストの実行後に `time()` 関数を元の動作に戻してやるべきでしょう。

```
$ vendor/bin/phpunit SampleTest.php 
PHPUnit 3.6.12 by Sebastian Bergmann.

.

Time: 0 seconds, Memory: 5.00Mb

OK (1 test, 1 assertion)
```

## もうちょっとやりやすくする

さすがにこれはしんどいので、もうちょっとやりやすくするために次のようなクラスを作りました。

```php
<?php
class DummyTimer
{
    public static $timeStack = array();

    public static function initRunKit()
    {
        static $first = true;

        if ($first) {
            $first = false;

            if (function_exists('runkit_function_redefine') == false) {
                throw new LogicException("Should be load runkit extension");
            }

            runkit_function_redefine('time', '', 'return ' . __CLASS__ . '::time();');
        }
    }

    public function __construct()
    {
        self::initRunKit();
    }

    public function __destruct()
    {
        $this->restore();
    }

    public static function time()
    {
        if (count(self::$timeStack)) {
            return end(self::$timeStack);
        } else {
            return (int)gettimeofday(true);
        }
    }

    public function set($time)
    {
        unset(self::$timeStack[spl_object_hash($this)]);
        self::$timeStack[spl_object_hash($this)] = $time;
    }

    public function restore()
    {
        unset(self::$timeStack[spl_object_hash($this)]);
    }
}
```

次のようにテストを書くことが出来ます。

```php
<?php
class SampleTest extends PHPUnit_Framework_TestCase
{
    public function test()
    {
        $timer = new DummyTimer();
        $timer->set(100);

        $obj = new Sample();
        $this->assertEquals(23, $obj->func(123));
    }
}
```

`DummyTimer::set($time)` で `time()` 関数の戻り値を指定します。

`DummyTimer` がスコープから消えるとデストラクタで `time()` 関数の戻り値が元に戻ります。

が、幾つか注意する点があります。

### テストケースのプロパティに DummyTimer を持たせてはならない

あまりやる意味は無いですが、次のように `PHPUnit_Framework_TestCase` のプロパティに `DummyTimer` のインスタンスを持たしてはいけません。

```php
<?php
class SampleTest extends PHPUnit_Framework_TestCase
{
    public function test()
    {
        $this->timer = new DummyTimer();
        $this->timer->set(100);

        $obj = new Sample();
        $this->assertEquals(23, $obj->func(123));
    }
}
```

`PHPUnit_Framework_TestCase` に追加したプロパティは、テストがすべて完了するまで破棄されません。

 - [PHPUnit の TestCase のメンバはテストが完了するまで解放されない - Qiita](http://qiita.com/ngyuki/items/9ba96efc5d433726fa22 "PHPUnit の TestCase のメンバはテストが完了するまで解放されない - Qiita")

なので、プロパティに DummyTimer のインスタンスをもたせると PHPUnit の終了までデストラクタが呼ばれません。

### DummyTimer をメソッドの引数に渡してはならない

あまりやる意味は無いですが、次のように `DummyTimer` のインスタンスをメソッドの引数に渡さないほうが良いです。

```php
<?php
class SampleTest extends PHPUnit_Framework_TestCase
{
    public function test()
    {
        $timer = new DummyTimer();
        $this->func($timer);
    }

    private function func(DummyTimer $timer)
    {
        $timer->set(100);
        $obj = new Sample();
        $this->assertEquals(12, $obj->func(123));
    }
}
```

メソッドが例外を投げた場合、例外のスタックトレースに `DummyTimer` のインスタンスが保持されてしまいます。

 - [オブジェクトが例外の呼び出し履歴に参照されてデストラクタが呼ばれない - ngの日記](http://ngyuki.hatenablog.com/entry/20121021/p1 "オブジェクトが例外の呼び出し履歴に参照されてデストラクタが呼ばれない - ngの日記")

そしてテストメソッドを突き抜けた例外は PHPUnit によって保持されるので、意図したとおりデストラクタが呼ばれなくなります。

### DummyTimer のあるスコープで PHP エラーを発生させてはならない

次のように `DummyTimer` のインスタンスが存在するスコープで PHP エラーを発生させない方が良いです。

```php
<?php
class SampleTest extends PHPUnit_Framework_TestCase
{
    public function test()
    {
        $timer = new DummyTimer();
        $timer->set(100);
        $obj = new Sample();
        $this->assertEquals(23, $obj->func(123));

        // PHP Notice
        $xxx = $vvv;
    }
}
```

PHPUnit は `set_error_handler()` で PHP エラーを例外に変換しますが、`set_error_handler()` の第５引数にエラーが発生した箇所のスコープのすべての変数が含まれています。なので、PHPUnit が飛ばす例外のスタックトレースに `DummyTimer` のインスタンスが保持されてデストラクタが呼ばれなくなります。

 - [カスタムエラーハンドラ（set_error_handler）から例外を投げるとスコープ内の変数が例外から参照される - ngの日記](http://ngyuki.hatenablog.com/entry/20121022/p1 "カスタムエラーハンドラ（set_error_handler）から例外を投げるとスコープ内の変数が例外から参照される - ngの日記")

## さいごに

いろいろ注意点もありますが、例外がテストメソッドを突き抜けるのはテストがコケたときがほとんどなので、あまり気にしてはいません。

あるいは、どうせテストでしか使わないので `tearDown()` で元に戻してやるだけの方が良かったかもしれません。