PHP
PHPUnit

PHPUnitで静的(static)メソッドのモック

More than 3 years have passed since last update.

次のような静的メソッドを持つクラスがあったとします。

<?php

class StaticMethods
{
public static function func()
{
return "this is static method";
}
}

テスト対象のコードで StaticMethods::func() などとクラス名がハードコードされているとユニットテストが困難です。

.

次のようにすれば静的メソッド(を持つクラス)をモック(スタブ)にすることができます(パーシャルモックはできません)。

<?php

class StaticMethodsTest extends \PHPUnit_Framework_TestCase
{
/**
* @param string $class
* @param array $methods
* @return \PHPUnit_Framework_MockObject_MockObject
*/

protected function getStaticMock($class, array $methods = array())
{
$arr = explode("\\", trim($class, '\\'));
$origShortName = array_pop($arr);
$origNamespace = implode("\\", $arr);

$dummyNamespace = trim('__DUMMY__\\' . $origNamespace, '\\');
$dummyShortName = $origShortName;
$dummyClassName = "$dummyNamespace\\$dummyShortName";

if (class_exists($dummyClassName, false) == false) {
$code = [];
$code[] = "namespace $dummyNamespace;";
$code[] = "class $dummyShortName {";
foreach ($methods as $method) {
$code[] = "public static function $method(){}";
}
$code[] = "}";
eval(implode("\n", $code));
}

$mock = $this->getMock($dummyClassName, $methods);
class_alias(get_class($mock), $class);
return $mock;
}

public function test()
{
$mock = $this->getStaticMock('StaticMethods', ['func']);
$mock->staticExpects($this->any())
->method('func')
->will($this->returnValue("this is mock value"))
;

$this->assertEquals("this is mock value", StaticMethods::func());
}
}

StaticMethods クラスがテストの実行時にはまだ未定義であることを前提に getStaticMock() の第2引数で指定された静的メソッドを持つクラスを適当な名前で定義し、そのクラスのモックを class_alias で本来のクラス名にエイリアスしています。

詳しくないですが Mockery は同じような原理で静的なメソッドをモックにできるらしいです。

なお StaticMethods をモッククラスとして定義するので、別のテストで StaticMethods を呼ぶと意図しない結果になります。例えば、次のようなテストケースだと test1 の実行時に StaticMethods がモックとして定義されるので test2 のテストは通りません。

<?php

class StaticMethodsTest extends \PHPUnit_Framework_TestCase
{
// ...

public function test1()
{
$mock = $this->getStaticMock('StaticMethods', ['func']);
$mock->staticExpects($this->any())
->method('func')
->will($this->returnValue("this is mock value"))
;

$this->assertEquals("this is mock value", StaticMethods::func());
}

public function test2()
{
$this->assertEquals("this is static method", StaticMethods::func());
// Failed asserting that null matches expected 'this is static method'.
}
}

@runInSeparateProcess アノテーションを使えば test1 は別プロセスになるので回避できます。

<?php

class StaticMethodsTest extends \PHPUnit_Framework_TestCase
{
// ...

/**
* @runInSeparateProcess
*/

public function test1()
{
$mock = $this->getStaticMock('StaticMethods', ['func']);
$mock->staticExpects($this->any())
->method('func')
->will($this->returnValue("this is mock value"))
;

$this->assertEquals("this is mock value", StaticMethods::func());
}

public function test2()
{
$this->assertEquals("this is static method", StaticMethods::func());
}
}

がしかし @runInSeparateProcess アノテーションで別プロセスにしても、テストの実行前に StaticMethods が定義されているとモックの作成時にクラスの多重定義になってコケます。例えば次のようなケースでは test0 で StaticMethods の本来のものが定義されるのでテストが通りません。

<?php

class StaticMethodsTest extends \PHPUnit_Framework_TestCase
{
// ...

public function test0()
{
$this->assertEquals("this is static method", StaticMethods::func());
}

/**
* @runInSeparateProcess
*/

public function test1()
{
$mock = $this->getStaticMock('StaticMethods', ['func']);
// Cannot redeclare class StaticMethods

$mock->staticExpects($this->any())
->method('func')
->will($this->returnValue("this is mock value"))
;

$this->assertEquals("this is mock value", StaticMethods::func());
}
}

これは StaticMethods をロードするすべてのテストに @runInSeparateProcess アノテーションを付ければ解決します。

<?php

class StaticMethodsTest extends \PHPUnit_Framework_TestCase
{
// ...

/**
* @runInSeparateProcess
*/

public function test0()
{
$this->assertEquals("this is static method", StaticMethods::func());
}

/**
* @runInSeparateProcess
*/

public function test1()
{
$mock = $this->getStaticMock('StaticMethods', ['func']);
$mock->staticExpects($this->any())
->method('func')
->will($this->returnValue("this is mock value"))
;

$this->assertEquals("this is mock value", StaticMethods::func());
}
}

あるいは @preserveGlobalState disabled とアノテーションを書いておけば @runInSeparateProcess分離プロセスでテストを実行するときに元のプロセスで読まれていたスクリプトを分離プロセスでも自動的に読む という機能が無効になるので解決します。

<?php

class StaticMethodsTest extends \PHPUnit_Framework_TestCase
{
// ...

public function test0()
{
$this->assertEquals("this is static method", StaticMethods::func());
}

/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/

public function test1()
{
$mock = $this->getStaticMock('StaticMethods', ['func']);
$mock->staticExpects($this->any())
->method('func')
->will($this->returnValue("this is mock value"))
;

$this->assertEquals("this is mock value", StaticMethods::func());
}
}

ただし PHPUnit の bootstrap で指定したスクリプトも読まれなくなるので、bootstrap で require_once 'PHPUnit/Framework/Assert/Functions.php' などとしてアサーションで $this を付けなくても良いようにしていたりする場合はテストの中で読む必要があります。

<?php

class StaticMethodsTest extends \PHPUnit_Framework_TestCase
{
// ...

public function test0()
{
$this->assertEquals("this is static method", StaticMethods::func());
}

/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/

public function test1()
{
require_once 'PHPUnit/Framework/Assert/Functions.php';

$mock = $this->getStaticMock('StaticMethods', ['func']);
$mock->staticExpects(any())
->method('func')
->will(returnValue("this is mock value"))
;

assertEquals("this is mock value", StaticMethods::func());
}
}