🎻

[Symfony] 機能テストでコントローラに注入しているサービスをモックする方法

2020/12/10に公開

Symfony Advent Calendar 2020 の10日目の記事です!🎄🌙

昨日も僕の記事で、[Symfony] UniqueEntityで複合ユニークを設定した際に対象のフィールドすべてにエラーを表示する方法 でした✨

ちなみに、僕はよく TwitterにもSymfonyネタを呟いている ので、よろしければぜひ フォローしてやってください🕊🤲

やりたいこと

例えば、以下のようなコントローラとサービス定義があるとしましょう。

/**
 * @Route("/foo", name="foo")
 * @Template()
 */
public function foo(BarService $bar)
{
    $result = $bar->doSomething();
    
    return [
        'result' => $result,
    ];
}
# config/services.yaml

App\Service\BarService: ~

この BarService を機能テストにおいてモックしたい、というケースを考えます。

やり方

特に難しいことはなくて、基本的にはテストコード内で

$client->getContainer()->set(BarService::class, $mockBar);

のようにしてコンテナ内のサービスをモックで置き換えればOKです。

ただし、サービスの public 属性true に設定しておかないとコンテナから直接触ることができず差し替えもできないので、テスト時のみ public にしておく必要があります。

# config/services_test.yaml

App\Service\BarService:
  public: true

上記のように services_test.yaml を作ってサービスの定義を上書きすることで、テスト時のみ設定を変更することができます。

こうしておいた上で、例えばテストコードで以下のようにモックを作って差し替えることで目的を達成できるでしょう。

use Prophecy\Argument;

// モックを作成
$mockBar = $this->prophesize(BarService::class);
$mockBar->doSomething(Argument::cetera())->willReturn('mocked result');

// サービスをモックに差し替え
$client->getContainer()->set(BarService::class, $mockBar->reveal());

// テストを実行
$crawler = $client->request('GET', '/foo');
$this->assertStringContainsString('mocked result', $crawler->filter('.result')->text(null, true));

注意点:一度画面をrequestしてその画面のフォームを送信する際にモックを使ってほしい場合は $client->disableReboot() が必要

単にGETで一度だけアクセスするだけなら上記でよいのですが、例えば最初に開いた画面でフォームを送信して、フォーム送信時の処理においてモックを使ってほしい場合なんかには注意が必要です。

というのも、 $client->request()$client->submit() などの画面遷移を 2回連続して行うと サービスコンテナが初期化されてしまう仕様のため、フォーム送信時にはせっかく差し替えたサービスが元に戻ってしまっているという問題が発生するのです。

解決策はとても簡単で、事前に $client->disableReboot() を実行しておけばよいだけです👍

参考:SymfonyのWebTestCaseでServiceContainerが再生成されてモックが使えなくなった · polidog lab++

なお、コントローラで @Template アノテーションを使っている場合、 $client->disableReboot() した状態でリダイレクトを行うと リダイレクト先で @Template が効かない というバグっぽい挙動があるようなのでご注意ください。( @Template アノテーションは現在あまり推奨されていないので使うのをやめたほうがいいかもしれません)

詳細は以下の別記事をご参照ください。

[Symfony] @Templateアノテーションを使わないほうがいい理由

具象クラスではなくインターフェースに対してサービスをバインドしている場合

例えば、以下のように BarServiceInterface に対してサービスをバインドすることで、コントローラが具象クラスに依存してしまうことを避ける(DIP)ような実装になっているとしましょう。

public function foo(BarServiceInterface $bar)
{
# config/services.yaml

App\Service\BarService1: ~
App\Service\BarService2: ~

App\Service\BarServiceInterface: '@App\Service\BarService1'

この場合、 services_test.yaml を先ほどのように

# config/services_test.yaml

App\Service\BarServiceInterface:
  public: true

とだけ書いても、どの具象クラスにもバインドされずにエラーになってしまいます。

なのでこの場合は、以下のように alias 属性を使って何か具象クラスをバインドしてあげる必要があります。

# config/services_test.yaml

App\Service\BarServiceInterface:
  alias: App\Service\BarService1
  public: true

その上で、テストコードでは以下のようにバインドした具象クラスのモックを作って差し替えればOKです。

// モックを作成
$mockBar = $this->prophesize(BarService1::class);
$mockBar->doSomething(Argument::cetera())->willReturn('mocked result');

// サービスをモックに差し替え
$client->getContainer()->set(BarServiceInterface::class, $mockBar->reveal());

おわりに

Symfonyの機能テストでコントローラに注入しているサービスをモックする方法について解説しました。参考になれば幸いです😇

Symfony Advent Calendar 2020、明日も僕です!笑 お楽しみに!

GitHubで編集を提案

Discussion