PHPUnitでControllerのプロパティにモックを持たせる方法

# はじめに

CakePHP の Controller クラスの initialize メソッドで、プロパティにインスタンスを持たせており、テスト実行時にはモックを持たせたかったのですが、うまくいかず詰まりました

- 問題
    1. Controller のテストを実行するとモックではなく実処理が走る問題
    2. Controller のテストを実行すると private プロパティが上書きできない問題
        - `error: [Error] Cannot access private property Controller::$property in Controller.php on line xxx`

# 結論

:::note info
EventManager::instance()->on() で Controller->initialize() を検知して controller オブジェクトを取得し、　　
private プロパティを Reflection クラスで無理やり上書き可能にしてモックを差し込めば OK
:::

```php:tests/TestCase/Controller/Api/V1/UsersControllerTest.php
class UsersControllerTest extends TestCase
{
    use IntegrationTestTrait;

    public function setUp(): void
    {
        parent::setUp();

        // CSRF コンポーネントのトークンミスマッチによるテスト失敗を防ぐ
        // <https://book.cakephp.org/4/ja/development/testing.html#csrfcomponent-securitycomponent>
        $this->enableCsrfToken();

        // 管理者でログイン
        $this->session([
            'Auth' => [
                'User' => [
                    'id' => 'dbdc8d4c-cf7d-4833-b755-4b69e97561f3',
                    'role' => 'admin',
                ],
            ],
        ]);

        // JSON形式でアクセス
        $this->configRequest(['headers' => ['Accept' => 'application/json']]);
    }

    public function test_ユーザー削除でエラーにならないこと(): void
    {
        // Arrange
        $id = '00676011-5447-4eb1-bde1-001880663af3';
        $mockUserDeleteUseCase = $this->createMock(UserDeleteUseCase::class);
        $mockUserDeleteUseCase->expects($this->once())->method('handle');
        $this->overridePrivatePropertyWithMock(propertyName: 'userDeleteUseCase', mockObject: $mockUserDeleteUseCase);

        // Act
        $this->delete("/api/v1/users/${id}");

        // Assert
        $this->assertResponseCode(200);
        $this->assertEquals('null', (string)$this->_response->getBody());
    }

    private function overridePrivatePropertyWithMock(
        string $propertyName,
        \PHPUnit\Framework\MockObject\MockObject $mockObject
    ): void {
        // グローバルイベントマネージャーで Controller->initialize() を検知
        // <https://book.cakephp.org/4/ja/core-libraries/events.html#id4>
        \Cake\Event\EventManager::instance()->on(
            'Controller.initialize',
            function (\Cake\Event\EventInterface $event) use ($propertyName, $mockObject) {
                // コントローラのオブジェクト取得
                // <https://book.cakephp.org/4/ja/core-libraries/events.html#id12>
                $controller = $event->getSubject();

                // Reflection クラスから Controller クラスのプロパティ情報を取得
                $property = (new \ReflectionClass($controller))->getProperty($propertyName);

                // privateプロパティへのアクセスを許可
                $property->setAccessible(true);

                // プロパティの値をモックで上書き
                $property->setValue($controller, $mockObject);
            }
        );
    }
}
```

# バージョン

- PHP
    - 8.0.10
- CakePHP
    - 4.3.0
- PHPUnit
    - 9.5.10

# 状況

## プロダクトコード

ユーザー削除APIで、以下の雰囲気のコードを書いていました

```php:src/Controller/Api/V1/UsersController.php
class UsersController extends AppController
{
    private CakePHPUserRepository $userRepository;
    private UserDeleteUseCase $userDeleteUseCase;

    public function initialize(): void
    {
        parent::initialize();

        // テスト時のモック用にプロパティのチェック
        $this->userRepository = $this->userRepository ?? new CakePHPUserRepository();
        $this->userDeleteUseCase = $this->userDeleteUseCase ?? new UserDeleteUseCase($this->userRepository);
    }

    public function delete(): void
    {
        $userId = $this->request->getParam('userId');
        $this->userDeleteUseCase->handle($userId); // ここをモック化したい！
        $this->viewBuilder()->setClassName('Json')->setOption('serialize', []);
    }
}
```

## テストコード

以下のテストコードでは実処理が動いていました。。。

```php:tests/TestCase/Controller/Api/V1/UsersControllerTest.php
class UsersControllerTest extends TestCase
{
    use IntegrationTestTrait;

    // （略）

    public function test_ユーザー削除でエラーにならないこと(): void
    {
        // Arrange
        $id = '00676011-5447-4eb1-bde1-001880663af3';
        $mockUserDeleteUseCase = $this->createMock(UserDeleteUseCase::class);
        $mockUserDeleteUseCase->expects($this->once())->method('handle');
        // $this は controller ではなかった！
        $this->userDeleteUseCase = $mockUserDeleteUseCase;

        // Act
        $this->delete("/api/v1/users/${id}");

        // Assert
        $this->assertResponseCode(200);
        $this->assertEquals('null', (string)$this->_response->getBody());
    }
}
```

ということで、上記の結論となります

# おわりに

Controller のテストでかなりつまずき、時間を吸われてしまったので、気が滅入りそうでした  
（CakePHP の記事が少なめ。。。）  
この記事が他のエンジニアの助けになれば幸いです

# 参考

- Controller の initialize でのプロパティの差し替えは、こちらの記事に助けられました

<https://www.fixes.pub/program/171563.html>

- private プロパティの上書きは、こちらの記事に助けられました

<https://qiita.com/sola-msr/items/c39321fd87c404b9cb19>