engineering hosting php バージョンアップ ユニットテスト

PHP8系へのバージョンアップに伴うライブラリのリプレイス その1 〜AspectMock編〜

engineering hosting php バージョンアップ ユニットテスト

こんにちは。@matsusukeと申します。ホスティング事業部でWebアプリケーションエンジニアとしてプロダクト開発・運用保守業務を行っています。 現在ホスティング事業部ではアプリケーションのPHPバージョンを8系に上げるプロジェクトを進めています。 8系へのバージョンアップに伴い、いくつかの依存ライブラリが動作しない事例があったので、今回の記事を皮切りに複数回に渡って紹介していきたいと思います。

第一回目である今回は、PHPのユニットテストのテストダブルライブラリであるAspectMockの事例について紹介します。

AspectMockについて

AspectMockはPHPUnitでテストダブルを作成できるライブラリです。 AspectMockは、PHPUnitで用いるテストダブルをほぼ無制限に置き換え可能という特徴を持っています。 具体的には、下記のようなケースでもテストダブルを作成することが可能です。

  • アプリケーションコード内のstaticなメソッド
  • メソッド内で依存クラスのインスタンス化をしている場合
  • テスト対象メソッド内で自クラスのメソッドを呼んでいる場合

このような特徴を持っているため、staticメソッドをアプリケーション内で多く使っていたり、メソッド内でインスタンスを作成しているような密結合なコードでもユニットテストを作成でき、 非常に便利なライブラリです。

ホスティング事業部のアプリケーションは歴史が古く、作成された当初はテストコードが作られていない実装が多くありました。 そのような状態から、できるだけリファクタリング工数をかけずにテストコードを導入する際にマッチしたライブラリと言えます。

