[Symfony] 機能テストでコントローラに注入しているサービスをモックする方法
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));
$client->disableReboot()
が必要
注意点:一度画面をrequestしてその画面のフォームを送信する際にモックを使ってほしい場合は 単にGETで一度だけアクセスするだけなら上記でよいのですが、例えば最初に開いた画面でフォームを送信して、フォーム送信時の処理においてモックを使ってほしい場合なんかには注意が必要です。
というのも、 $client->request()
や $client->submit()
などの画面遷移を 2回連続して行うと サービスコンテナが初期化されてしまう仕様のため、フォーム送信時にはせっかく差し替えたサービスが元に戻ってしまっているという問題が発生するのです。
解決策はとても簡単で、事前に $client->disableReboot()
を実行しておけばよいだけです👍
参考:SymfonyのWebTestCaseでServiceContainerが再生成されてモックが使えなくなった · polidog lab++
なお、コントローラで @Template
アノテーションを使っている場合、 $client->disableReboot()
した状態でリダイレクトを行うと リダイレクト先で @Template
が効かない というバグっぽい挙動があるようなのでご注意ください。( @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、明日も僕です!笑 お楽しみに!
Discussion