はじめに

こんにちは,開発部アルバイトの池口です.

今回は,テストフレームワークの PHPUnit にも標準搭載されている Prophecy において, final なクラスでもモックしてテストを行う方法を紹介します.

想定するケース

あるクラスに対して単体テストを行う際,そのクラスが依存している他のクラスのコードに影響されないよう, モックを作成しておいて何らかの方法で注入する手法がよく使われます. このとき,冒頭で述べた Prophecy のようなライブラリが使われることもあります.

モックを作成するときは,そのクラス,またはそのクラスが実装するインターフェースをもとに作ることがほとんどです. それによって,そのクラスまたはインターフェースを受け付けるメソッドでも問題なく注入できます.

しかし,クラスによっては final 指定によって 継承できないようになっているためにモックが作成できない ことがあります.

以下のようなコードで実際の挙動が確認できます.

<?php // src/ImmockableClass.php

declare(strict_types=1);

namespace MyVendor\MyPackage;

final class ImmockableClass
{
}
<?php // tests/ImmockableClassTest.php

declare(strict_types=1);

namespace MyVendor\MyPackage;

use PHPUnit\Framework\TestCase;

class ImmockableClassTest extends TestCase
{
    public function test(): void
    {
        $this->prophesize(ImmockableClass::class);
    }
}

実際に実行してみると,以下のようなエラーが発生します.

Prophecy\Exception\Doubler\ClassMirrorException : Could not reflect class MyVendor\MyPackage\ImmockableClass as it is marked final.

このように, 基本的に final なクラスはモックできない ことが分かります.

自前のクラスであれば final を削除すれば解決できるかもしれませんが, 何らかの理由で final を削除できない場合や,外部ライブラリを用いている場合 は,このままではテストが行えません.

解決方法

これを解決する方法はいくつか存在します.

dg/bypass-finals ライブラリを用いる方法

New BSD License / GPL License v2, v3 で公開されている dg/bypass-finals を用いると, final なクラスを final でないかのように使用できます.

ただし,これは PHP のファイル読み込みに直接介入するため,他のクラスやコードに影響を与えてしまう可能性 があります.

このライブラリを用いて先述したテストを書き換えると以下のようになります. これを実行すると,アサーションはありませんが例外なくテストが終了します.

<?php // tests/ImmockableClassTest.php

declare(strict_types=1);

namespace MyVendor\MyPackage;

use DG\BypassFinals;
use PHPUnit\Framework\TestCase;

class ImmockableClassTest extends TestCase
{
    protected function setUp()
    {
        BypassFinals::enable();
    }

    public function test(): void
    {
        $this->prophesize(ImmockableClass::class);
    }
}

フェイクのクラスを作成する方法

カルテットで現在主にとっている方法がこちらです.

モックする対象のクラスと 同じメソッド群を実装したクラスをフェイクとして作成 し, それを注入することでモックのような挙動をさせることができます.

<?php // tests/Fake/FakeImmockableClass.php

declare(strict_types=1);

namespace MyVendor\MyPackage;

class FakeImmockableClass
{
}

では,先述したテストを書き換えると以下のようになります.

<?php // tests/ImmockableClassTest.php

declare(strict_types=1);

namespace MyVendor\MyPackage;

use PHPUnit\Framework\TestCase;

class ImmockableClassTest extends TestCase
{
    public function test(): void
    {
        $this->prophesize(FakeImmockableClass::class);
    }
}

ただし,PHP の型システムにおいて,モック対象のクラスとフェイククラスはもちろん互換性がないので, 注入される場所に 型ヒンティングがあるとエラーが発生します ので注意してください.

例えば,注入先のクラスは以下のようになります.

<?php // src/ServiceClass.php

declare(strict_types=1);

namespace MyVendor\MyPackage;

class ServiceClass
{
    private $immockable;
    
    /**
     * @param ImmockableClass|FakeImmockableClass $immockable // Hint by PHPDoc
     */
    public function __construct($immockable) // Cannot hint the type
    {
        
    }
}

また,モック対象のクラスが,何らかのインターフェースを実装しているか,何らかのクラスを継承している場合は, フェイククラスでもそのクラスやインターフェースを継承・実装しておけば型ヒンティングもある程度確保できます.

おわりに

モックを用いたテストはとても便利ですが,ときには,今回の final なクラスのようにうまくモックできないこともあるでしょう. しかし, 他のコンポーネントに影響を与えることなくテストを行う ことも重要です.

今回紹介したように,フェイククラスを作成すれば,実際のクラスには影響を与えないので,安心してテストが行えます.

それでは,よいモックテストライフを!