2011年12月17日土曜日

Play 2.0 RC1 でControllerのテストを書く

この記事はPlay! framework Advent Calendar 2011 jpの17日目です。

来年の1月頃のリリースに向けて活発な開発が続いているPlay 2.0ですが、コミットログを追っていると、最近テスト関係の変更が少ないのでそろそろfixかと思い、勉強ついでにControllerのテストを書いてみました。

一応、Controllerに対するテストの書き方はPlay 2.0 RC1の公式ドキュメントにも記載されています。
https://github.com/playframework/Play20/wiki/Scalatest

テストはspecs2による記述が推奨されているようです。
具体的には、specs2にPlayのヘルパーメソッドを併用します。公式ドキュメントから抜粋したコードが以下です。

object ApplicationSpec extends Specification {

  "an Application" should {
    "execute index" in {
        withApplication(Nil, MockData.dataSource) {
          val action = controllers.Application.index
          val result = action.apply(new FakeRequest)
          ...
        }
     }
   }
}

コントローラごとに*Specを定義します。そして、
"テスト対象" should {
  "期待する振る舞い" in {
    テストコード
  }
}
という形で仕様を記述していきます。

withApplicationはアプリケーションのモックを作成して、そのモック上でコントローラなどのコンポーネントを動かしてくれます。Playに依存したコードのテストは、基本的にはwithApplicationを使った方が楽だと思われます。

MockData.dataSourceというのはただのMap[String, String]で、application.confの代わりです。次のように定義されています。
/**
 * provides standard mock data
 */
object MockData {
  val dataSource = Map("application.name" -> "mock app",
    "db.default.driver" -> "org.h2.Driver",
    "db.default.url" -> "jdbc:h2:mem:play",
    "ebean.default" -> "models.*",
    "db.default.disableJMX" -> "true",
    "mock" -> "true")
  def dataSourceAsJava = dataSource.asJava

}
デフォルト設定では、テストにインメモリデータベースを使うことがわかります。

controllers.Application.indexは、アクションを取得しています。アクションを実行しているわけではないことに注意してください。Play 1系まではコントローラのメソッドがアクションそのものでしたが、2.0のコントローラのメソッドはアクションを返すだけです。
コントローラが返したアクションをフレームワークが実際には実行するわけですが、テストにおいては自分でアクションにHTTPリクエストなどを渡して、アクションを実行する必要があります。
それが、
val result = action.apply(new FakeRequest)
の部分です。

さて、以上で公式ドキュメントの内容はおさらいできましたが、実際のテストを書くために知っておきたいことがいくつか足りていません。
例えば、リクエストメソッドやリクエストパラメータの指定、データベースの初期データの投入方法など、肝心なところが説明されていません。

というわけで、今回はPlay 2.0のzentasksサンプルの一部にテストを書いてみました。
テスト対象はApplicationコントローラ。zentasksへのログイン・ログアウトに関するアクションが定義されています。routesファイルからは次のようにマッピングされています。

# Authentication
GET     /login                              controllers.Application.login
POST    /login                              controllers.Application.authenticate
GET     /logout                             controllers.Application.logout

今回は、login, authenticate, logoutの3つのアクションの仕様を確認しつつ、テストフレームワークに足りない部分を実装しつつ、specs2の記述に落とし込んでみました。
コードはGitHubで公開してあります。

test/ApplicationSpec.scala at master from mumoshu/play20-zentasks-tests - GitHub

個別に説明していきます。まずは、loginアクションのspecを見てみてください。