このような背景がある中で使われてきたAspectMockですが、2024年2月時点で、PHP8系への対応は行われていません。(Issueはこちら

PHP8環境でPHPUnitを動かすには、AspectMockを使って作成されたテストダブルを別の方法で作成するしかありません。 そこで、代替となるツールとしてPHP8系に対応している、かつ、スター数が多いMockeryを使うことにしました。

リプレイスを行う

上述したように、テストダブルへの置き換えの制約が少ないAspectMockと比較し、Mockeryではアプリケーションコードによってはそのまま置き換えが行えないケースもあります。 可能な限り実装・テストコードの振る舞いを変更せずにMockeryへのリプレイスを行った事例についていくつか紹介します。

テスト対象メソッド内で自クラスのメソッドを呼んでいるケース

class Hoge
{
    public function available()
    {
       return $this->settingEnabled() && $this->serviceAvailable();
    }

    private function settingEnabled()
    {
        // true or falseを返す何らかの処理
    }

    private function serviceAvailable()
    {
        // true or falseを返す何らかの処理
    }
}

上記のようなアプリケーションコードを想定します。

use \AspectMock\Test as test;

class HogeTest
{
    private function test_available_true()
    {
        $hoge = new Hoge();
        test::double('Hoge', ['settingEnabled' => true]);
        test::double('Hoge', ['serviceAvailable' => true]);
        
        $this->assertTrue($hoge->available());
    }
}

AspectMockを用いた場合、テストダブルの作成はAspectMock::doubleによって行えていました。 Mockeryの場合、通常であればMockery::mock(Hoge::class)によってテストダブルの作成が行えます。 しかし、今回のようにテスト対象メソッド内で自クラスのメソッドを呼んでいるケースではテストダブルによる置き換えがうまくいきません。

そこで、Mockeryのパーシャルモック機能を用います。

パーシャルモックとはモックしたいメソッドの挙動のみを変更できるモックオブジェクトのことを指します。 下記のようにmakePartialメソッドを用いることで、パーシャルモックを作成します。

use \Mockery as Mockery;

class HogeTest
{
    private function test_available_true()
    {
        $hoge = Mockery::mock(Hoge::class)->makePartial();
        $hoge->shouldReceive('settingEnabled')->andReturn(true);
        $hoge->shouldReceive('serviceAvailable')->andReturn(true);
        
        $this->assertTrue($hoge->available());
    }
}

これにより、HogeクラスのsettingEnabledメソッドとserviceAvailableメソッドの挙動だけモックすることができます。 Mockeryのパーシャルモックについての詳しい説明はこちらをご覧ください。

モックしたメソッドのアクセス権

Mockeryへリプレイスしたテストについて、上記のままだとモックしたsettingEnabledメソッドとserviceAvailableメソッドへのアクセス権がprivateであるため、テストに失敗します。 AspectMockによってモックした場合と挙動が変わるポイントになります。 Mockeryで作成したモックのメソッドはprivateのままだとテストコードでの実行は難しいですが、protectedメソッドの場合アクセス可能にできる方法があります。

それは、shouldAllowMockingProtectedMethodsメソッドを用いることです。

class Hoge
{
    public function available()
    {
       return $this->settingEnabled() && $this->serviceAvailable();
    }

    protected function settingEnabled()
    {
        // true or falseを返す何らかの処理
    }

    protected function serviceAvailable()
    {
        // true or falseを返す何らかの処理
    }
}
use \Mockery as Mockery;

class HogeTest
{
    private function test_available_true()
    {
        $hoge = Mockery::mock(Hoge::class)->makePartial()->shouldAllowMockingProtectedMethods();
        $hoge->shouldReceive('settingEnabled')->andReturn(true);
        $hoge->shouldReceive('serviceAvailable')->andReturn(true);
        
        $this->assertTrue($hoge->available());
    }
}

上記のように、protectedメソッドでもアクセスできるようになる根拠はMockeryのソースコードを読み解くことで確認できます。

if ($rm->isPrivate()) {
    throw new \InvalidArgumentException("$method() cannot be mocked as it is a private method");
}
if (!$allowMockingProtectedMethods && $rm->isProtected()) {
    throw new \InvalidArgumentException("$method() cannot be mocked as it is a protected method and mocking protected methods is not enabled for the currently used mock object. Use shouldAllowMockingProtectedMethods() to enable mocking of protected methods.");
}

確かに、protectedメソッドの場合のみ、$allowMockingProtectedMethodsがtrueであればアクセス権が付与されていることがわかりました。

なお、アプリケーションコードのメソッドのアクセス権をprivateからprotectedへリファクタリングする際は、下記のように実装に影響がないことを確認する必要があります。

  • Hogeクラスを継承する子クラスがないこと
  • Hogeクラスが他のクラスの子クラスとなっていないこと

メソッド内で依存クラスのインスタンス化をしているケース

class Hoge
{
    public function available()
    {
        $fuga = new Fuga();
        return $fuga->settingEnabled() && $fuga->serviceAvailable();
    }
}

Class Fuga 
{
    public function settingEnabled()
    {
        // true or falseを返す何らかの処理
    }

    public function serviceAvailable()
    {
        // true or falseを返す何らかの処理
    }
}

次に上記のような実装におけるHogeクラスのavailableメソッドのテストケースを作成しようとする場合について想定します。

availableメソッドがFugaクラスに依存しているため、Hogeクラスの実装を何も変更しなくても、Fugaクラスの実装を変更したタイミングで Hogeクラスの単体テストが壊れてしまう可能性があります。 こういった密結合なコードでも、AspectMockであれば

use \AspectMock\Test as test;

class HogeTest
{
    private function test_available_true()
    {
        $hoge = new Hoge();
        test::double('Fuga', ['settingEnabled' => true]);
        test::double('Fuga', ['serviceAvailable' => true]);
        
        $this->assertTrue($hoge->available());
    }
}

のように、テスト対象メソッドでインスタンス化されているクラスのテストダブルも作成可能です。 しかし、Mockeryの場合はテストダブル作成時に一工夫する必要があります。

オーバーロード機能を使う

Mockeryではモック生成時に既存のクラスをオーバーロードしてモックを作成できる機能があります。 これにより、テスト対象メソッドでインスタンス化されているような場合でも依存しているクラスのメソッドの振る舞いをモックできるようになります。

use \Mockery as Mockery;

class HogeTest
{
    private function test_available_true()
    {
        $hoge = Mockery::mock(Hoge::class);
        $fuga = Mockery::mock('overload:Fuga');
        $fuga->shouldReceive('settingEnabled')->andReturn(true);
        $fuga->shouldReceive('serviceAvailable')->andReturn(true);
        
        $this->assertTrue($hoge->available());
    }
}

オーバーロード機能についての詳しい説明はこちらをご覧ください。

上記で述べたような「privateメソッドのテストをするためにprotectedメソッドへ変更する」ことや「実装メソッド内でインスタンスの生成を行っている」ことは、本来であればアンチパターンとして語られることがあるような事象です。

しかし、今回のプロジェクトの目的である「PHPのバージョンを上げてユニットテストを全てパスさせる」ことを最優先に考えたため、リファクタリングを行うべき箇所についてあえて行わず、テストコードの修正によって回避したという背景があります。

リプレイスを行う上での課題

AspectMockからMockeryへのリプレイスがうまくいかなったケースもあるため、その事例も紹介します。

静的メソッドのモックができない

class Hoge
{
    public function available()
    {
        $fuga = new Fuga();
        if ($fuga->settingEnabled()) {
            // 何らかの処理
        } else {
            // ロギングをする静的メソッド
            $logger = Piyo::logger();
            $logger->info('Disabled');
            throw new HogeException('Throw Disabled');
       }
    }
}

上記のような実装を想定します。 AspectMockを用いた場合のテストコードは下記のようになっていました。

use \AspectMock\Test as test;

class HogeTest
{
    private function test_available_error()
    {
        $hoge = new Hoge();
        test::double('Fuga', ['settingEnabled' => false]);
        $loggerResMock = test::double(new stdClass());
        $piyoMock = test::double('Piyo', ['logger' => $loggerResMock]);

        // loggerが呼ばれ、例外が発生すること。
        try {
            $hoge->available();
        } catch (Exception $e) {
            $piyoMock->verifyInvoked('logger');
        }
    }
}

静的メソッドのモックをMockeryを使って置き換えようとすると、下記のようなテストコードになります。

use \Mockery as Mockery;

class HogeTest
{
    private function test_available_error()
    {
        $hoge = new Hoge();
        $fuga = Mockery::mock('overload:Fuga');
        $fuga->shouldReceive('settingEnabled')->andReturn(false);
        $loggerResMock = Mockery::mock(new stdClass());
        $piyoMock = Mockery::mock('overload:Piyo');

        // loggerが呼ばれ、例外が発生する。
        try {
            $hoge->available();
        } catch (Exception $e) {
            $piyoMock->verifyInvoked('logger');
            $piyoMock->shouldReceive('logger')->andReturn(null);
        }
    }
}

しかし、上記のように静的メソッドを実行しているPiyoクラスをオーバーロードしようとした場合、下記のようなエラーが発生してしまいます。

Mockery\Exception\RuntimeException: Could not load mock Piyo, class already exists

このエラーは、PHPUnit実行時に静的メソッドを実行しているPiyoクラスが既にロードされてしまっており発生したエラーであると考えられます。

どのように回避したか?

静的メソッドのモックを行えるようにするには、PHPUnit実行時のクラスのオートロードの設定を見直すなどの対応策が考えられますが、今回は「PHPをバージョンアップし、ユニットテストをパスさせる」という目的が大前提にあるので、リファクタリング工数をできるだけかけずに既存のテストを通過させる選択を取る必要がありました。

そのような背景から、今回のようなケースのテストコードは下記のように修正しました。

use \Mockery as Mockery;

class HogeTest
{
    private function test_available_error()
    {
        $hoge = new Hoge();
        $fuga = Mockery::mock('overload:Fuga');
        $fuga->shouldReceive('settingEnabled')->andReturn(false);

        // 静的メソッドが呼ばれることを確認するのではなく、例外がスローされることを期待する
        $this->expectException(HogeException::class);
        $this->expectExceptionMessage('Throw Disabled');
        $hoge->available();
    }
}

今回のリプレイス対象のテストコードは静的メソッドのテストダブルを作れなくても振る舞いを変えずにテストコードの変更が行えました。 しかし、静的メソッドの戻り値によって単体テストの結果が変わるような密結合な実装の場合、今回のような回避策は使えないため、PHPUnit実行時のオートロードの部分を見直すなどの根本的な解決が必要になりそうです。

最後に

今回のような、PHPのバージョンアップによる依存ライブラリのリプレイスは個人的に初めて経験した業務となりました。 ライブラリによってできることとできないことがあるため、「バージョンアップしたPHP環境で動くようにする」という大前提の目的を達成するために、代替の機能を調べたり、ライブラリのドキュメントやソースコードを読み解いていくことの重要性を再認識しました。

中には根本解決ができない問題もありましたが、「どうすれば最短で大前提の目的を達成できるか?」を意識し、回避策を考えることもできました。

ライブラリのリプレイスという一見すると地味な対応でしたが、多様な学びを得ることができました。