17
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.

PHPAdvent Calendar 2018

Day 18

PHP でのユニットテスト用に protected や private を一撃で引っ剥がすやつを作った

Last updated at Posted at 2018-12-18

もう Reflection なんて要らない。 reveal() の一撃で終わらせましょう。

<?php

use function Wazly\Revelation\reveal;

$obj = new class {
    private function do() {
        return 'プライベートメソッドにアクセスできちゃった!';
    }
};

echo reveal($obj)->do(); // "プライベートメソッドにアクセスできちゃった!"

ユニットテストのつらみ

PHPUnit を使ってユニットテストを書いていてつらいのが public でないメソッドを単体でテストするときです。テストを実行するオブジェクトのクラススコープとテスト対象オブジェクトのクラススコープが異なるため、 protected あるいは private で定義されたメソッドやプロパティにアクセスできないのです。それを解決する方法は、サンプルコードとともにネット上にたくさん転がっているので、本稿では特に説明しません。

汎用パッケージ化

よく紹介されている ReflectionClosure を用いたサンプルコードは醜いです。しかし、それ以外に方法はありません。ただ、もっと簡単かつ汎用的に扱えるモノが欲しかったので冒頭例のように超簡単に non-public を擬似的に public にするツールを作りました。

使い方

ここからは使い方を紹介します。

サポートする PHP バージョン

  • 7.1 以上

インストール

Composer を使ってください。テストでしか使わないと思うので require --dev で。

composer require --dev knj/revelation

Revelation オブジェクトを作る

private や protected をアクセス可能にしたいオブジェクトを Revelation オブジェクト (以下、 Revelation) によってラップします。実にこれだけです。例えば次のような Stuff クラスがあるとしましょう。

<?php

class Stuff
{
    private $privateProperty;

    // $privateProperty に $a + $b をセット
    public function __construct($a, $b)
    {
        $this->privateProperty = $a + $b;
    }

    // $privateProperty に $x + $y をセット
    // 自分自身を返す 
    private function privateMethod($x, $y)
    {
        $this->privateProperty = $x + $y;

        return $this;
    }
}

次のように reveal() を使って、 Stuff オブジェクトの Revelation を作ります。


<?php

use function Wazly\Revelation\reveal;

$stuff = new Stuff(1, 2);
$stuff = reveal($stuff);

これで private なメソッドやプロパティにアクセスできます。


echo $stuff->privateProperty;  // 3
$stuff->privateMethod(1, 100);
echo $stuff->privateProperty;  // 101

Revelation の作り方は他にもいくつかあります。

$stuff = new Stuff(1, 2);

// use function Wazly\Revelation\reveal;
reveal($stuff);
reveal(Stuff::class, 1, 2);
reveal(function ($a, $b) { return new Stuff($a, $b); }, 1, 2);

// use Wazly\Revelation;
Revelation::wrap($stuff);
Revelation::wrap(Stuff::class, 1, 2);
Revelation::wrap(function ($a, $b) { return new Stuff($a, $b); }, 1, 2);

Revelation オブジェクトから素 (もと) のオブジェクトを取り出す

$stuff は Revelation という全く別のオブジェクトに置き換わっています。 Revelation が参照している素のオブジェクトを取り出したいときは getOriginal メソッドを使います。

$stuff = reveal($stuff);
echo get_class($stuff);                // Wazly\Revelation
echo get_class($stuff->getOriginal()); // Stuff

素のオブジェクトに対する参照

同じオブジェクトから作られた Revelation は同じそのオブジェクトに対する参照を持っています。次の例では、 $rev1$rev2 は別々の Revelation オブジェクトですが、参照している元のオブジェクト ( $stuff ) は同じなので、1つの $stuff を共有している状態になっています。

$rev1 = reveal($stuff);
$rev1->privateMethod(1, 2);

$rev2 = reveal($stuff);
$rev2->privateMethod(3, 4);

echo $rev1->privateProperty; // 7 not 3
echo $rev2->privateProperty; // 7

このようなふるまいを回避したい場合には、 Revelation::clone を使います1

$rev1 = reveal($stuff);
$rev1->privateMethod(1, 2);

$rev2 = Revelation::clone($stuff);
$rev2->privateMethod(3, 4);

echo $rev1->privateProperty; // 3
echo $rev2->privateProperty; // 7

メソッドチェーン

Revelation ラップされたオブジェクトのメソッドが return $this をするとき、この $this はラップしている Revelation に上書きされます。これによって public でないメソッドを連鎖させることができます。

reveal($stuff)->privateMethod(1, 2)->privateMethod(3, 4);

static メソッドとプロパティ

public でない static メソッドやプロパティは (public なそれらと異なり) インスタンス化していないとアクセスできないので、インスタンスを Revelation ラップしてから Revelation::getStaticRevelation::callStatic を使ってアクセスします。

class A
{
    protected static $staticProperty = 'static';

    protected static function className()
    {
        return __CLASS__;
    }

    protected static function selfName()
    {
        return self::className();
    }

    protected static function staticName()
    {
        return static::className();
    }
}

class B extends A
{
    protected static function className()
    {
        return __CLASS__;
    }
}

echo reveal(B::class)->getStatic('staticProperty'); // static
echo reveal(B::class)->callStatic('className');     // B
echo reveal(B::class)->callStatic('selfName');      // A
echo reveal(B::class)->callStatic('staticName');    // B

どういう仕組み?

擬似的な public 化には Closure::bindTo しか使ってません。Closure::bindTo とはなんぞや?という人は公式リファレンスへGO!簡単に説明すると、クロージャ内の $this に任意のオブジェクトを指定し、実行時のクラススコープも変更できてしまうものです。これを使ってメソッドを実行するクロージャを作り、スコープを合わせてしまえば private でも呼び出せてしまうということです。

Revelation によるラップの仕組みは PHPでメソッドの挙動を柔軟に変更するプロキシ設計 を参照してください。

追記

Twitter で空リプをいただいたような気がするので。

確かにエラーになってしまいます。

Fatal error: Method Wazly\Revelation::__call() cannot take arguments by reference in ...

ケースとしてはそこまで多くないと思うので、専用のメソッドを用意して対応しようかと思います。ありがとうございました。

追記2

バインドされたオブジェクトを持つクロージャを返すAPI Revelation::bind を追加しました。これによって、少し遠回りで冗長になりますが、リファレンスを渡すメソッドも扱えるようになります。


class X
{
    private function callPassByReferenceMethod($val, &$ref)
    {
        static::passByReference($val, $ref);
    }

    private static function passByReference($val, &$ref)
    {
        $ref = $ref ?? 1;
        $ref += $val;
    }
}

$closure1 = reveal(X::class)->bind(function ($val, &$ref) {
    $this->callPassByReferenceMethod($val, $ref);
});

$closure2 = reveal(X::class)->bind(function ($val, &$ref) {
    static::passByReference($val, $ref);
});

$closure1(99, $ref);
echo $ref; // 100
$closure2(100, $ref);
echo $ref; // 200

参考

  1. 内部で clone を使っているので __clone() がコールされます。

17
8
0

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
17
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?