"an Application" should {

    /**
     * GET /login へアクセスすると、ログインフォームが表示される。
     */
    "show login page" in {
      withEvolutions {
        withApplication(Nil, MockData.dataSource) {
          // Controllerに対するspecは、
          // 1. requestをテスト対象のactionへ渡して、resultを得る
          // 2. resultからレスポンス内容をextractする
          // 3. レスポンス内容をspecs2のMatcherで検証する
          // という流れになります。
          val action = controllers.Application.login
          val result = action.apply(FakeRequest("GET", "/login", body = AnyContentAsEmpty))
          val extracted = Extract.from(result)
          // ステータスコード
          extracted._1 must equalTo (200)
          // レスポンスヘッダ
          extracted._2.toString mustEqual ("Map(Content-Type -> text/html; charset=utf-8)")
          // レスポンスボディ
          extracted._3 must contain ("/login")
        }
      }
    }

公式ドキュメントに書いてない部分は、withEvolutionsとFakeRequest、Extractの3つですね。

まず、withEvolutionsについて説明します。そもそも、Scala版のPlayにはevolutionsという機能があります。データベースが最新の状態でないときにエラー画面でDDLの実行を促すというものです。
この便利機能が、今回は罠になりました。なんと、withApplicationでモックアプリケーションを起動するときに、evolutionsにより「データベースが最新の状態ではない」ということで例外を投げて、テストが必ず失敗してしまうのです・・・。

というわけで、テスト中にevolutionsの例外をcatchしたら、DDLを実行してから再度テストを走らせるという力技をかけました。それがwithEvolutionsです。コードが次の通りです。

private def withEvolutions[T](spec: => T): T = {
    try {
      spec
    } catch {
      case e: InvalidDatabaseRevision => {
        play.api.Logger.info("Applying all evolution scripts.")
        applyAllEvolutionScripts()
        spec
      }
    }
  }

specがwithApplicationから始まるテスト本体です。InvalidDatabaseRevisionというevolutionsの例外発生時にapplyAllEvolutionScripts()により全てのDDLを適用しています。コードは次の通りです。

private def applyAllEvolutionScripts() {
    val app = play.api.Play.current
    val api = app.plugin[DBPlugin].map(_.api).getOrElse(throw new Exception("there should be a database plugin registered at this point but looks like it's not available, so evolution won't work. Please make sure you register a db plugin properly"))
    val db = "default"
    val appPath = new File(".")
    val scripts = Evolutions.evolutionScript(api, appPath, db)
    Evolutions.applyScript(api, db, scripts)
  }

Playに組み込まれているEvolutionsPluginが行っているのと同じ方法で、"default"という名前のデータベースに必要な全てのDDLを取得・実行しています。

気づかれた方もいるかもしれませんが、正直なところ、なぜこれで動くのかわかっていない所があります・・・。本来、withApplicationでアプリケーションが作り直されるたびにインメモリデータベースも初期化されるべきだと思うのですが、どうやらされていないようです。というより、されていないから、evolutions実行後にwithApplicationをもう一度実行すると、evolutionがエラーを出さないのだと思います。@kitora_naokiさんの記事にもあるようにh2dbを使った方が良いのかもしれません。

さて、3つめのFakeRequestですが、これも今回独自に実装した、テスト用のリクエストを簡単に作成するためのクラスです。Play本体にあって欲しいところ・・・。
実装については特筆すべきところはないので説明は省いて、使い方だけ書きます。

// クエリパラメータなしのGETリクエスト
FakeRequest("GET", "/login", body = AnyContentAsEmpty)

// クエリパラメータありのGETリクエスト
FakeRequest("GET", "/login", params = Map("key" -> Seq("value")))

// POSTリクエスト
FakeRequest("POST", "/login", body = AnyContentAsUrlFormEncoded(Map(
  "email" -> Seq("mumoshu@sample.com"),
  "password" -> Seq("secret")
)))

3つめのExtractについて説明します。ExtractはPlay組み込みのクラスです。
Extract.from(result)
は、resultから
  • HTTPステータスコード
  • レスポンスヘッダ
  • レスポンスボディ
を抽出して、Tuple3で返します。

基本的にアクションのテストは、このようにresultからExtract.fromで上記3つを取り出し、specs2のmatcherで検証すれば良いでしょう。

最後に、データベースにテスト用データを投入する方法です。withApplication内で普通にModelを呼び出してinsertしましょう。
withEvolutions {
        withApplication(Nil, config) {

          User.create(User("mumoshu@sample.com", "mumoshu", "secret"))

          val action = controllers.Application.authenticate
          // つづく...

まとめ


Play 2.0 RC1でのControllerに対するテストの書き方を、公式ドキュメントより少し踏み込んで説明しました。
個人的にここで紹介した以外にも、
  • Web APIを呼び出すアクションのテスト。APIをモックする?
  • HTTP Chunkingに対応したアクションのテスト。チャンクを一つずつ取得して検証する?
  • Web Socketコントローラのテスト。
など、どうやってテストするのか気になるところが残っていますが、それはまた別の機会に。

明日はPlay! framework Advent Calendar 2011 jp 18日目、@garbagetownさんのご担当です!
よろしくお願いします。

0 件のコメント:

コメントを投稿