PHPユーザーであれば、PHPが標準で持つ多くの内部(ビルトイン)関数や定数には日常的にお世話になっていることでしょう。これらの内部関数・定数はPHPの便利さの象徴といえます。しかし、内部関数や定数の使用はテストのしやすさを低下させる原因となります。以下のコードを見てみましょう。
<?php
...
class CollectingType
{
protected $type;
protected $expectedSuperTypes = array();
...
public function isTest()
{
if (in_array($this->type, $this->expectedSuperTypes)) {
return false;
} else {
foreach ($this->expectedSuperTypes as $expectedSuperType) {
if (is_subclass_of($this->type, $expectedSuperType)) {
return true;
}
}
return false;
}
}
...
isTest()は与えられた型($typeフィールドの値)が使用中のテスティングフレームワークのテストにあたるかどうかを判定するメソッドです。判定には内部関数であるis_subclass_of()を使用しています。isTest()メソッドをテストする場合、is_subclass_of()関数の結果を変更するために本物の型を引数として使用する必要があります。これには実際に複数のクラスが必要となるため準備に手間がかかります。また、ここでテストしたいのはisTest()メソッドのロジックであってis_subclass_of()関数が正しく動作することではありません。
では、どうすればいいのでしょうか?1つの方法はis_subclass_of()関数の呼び出し部分をメソッドとして抽出し、そのメソッドの呼び出しをモックに置き換えることです。この場合プロダクションコードは以下のようになります。
<?php
...
class CollectingType
{
...
public function isTest()
{
if (in_array($this->type, $this->expectedSuperTypes)) {
return false;
} else {
foreach ($this->expectedSuperTypes as $expectedSuperType) {
if ($this->isSubTypeOfExpectedSuperType($this->type, $expectedSuperType)) {
return true;
}
}
return false;
}
}
...
protected function isSubTypeOfExpectedSuperType($type, $expectedSuperType)
{
return is_subclass_of($type, $expectedSuperType);
}
...
モッキングフレームワークPhakeを使った場合のテストコードは以下のようになります。
<?php
...
class CollectingTypeTest extends \PHPUnit_Framework_TestCase
{
public function typeToResultMap()
{
return array(
array('PHPSpec\Context', true, false),
array('DescribePHPSpecPass', true, true),
array('Foo', false, false),
);
}
/**
* @test
* @dataProvider typeToResultMap
*/
public function 型がテストかどうかをチェックする($type, $isSubTypeOfExpectedType, $isTest)
{
$collectingType = \Phake::partialMock(
'\Stagehand\TestRunner\Collector\CollectingType',
$type,
array('PHPSpec\Specification\ExampleGroup', 'PHPSpec\Context')
);
\Phake::when($collectingType)
->isSubTypeOfExpectedSuperType($this->anything(), $this->anything())
->thenReturn($isSubTypeOfExpectedType);
$this->assertThat($collectingType->isTest(), $this->equalTo($isTest));
}
...
このように関数の呼び出しをメソッドの呼び出しに置き換えることで、部分的にモックが適用されたオブジェクト(パーシャルモック)を使ったテストが可能になります。
この方法の問題点はテストの都合によってプロダクションコードの変更が必要になることです。例え同じ関数であったとしても、クラスが異なれば再度同じことを行う必要があります。加えてパーシャルモックはDIコンテナとの統合が難しいため、テストコードにオブジェクトの構成の知識が流出することになります。
関数のラッパーオブジェクト(レガシープロキシー)を導入する
もう1つの方法は関数のラッパーオブジェクト(私はレガシープロキシーと呼んでいます。)を導入することです。最初に以下のようなクラスを用意します。メソッド名、引数は置換対象の関数と同じにしておきます。
<?php
...
class LegacyProxy
{
...
public function is_subclass_of($object, $class_name)
{
return is_subclass_of($object, $class_name);
}
...
次にLegacyProxyオブジェクトを使ってプロダクションコードを書き換えます。
<?php
...
class CollectingType
{
...
protected $legacyProxy;
...
public function setLegacyProxy(LegacyProxy $legacyProxy)
{
$this->legacyProxy = $legacyProxy;
}
...
public function isTest()
{
if (in_array($this->type, $this->requiredSuperTypes)) {
return false;
} else {
foreach ($this->requiredSuperTypes as $expectedSuperType) {
if ($this->legacyProxy->is_subclass_of($this->type, $expectedSuperType)) {
return true;
}
}
return false;
}
}
...
最後にテストコードを書き換えます。先ほどのパーシャルモックの代わりにLegacyProxyオブジェクトをモックオブジェクトで置き換えます。
<?php
...
Class CollectingTypeTest extends \PHPUnit_Framework_TestCase
{
/**
* @test
* @dataProvider typeToResultMap
*/
public function 型がテストかどうかをチェックする($type, $isSubTypeOfExpectedType, $isTest)
{
$legacyProxy = \Phake::mock('Stagehand\TestRunner\Util\LegacyProxy');
\Phake::when($legacyProxy)
->is_subclass_of($this->anything(), $this->anything())
->thenReturn($isSubTypeOfRequiredSuperType);
$collectingType = new CollectingType(
$type,
array('PHPSpec\Specification\ExampleGroup', 'PHPSpec\Context')
);
$collectingType->setLegacyProxy($legacyProxy);
$this->assertThat($collectingType->isTest(), $this->equalTo($isTest));
}
...
この方法では、プロダクションコードへの新たなメソッドの追加は不要になり、LegacyProxyクラスに定義された関数は再利用されることになります。また、DIコンテナとの統合も問題ありません。
定数をレガシープロキシーで置き換える
関数と同じように定数もレガシープロキシーで置き換えることができます。最初にLegacyProxyクラスに以下のようなメソッドを追加します。メソッド名は定数と同じにしておきます。
<?php
...
class LegacyProxy
{
...
public function PHP_OS()
{
return PHP_OS;
}
...
次にプロダクションコードを書き換えます。
<?php
...
class OS
{
...
protected $legacyProxy;
...
public function setLegacyProxy(LegacyProxy $legacyProxy)
{
$this->legacyProxy = $legacyProxy;
}
...
public function isWin()
{
return strtolower(substr($this->legacyProxy->PHP_OS(), 0, strlen('win'))) == 'win';
}
...
おわりに
レガシープロキシーを導入することで関数や定数を使ったコードのテスト容易性(testability)を高めることができます。関数・定数が多用された古いコードの改善においても大いに役立つことでしょう。
参考