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 からソースを持ってきたら、ビルドも通るしセグりもしませんでした。
$ 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
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
class Sample
{
public function func($time)
{
return $time - time();
}
}
Sample::func($time)
を呼ぶと現在日時を引き算して返します。
テストコードその1
<?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)
テストコードその2
<?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
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
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
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
に追加したプロパティは、テストがすべて完了するまで破棄されません。
なので、プロパティに DummyTimer のインスタンスをもたせると PHPUnit の終了までデストラクタが呼ばれません。
DummyTimer をメソッドの引数に渡してはならない
あまりやる意味は無いですが、次のように DummyTimer
のインスタンスをメソッドの引数に渡さないほうが良いです。
<?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
のインスタンスが保持されてしまいます。
そしてテストメソッドを突き抜けた例外は PHPUnit によって保持されるので、意図したとおりデストラクタが呼ばれなくなります。
DummyTimer のあるスコープで PHP エラーを発生させてはならない
次のように DummyTimer
のインスタンスが存在するスコープで 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()
の第5引数にエラーが発生した箇所のスコープのすべての変数が含まれています。なので、PHPUnit が飛ばす例外のスタックトレースに DummyTimer
のインスタンスが保持されてデストラクタが呼ばれなくなります。
さいごに
いろいろ注意点もありますが、例外がテストメソッドを突き抜けるのはテストがコケたときがほとんどなので、あまり気にしてはいません。
あるいは、どうせテストでしか使わないので tearDown()
で元に戻してやるだけの方が良かったかもしれません。