4年ほど前に書いた記事1の内容を書き直したもの。
問題
ユニットテストでprivate
やpublic
なメソッドをテストしたいとします。
@t_wadaさんがプライベートメソッドのユニットテストは書かないもの?に書いてる通りデメリットがある行為だと把握しつつ、ここでは「書く」ことにしよう。
使用方法
今回は雑にテストを書いていく。
<?php
class Hoge
{
public static function foo() { return __METHOD__; }
private static function bar() { return __METHOD__; }
}
assert('Hoge::foo' === Hoge::foo());
assert('Hoge::bar' === Hoge::foo()); // ここで Errorが発生
// Call to private method Hoge::bar()
echo 'ok', PHP_EOL;
ここで、このあと紹介するPrivateCaller
を使って、こうする。
assert('Hoge::foo' === Hoge::foo());
assert('Hoge::bar' === PrivateCaller::invokeMethod("Hoge::bar"));
echo 'ok', PHP_EOL;
これでエラーが出ずに実行できた。やりましたね。!
引数をとる関数の場合は以下のようにします。
$actual = PrivateCaller::invokeMethod("Cat::nyan", 'arg1', 'arg2', 'arg3');
$args = ['arg1', 'arg2', 'arg3'];
$actual = PrivateCaller::invokeMethodArgs("Cat::nyan", $args);
PrivateCaller::invokeMethod("Cat::nyan", ...$args)
とも書けそうですが、これは別の機能です。
PrivateCaller::invokeMethodArgs("Cat::nyan", ['arg1', 'arg2', 'arg3'])
これは動きません。
実装
適当にコピペしてください。
<?php /* tadsan@pixiv.com | license WTFPL */
trait PrivateCaller
{
/**
* アクセス権限のないメソッドを起動する
*
* @param callable|string $class_method
* @phan-param array{0:object|string,1:string}|string
*/
public static function invokeMethod($class_method, ...$args)
{
if (\is_array($class_method)) {
[$class, $method] = $class_method;
} elseif (\is_string($class_method) && \strpos($class_method, '::') !== false) {
[$class, $method] = \explode('::', $class_method, 2);
} else {
throw new \DomainException('$class_method is not method');
}
$ref = new \ReflectionMethod($class, $method);
$ref->setAccessible(true);
return $ref->invokeArgs(is_object($class) ? $class : null, $args);
}
/**
* アクセス権限のないメソッドを配列変数のリファレンスを渡して起動する
*
* @param callable|string $class_method
* @phan-param array{0:object|string,1:string}|string
*/
public static function invokeMethodArgs($class_method, array &$args)
{
if (\is_array($class_method)) {
[$class, $method] = $class_method;
} elseif (\is_string($class_method) && \strpos($class_method, '::') !== false) {
[$class, $method] = \explode('::', $class_method, 2);
} else {
throw new \DomainException('$class_method is not method');
}
$ref = new \ReflectionMethod($class, $method);
$ref->setAccessible(true);
return $ref->invokeArgs(is_object($class) ? $class : null, $args);
}
/**
* アクセス権限のないメソッドを起動できるクロージャを返す
*
* @param callable|string $class_method
* @phan-param array{0:object|string,1:string}|string
*/
public static function getCallable($class_method): \Closure
{
if (\is_array($class_method)) {
[$class, $method] = $class_method;
} elseif (\is_string($class_method) && \strpos($class_method, '::') !== false) {
[$class, $method] = \explode('::', $class_method, 2);
} else {
throw new \DomainException('$class_method is not method');
}
$ref = new \ReflectionMethod($class, $method);
$obj = is_object($class) ? $class : null;
$ref->setAccessible(true);
return function (array &$args = []) use ($ref, $obj) {
return $ref->invokeArgs($obj, $args);
};
}
}
なぜinvokeMethod
とinvokeMethodArgs
が分かれてるの?
可変長引数ではリファレンスとの相互運用に難があるからです。
以下のようにリファレンスを使った機能はPrivateCaller::invokeMethod()
では呼べません。
class Preg
{
private static function match(string $pattern, string $subject, array &$matches = null, int $flags = 0, int $offset = 0)
{
return preg_match($pattern, $subject, $matches, $flags, $offset);
}
}
// これはだめ (リファレンスが通らない)PrivateCaller::invokeMethod('Preg::match', $pattern, $subject, $n);
// これなら呼べる
$args = ['/\A#(.*)#\z/', '#xxxx#'];
$m = null;
$args[2] = &$m;
PrivateCaller::invokeMethodArgs('Preg::match', $args);
// var_dump($m);
// array(2) {
// [0]=>
// string(6) "#xxxx#"
// [1]=>
// string(4) "xxxx"
// }
どうしてこれなら動くのか知りたいひとはPHPのリファレンス(参照&)の傾向と対策、あるいはさよなら - Qiitaを読んでください。
さらについでになのですが、リファレンスをとるために以下のように仕様を変更することは可能です。
public static function invokeMethod($class_method, &...$args)
↑このように仕様変更すると、どのような影響が出るかは読者への課題とします。
PHPUnitから使用したい場合
トレイトなので、クラスから利用できます。これは好みの問題ですが個別のテストケースでは\PHPUnit\Framework\TestCase
は直接継承せず、プロジェクトごと、あるいはテストの文脈ごとに共通のabstract class
を用意するのがおすすめです。
その共通クラスでべんりユーティリティをくっつけておくかどうかはまた議論が分かれるところですが、私はユーティリティも個別のテストケースクラスでは追加せず、共通クラスでuse
しておいた方が楽でいいよね派です。
さて、そのような価値観で書くとこうなります。
<?php
namespace YourApp;
abstract class TestCase extends \PHPUnit\Framework\TestCase
{
use PrivateCaller;
// そのほかの処理を書く
}
<?php
namespace YourApp;
abstract class TestCase extends TestCase
{
private $hoge;
public function setup()
{
$this->hoge = new Hoge();
}
public function test()
{
$actual = $this->invokeMethod([$this->hoge, 'piyo'], 'arg1', 'arg2');
$this->assertEquals('expected', $actual);
}
}
invokeMethod
なんて長ったらしい? それならuse
の部分をちょっと直します。
abstract class TestCase extends \PHPUnit\Framework\TestCase
{
use PrivateCaller {
invokeMethod as call;
invokeMethodArgs as callA;
}
}
call
とかcallA
とか書いてる部分は好きな名前に直してください。
トレイトって最高便利ですよね?
あとがき
みなさまはトレイトをクラスでuse
しなくてもそのまま呼び出せるって知ってましたか?
おまけ
なぜ今回のコードでは型宣言にcallable
と書かなかったのかは読者への課題ry