Play Frameworkのテスト実行中にDBのコネクションが枯渇する

概要

  • Play Frameworkのテストコードでplay.api.Applicationインスタンスを生成したら必ずplay.api.Play.stop()に渡して終了させる必要がある。
  • そうしなかった場合、コネクションプールも破棄されないので、やがてDBのコネクションが枯渇しテストが失敗するようになる。
  • GuiceOneAppPerSuiteなどのtraitを正しく使うか、そうでなければPlay.stop()Applicationインスタンスを渡す必要がある。

背景

私はScala/Play Frameworkを使ったアプリケーションの開発に携わっていますが、そのアプリケーションはこれまでテストがあまり書かれてこなかったため、少しずつ健全な状態に戻していきたいと思いました。最初に手を付けたのが以下の2点でした。

  • GitHubにpushした際、prを出した際にテストが実行されるようCIを整備する
  • 現在失敗するテストを全て修復する

ところが、sbt testを実行すると途中までは正常に実行されるのですが、いつも同じところでDBのコネクションが枯渇し、その後のテストが全て落ちてしまい、テストコードの大半が機能しなくなっていました。 おま環の可能性も考えましたが、試しにCIを構築してテストを回しても結果は同じでした。

Play Frameworkのコネクションプールの動作

Play Frameworkの公式ドキュメントでDBに関するconfigについて確認すると、play.db.prototype.pool, play.db.poolに何も指定していない場合はPlay Framework2.8ではHikariCPがデフォルトで使用されることが分かります。*1

また、HikariCPはコネクションプールが生成された瞬間play.db.hikaricp.maximumPoolSizeで指定した数だけDBとのコネクションを確立します。*2

Applicationのインスタンスが生成されると同時にコネクションプールも生成されるため、Applicationのインスタンスを作りっぱなしで放置するとsbt test実行中コネクションプールが残り続け、途中でDB側のコネクション数の上限に引っかかってしまうので新しいコネクションプールを生成できなくなり、それに伴ってApplicationのインスタンスの生成にも失敗するようになりエラーが発生するようになります。

対策

GuiceOneAppPerSuiteなど用意されたtraitの枠組みに乗っかる

play.api.Play.stop()にApplicationのインスタンスが確実に渡されるようにすれば解消します。

例として、ScalaTest PlusとGuiceを使っている場合は、GuiceOneAppPerSuiteというtraitをmixinするのが手っ取り早いです。 当然テストクラスにGuiceOneAppPerSuiteをmixinするだけで勝手にApplicationをstop()してくれるわけではなく、正しい方法でtraitのインスタンス変数やメソッドをoverrideする必要があります。

GuiceOneAppPerSuiteBaseOneAppPerSuiteGuiceFakeApplicationFactoryを合成したtraitになっています。 BaseOneAppPerSuite.run()の処理を確認すると

  1. GuiceFakeApplicationFactory.fakeApplication()が返すインスタンスインスタンス変数appに格納される
  2. appに入ったApplicationのインスタンスPlay.start()に渡される
  3. テストが実行される
  4. appに入ったApplicationのインスタンスPlay.stop()に渡される

という流れになっています。 つまり、GuiceOneAppPerSuiteを正しく機能させるには以下のどちらかが必要になります。

  • fakeApplication()をoverrideしてテストに実際に使いたいApplicationのインスタンスを返すよう実装する
  • appをoverrideしてfakeApplication()から取得したインスタンスを格納するのではなくテストに使うインスタンスを格納するよう実装する

GuiceFakeAppicationFactoryのコメントを読む限りではfakeApplication()をoverrideすることを想定していそうなので、私はそのようにしています。 基本はGuiceOneAppPerSuiteなど用意された仕組みで何とかすると良いでしょう。

テストケースごとにModuleをoverrideしたい場合

テストケースごとにDIで注入するオブジェクトを変更したい場合については、どうやらそのためのtraitは用意されておらず、try-finallyのfinallyブロックでPlay.stop()Applicationインスタンスをを渡してやる他無さそうでした。 しばしば使う処理だったので、私はApplication => Unit型のテストを行う処理を受け取るloan patternのメソッドをテスト関連のUtilityに追加しました。

def withApplication(overrideModules: GuiceableModule*)(block: Application => Unit): Unit = {
  val app = new GuiceApplicationBuilder().overrides(overrideModules: _*).build()

  Play.start(app)
  
  try {
    block(app)
  } finally {
    Play.stop(app)
  }
}