CakePHP の Command のテストで Mock を使うのは、 DI を利用するのがよさそう #cakephp

概要

Mock はできるだけ使いたくない派なんだけれど、外部API利用時なんかはどうしても必要になる時がある。

Controller の場合はやりようがあるが、 Command はちょっと迷った。

CakePHP の Command で使うようなときに Mock をするには DI を使うのがよさそう。

Controller の場合はどうするか

Controller の場合は \Cake\TestSuite\IntegrationTestTrait::controllerSpy という用途にあったメソッドが提供されている。

https://api.cakephp.org/4.2/trait-Cake.TestSuite.IntegrationTestTrait.html#controllerSpy()

また、 Controller.initialize のように、初期化時のフックイベントが元から用意されているので、そこに差し込むようなことも可能。

https://book.cakephp.org/4/en/controllers.html#event-list

DI がよさそうな理由

Command 用には \Cake\TestSuite\ConsoleIntegrationTestTrait が用意されているものの、 controllerSpy に相当するようなメソッドは用意されていないようだ。

Command 関連のイベントには Console.buildCommands が用意されているが、これはどちらかというとコマンドの名前変更等が目的なようで、このイベント内でどうにかするにはかなり無理がありそう(イベントが発火されるのも、Command のインスタンス生成前)。

自前で差し込み用のイベントを作成することも考えたが、テストのためだけに新しいイベントを作成するのは躊躇われる。

そういった理由から DI がよさそうと判断した。DI 自体は CakePHP の中ではまだ実験的な位置づけらしいので、大きな変更や使えなくなったりする可能性はあると思う。

DI でやってみる

まず Command のコンストラクタで、 Mock したいクラスを受け取るようにする。 受け取った引数をメンバ変数に格納する

<?php

class HelloCommand extends Command
{
    public $externalApiService;

    public function __construct(ExternalApiService $externalApiService)
    {
        parent::__construct();
        $this->externalApiService = $externalApiService;
    }

    public function execute(Arguments $args, ConsoleIo $io)
    {
        // モックしたい処理
        $this->externalApiService->doSomething();
    }
}

\App\Application::services で、 DI コンテナに登録しておく。

Command の場合は次のようになる。

<?php

public function services(ContainerInterface $container): void
{
    parent::services($container);
    $container
        ->add(HelloCommand::class)
        ->addArgument(ExternalApiService::class);
}

ここまでで DI されるようになったので、テストで Mock に差し替えてみる。

差し替えには \Cake\TestSuite\ContainerStubTrait::mockService を使う。

ConsoleIntegrationTestTrait は ContainerStubTrait を実装しているので、別途 use する必要はない。

<?php

class HelloCommandTest extends TestCase
{
  public function testExecute(): void
    {
        // モックに差し替える
        $this->mockService(ExternalApiService::class, function () {
            $mock = $this->createPartialMock(ExternalApiService::class, ['doSomething']);
            $mock->expects($this->atLeastOnce())->method('doSomething');

            return $mock;
        });

        $this->exec('hello');
        $this->assertExitCode(CommandInterface::CODE_SUCCESS);
    }
}

参考