specs で GAE/J データストアのユニットテスト - LocalServiceTestHelper使用

GAE/J データストアを使用した処理のローカル上でのユニットテスト(BDD なのでユニットテストとは呼ばないかも)を specsScala用BDDツール)で実施してみました。

テストには GAE/J SDK の lib/testing/appengine-testing.jar ファイルに含まれる LocalServiceTestHelper クラスを使っていますが、今のところ日本語ドキュメントに使い方が書かれていないようなのでご注意下さい。

使用した環境は以下の通りです。(sbtの環境は id:fits:20100810 と同じ)

  • GAE/J SDK 1.3.7
  • sbt-appengine-plugin 2.1
  • specs 1.6.5

サンプルソースは http://github.com/fits/try_samples/tree/master/blog/20100927/

事前準備

今回は、sbt用の GAE/J プラグイン sbt-appengine-plugin を使うので、以下のようにソースからビルドしてローカルのリポジトリに配布しておきます。

sbt-appengine-plugin のビルドとローカルへの配布例
>git clone http://github.com/Yasushi/sbt-appengine-plugin.git
>cd sbt-appengine-plugin
>sbt publish-local

正常終了すると、ユーザーディレクトリの .ivy2/local/net.stbbs.yasushi/sbt-appengine-plugin に各種ディレクトリ・ファイルが配置されているはずです。

プロジェクト設定

はじめに環境変数 APPENGINE_SDK_HOME に GAE/J SDK ルートディレクトリへのパスを設定しておきます。(sbt-appengine-plugin を使用するために必要)

環境変数の設定例(Windows環境)
>set APPENGINE_SDK_HOME=C:\appengine-java-sdk-1.3.7

次に sbt でプロジェクトを作成し、sbt-appengine-plugin を使うための設定を Plugins.scala ファイルに行います。

project/plugins/Plugins.scala
import sbt._

class Plugins(info: ProjectInfo) extends PluginDefinition(info) {
    val appenginePlugin = "net.stbbs.yasushi" % "sbt-appengine-plugin" % "2.1"
}

最後にプロジェクトの設定ファイルで sbt-appengine-plugin(AppengineProject を継承させる)と specs の設定を行います。

project/build/GaejSpecsSampleProject.scala
import sbt._

class GaejSpecsSampleProject(info: ProjectInfo) extends AppengineProject(info) {
    //Scala 2.8.0 用の specs の設定
    val specs = "org.scala-tools.testing" % "specs_2.8.0" % "1.6.5" % "test"
}

sbt update でプロジェクト設定を反映しておきます。

プロジェクト設定を反映
>sbt update

テスト実施

今回使用している LocalServiceTestHelper クラスは以下のようにして使います。

  • テスト対象サービス(機能)毎に用意された LocalXXXServiceTestConfig クラスのインスタンスコンストラクタに指定(複数指定可)
  • テスト実施前に setUp メソッドをテスト実施後に tearDown メソッドを呼び出す

setUp・tearDown メソッド内で古いユニットテスト方法のドキュメントにあるような ApiProxy へのテスト用 Environment・Delegate の設定や解除が行われているようです。

ちなみに specs では、doBeforeSpec で setUp を doAfterSpec で tearDown を呼び出せば良いです。

上記を踏まえたデータストアのテストコードは以下のようになります。

スペック(テスト)クラス例 src/test/scala/BookmarkSpec.scala
package fits.sample

import org.specs._
import com.google.appengine.tools.development.testing.LocalServiceTestHelper
import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig

class BookmarkSpec extends Specification {
    val helper = new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig)
    val testerId = "TESTER01"

    //前処理
    doBeforeSpec {
        helper.setUp()
    }

    "初回のブックマークは空" in {
        Bookmark.getEntryList(testerId).length must_==0
    }

    "ブックマーク追加" in {
        Bookmark.addEntry(testerId, BookmarkEntry("http://localhost/", "default"))
        val list = Bookmark.getEntryList(testerId).toList
        list must haveSize(1)

        val entry = list.head
        entry.url must beEqual("http://localhost/")
        entry.description must beEqual("default")
    }

    //後処理
    doAfterSpec {
        helper.tearDown()
    }
}

ただし、doBeforeSpec・doAfterSpec は Example("XXX" in {・・・} の箇所)毎に実行されません。(上記サンプルで doBeforeSpec・doAfterSpec 内の処理が実行されるのは 1回だけ)

Example毎に helper の setUp・tearDown を実行したい場合は以下のように Sus("XXX" should {・・・} の箇所)の doBefore・doAfter で実行するようにします。
なお、doAfter は should に渡すブロックの最後に記載できない点に注意。(should に渡す処理の戻り値は Unit だが、doAfter は Option[Unit] のため)

src/test/scala/BookmarkSpec2.scala
package fits.sample

