はじめに
CakePHP の Controller クラスの initialize メソッドで、プロパティにインスタンスを持たせており、テスト実行時にはモックを持たせたかったのですが、うまくいかず詰まりました
- 問題
- Controller のテストを実行するとモックではなく実処理が走る問題
- Controller のテストを実行すると private プロパティが上書きできない問題
error: [Error] Cannot access private property Controller::$property in Controller.php on line xxx
結論
EventManager::instance()->on() で Controller->initialize() を検知して controller オブジェクトを取得し、
private プロパティを Reflection クラスで無理やり上書き可能にしてモックを差し込めば OK
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で、以下の雰囲気のコードを書いていました
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', []);
}
}
テストコード
以下のテストコードでは実処理が動いていました。。。
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 プロパティの上書きは、こちらの記事に助けられました