10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

private,protectedなメソッドを呼び出してテストする(新)

Last updated at Posted at 2018-08-12

4年ほど前に書いた記事1の内容を書き直したもの。

問題

ユニットテストでprivatepublicなメソッドをテストしたいとします。

@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']) これは動きません。

実装

適当にコピペしてください。

PrivateCaller.php
<?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);
        };
    }
}

なぜinvokeMethodinvokeMethodArgsが分かれてるの?

可変長引数ではリファレンスとの相互運用に難があるからです。

以下のようにリファレンスを使った機能はPrivateCaller::invokeMethod()では呼べません。

Preg.php
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しておいた方が楽でいいよね派です。

さて、そのような価値観で書くとこうなります。

TestCase.php
<?php

namespace YourApp;

abstract class TestCase extends \PHPUnit\Framework\TestCase
{
    use PrivateCaller;

    // そのほかの処理を書く
}
HogeTest.php
<?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の部分をちょっと直します。

TestCase.php
abstract class TestCase extends \PHPUnit\Framework\TestCase
{
    use PrivateCaller {
        invokeMethod as call;
        invokeMethodArgs as callA;
    }
}

callとかcallAとか書いてる部分は好きな名前に直してください。
トレイトって最高便利ですよね?

あとがき

みなさまはトレイトをクラスでuseしなくてもそのまま呼び出せるって知ってましたか?

おまけ

なぜ今回のコードでは型宣言にcallableと書かなかったのかは読者への課題ry

脚注

  1. private,protectedなメソッドを呼び出してテストする - Qiita

10
8
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?