import org.specs._
・・・
class BookmarkSpec2 extends Specification {
    val helper = new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig)
    val testerId = "TESTER01"

    "ブックマーク" should {
        //doBefore と doAfter は Example("XXXX" in {・・・})毎に実施される
        doBefore {
            helper.setUp()
        }

        doAfter {
            helper.tearDown()
        }

        "初回のブックマークは空" in {
            Bookmark.getEntryList(testerId).length must_==0
        }

        "ブックマーク追加" in {
            ・・・
        }

        /* 以下のように doAfter をブロックの最後に記載できない
        doAfter {
            helper.tearDown()
        }
        */
    }
}


余談ですが、はじめは LocalDatastoreServiceTestConfig を使わずに以下のようにしてテストしてました。(LocalDatastoreServiceTestConfig の setUp メソッド内で同一の処理が実施されている)

    ・・・
    val helper = new LocalServiceTestHelper()
    ・・・
    doBeforeSpec {
        helper.setUp()
        LocalServiceTestHelper.getApiProxyLocal().setProperty(LocalDatastoreService.NO_STORAGE_PROPERTY, "true")
    }
    ・・・

BookmarkSpec のテスト対象は以下です。データストアの Low-level API を使って、Bookmark と BookmarkEntry から構成されるエンティティグループにデータを格納・取得しています。

テスト対象例 src/main/scala/Bookmark.scala
package fits.sample

import scala.collection.JavaConversions._
import com.google.appengine.api.datastore._

case class BookmarkEntry(val url: String, val description: String)

object Bookmark {
    private val bookmarkKind = "Bookmark"
    private val bookmarkEntryKind = "BookmarkEntry"
    private val descriptionProperty = "description"
    private val store = DatastoreServiceFactory.getDatastoreService()

    //BookmarkEntry を追加する
    def addEntry(userId: String, entry: BookmarkEntry) = {
        using(store.beginTransaction()) {tr =>
            val bkey = createBookmarkKey(userId)

            try {
                store.get(bkey)
            }
            catch {
                case e: EntityNotFoundException => 
                    store.put(tr, new Entity(bkey))
            }

            val bentry = new Entity(bookmarkEntryKind, entry.url, bkey)
            bentry.setProperty(descriptionProperty, entry.description)

            store.put(tr, bentry)
        }
    }

    //BookmarkEntry を取得する
    def getEntryList(userId: String): Iterator[BookmarkEntry] = {
        val query = new Query(bookmarkEntryKind, createBookmarkKey(userId))

        store.prepare(query).asIterator().map {et =>
            val url: String = et.getKey().getName()
            BookmarkEntry(url, et.getProperty(descriptionProperty))
        }
    }

    private def using(tr: Transaction)(f: Transaction => Unit) = {
        try {
            f(tr)
            tr.commit()
        }
        catch {
            case e: Exception => 
                e.printStackTrace()
                tr.rollback()
        }
    }

    private implicit def toStr(obj: Object): String = {
        obj match {
            case s: String => s
            case _ => ""
        }
    }

    private def createBookmarkKey(userId: String): Key = {
        KeyFactory.createKey(bookmarkKind, userId)
    }
}

テストの実行結果は以下です。

テスト実行例
>sbt test
・・・
[info] == fits.sample.BookmarkSpec ==
[info] BookmarkSpec
[info]   + 初回のブックマークは空
[info]   + ブックマーク追加
[info] == fits.sample.BookmarkSpec ==
[info]
[info] == test-complete ==
[info] == test-complete ==
[info]
[info] == test-finish ==
[info] Passed: : Total 2, Failed 0, Errors 0, Passed 2, Skipped 0
[info]
[info] All tests PASSED.
[info] == test-finish ==
・・・

追記(BookmarkSpec の改良)

BookmarkSpec をチェック項目単位にまとめて、改良してみたのが以下です。

src/test/scala/BookmarkSpec3.scala
package fits.sample

import org.specs._
import com.google.appengine.tools.development.testing.LocalServiceTestHelper
import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig

class BookmarkSpec3 extends Specification {
    val helper = new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig)
    val testerId = "TESTER01"

    lazy val setUp = helper.setUp()
    lazy val tearDown = helper.tearDown()

    "初期状態" should {
        doBefore(setUp)
        doAfter(tearDown)

        "ブックマークは空" in {
            Bookmark.getEntryList(testerId).length must_==0
        }
    }

    "ブックマーク追加" should {
        var list: List[BookmarkEntry] = List()

        doBefore {
            setUp
            Bookmark.addEntry(testerId, BookmarkEntry("http://localhost/", "default"))
            list = Bookmark.getEntryList(testerId).toList
        }

        doAfter(tearDown)

        "サイズは 1" in {
            list must haveSize(1)
        }

        "追加したブックマークは http://localhost/ で default" in {
            val entry = list.head
            entry.url must beEqual("http://localhost/")
            entry.description must beEqual("default")
        }
    }
}