Symfony2のプロジェクトにひたすら機能テストを書いた話


はじめに

2017年は、Symfony2のプロジェクトにひたすら機能テストを書いていました。
そこで得た知見をまとめていきたいと思います。

尚、ここでいう機能テストは、KernelTestCaseを継承したテストを意図しています。
KernelTestCaseを継承したテストは、Kernelを通じて様々なことを自動でしてくれるので大変便利です。

  • サービスコンテナにアクセスできる(依存オブジェクトの注入などを自動でやってくれる)
  • EventDispatcherを通じて、イベントを発火してくれる(単体テストではできない)

Symfony2における機能テスト、KernelTestCaseについては下記のリンクを参照ください。

どういった環境か

アプリケーション

  • Symfony 2.8
  • 一つのWebアプリはAPIと複数のバンドルを利用して構成される
  • Webアプリ側には、ほとんどソースがない
  • コントローラも機能ごとにバンドルに束ねられている(例:会員系のバンドル、決済系のバンドル)
  • テンプレートは、Webアプリ側(app/Resources/XxxxBundle)にある
  • Domain KataUsecaseに主なビジネスロジックは実装

データベース、Doctrine ORM周り

  • postgresql 8.3/8.4 with pgpool
  • データベースの制約少なめ(ユニーク制約、外部キー制約など)
  • データベースが先にあって、Symfony2を後から導入した格好
  • Doctrine ORMのリレーションはほぼ未使用
  • マイグレーションなし
  • 社内共用サーバーのデータベースにアクセスして開発するスタイル

テスト周り

  • 単体テストは少しあるが、あまりメンテはされていなさそう
  • Scrutinizer CIでビルドが回っているが、静的解析のみで自動テストはなし

やってきたこと

2017年1月〜3月

  • 小さめの機能追加やリファクタリングをやりながら、徐々に全体を把握していく
  • Codeceptionを使った受入テストを実装

2017年4月〜6月

  • 毎日決まった時間にpg_dumpを使って、開発DBのDDLと最低限のマスターデータをSQLファイルに出力し、S3にアップロードするようにした
  • DBを使った機能テストをScrutinizer CIで実行するようになった
    • ビルド時に、S3からダウンロードした最新のDDL、マスターデータをリストア

2017年7月〜9月

  • 今までテストがなかったバンドルに、テストを実装していく
  • 主要なバンドルにはテストがある状態になった

2017年10月〜11月

  • バンドル側に実装が偏っていたので、徐々にアプリ側に実装を移していく
  • アプリ側に実装を移していくのにあわせて、アプリ側のテストも実装

得られた知見

マイグレーションがなければ、正となるDBからリストアする方法もある

PostgreSQLならpg_dumpMySQLならmysqldumpといったダンプツールがあるので、正となるDBを定期的にバックアップして、ローカルやCI環境にリストアする方法があります。
尚、CIなどイミュータブルな環境であれば、データベース作成 => リストアで良いのですが、既存のDBに差分更新をかけたい場合は、別のツールが必要になります。
PostgreSQLならapgdiffというJAVA製のツールがあるので、差分更新用のSQLを生成することができます。

単体テストでも受入テストでもなく、機能テストを重視すべき

冒頭でも触れましたが、単体テストではイベントが発火されないので、イベントが発火した後の状態を検査できません。
また、モックオブジェクトが増えれば増えるほど、テストコードが増える割にカバレッジはそれほど増えません。
一方、受入テストは、カバレッジは増やしやすいものの、テスト対象が広範囲なので、一つ一つのテストが大きく、実装の難易度も高いのが難点です。
SymfonyDI(Dependency Injection)の仕組みが備わっており、機能テストしやすい構造を持っていますので、機能テストに注力するのが良いと思います。

バンドル単体の機能テスト

バンドル単体の機能テストについては、Symfonyと言えばおなじみの「カルテットコミュニケーションズ」さんの技術ブログにいい記事があります。

ブロク記事の内容そのままですが、ポイントは下記のとおりです。

  • テスト用のKernelを作る
  • テスト用のKernelにバンドルを登録する
  • テスト用の設定をテスト用のKernelにロードさせる
  • テスト用のKernelで機能テストする

バンドル側にあるコントローラであっても、テストはアプリ側でやるべき

同じバンドルであっても、それを利用するアプリによって、設定やテンプレートが変わる(カスタマイズ可能)ので、バンドルのソースだったとしても、コントローラに関してはアプリ側でやりましょうということです。

ログインユーザーをシミュレートする

任意のユーザーでログインしたことにする方法が、Symfonyの公式ドキュメントにて紹介されています。

これによって、ユーザーのロールによって異なる挙動などがテストできます。

doctrine-test-bundleはマスト

コントローラのテストを動かしていると、下記の事象に出くわすことがありました。

  • setUpbeginTransactionして、テストデータを登録し、tearDownrollback
  • persist => flushしているにも関わらず、データを取得するとnullになる

テストデータの登録時と参照時でコネクションが別になってしまっていることが原因として考えられます。
そこで、doctrine-test-bundleを利用すると、コネクションが一元管理されるので、上記のような事象が解決されます。
詳細については、下記のリンクからREADMEを参照してください。

テスト実行時だけ、任意のサービスを差し替えることができる

config_test.ymlを経由して、テスト時のサービス設定を下記のようにします。

# app/config/config_test.yml

imports:
    - { resource: services_test.yml }
# app/config/services_test.yml

services:
    service.foo:
        class: AppBundle\Tests\Fake\FakeFooService

するとテスト時には、既存のservice.fooサービスがFakeFooServiceに差し替えられます。

ただ、フェイクを多用すると、テストされないコードが増えるだけなので、どうしてもテストできない部分(外部システムのAPIコールなど)に限定するようにしましょう。

発見したバグ

テストを書いている最中に、ひょっこり発見したバグがいくつかありました。

投げられない例外

例外が投げられることを確認するテストを書いて実行したら、テストがコケたことで気付きました。
静的解析やIDEを使えば気付けるのかもしれませんが、最終的にはテストで未然に防ぎたいものですね。

if (/* 何か例外的な状況で */) {
    new Exception('ま、throwしないけどな');
}

// 処理続行しちゃうよ...

尚、Java用の静的解析ツールであるfindbugsには検出項目があるのですが、Scrutinizer-CIにもphpmdにもそういった検出項目はなさそうです。

例外が投げられていることを確認するテストを書きましょう。

通らないコード

PHPは宣言なしに、変数を使うことができるので、下記のようなコードであっても例外にはなりません。
が、その条件が真になることはないので、通らないコードとなります。

private function doSomething($items)
{
    if (!empty($item)) { // $items の間違い
         // 結果、このブロックを通ることはない
    }

さすがに、Scrutinizer-CIBugとしてこれを指摘していました。
ただ、他の指摘に埋もれてしまったのか、僕が気付くまでそのままになっていました。

結果的に、その条件で何かすることは仕様的に不要と判断されたので、if文ごとコードを削除したのですが、何も考えずにバグを直してしまっていたら、それはそれでバグ扱いされていたかもしれません。
通らないコードは後になって時限爆弾のようにエンジニアを苦しめるだけなので、作り込まないようにテストを通しておきましょう。

おわりに

フルコミットではなかったので、約1年かかってしまいましたが、テストを書く文化、土壌みたいなものは作れたと思います。

  • 安全にコードを変更していくためには、テストが必要
  • テストを通じて現状のソースコードを知ることができる
  • まずは自分が安心するために、テストを書く

来年もSymfonyに限らず、PHPに限らず、テストを書きまくりたいと思います。

ではでは。