GAE/JRubyで開発サーバを使わずに単体テストする方法

前置き

Google App Engine for JRuby(以下GAE/JRuby*1で開発をするにあたって不便に感じるものの一つに、単体テストのし難さがありました。

開発サーバ(dev_appserver)って起動するまでに結構時間がかかるので*2、少し修正を加えるたびに開発サーバを立ち上げ直すのも地味にストレスが溜まるんですよね…。そもそもサーブレットの中で単体テストを書くという行為自体が非効率的だと言わざるを得ません。

そんなわけで、なんとか開発サーバに頼らずに単体テストを行う方法がないものかと思っていたら…。

普通にGAEの公式ドキュメント中に解説がありました。初めからちゃんと読んでおけって話ですね…。

どうやらGAE/Jの提供する各サービスの肩代わりをしてくれるAPI群(ローカルサービス)がSDKに用意されており、それを利用することで、素のJava実行環境上でもGAE/Jのサービスを呼び出すプログラムを実行することができるようです。

Javaでの使い方は上のページと、それに加えて以下のページが参考になります。

更にググっていたら、GAE/JRubyで同じことをするための、まさにビンゴな解説がありました。ありがたやありがたや。

というわけで、今回はこれらの参考資料から得られた情報を私なりにまとめなおして、GAE/JRubyでローカルサービスを用いて単体テストを行う手順を書いてみようと思います。

SDKのバージョンの違いについて

本題に入る前に、一つ注意しておきたい点があります。

それは、ローカルサービスの使い方がSDKのバージョンによって微妙に異なる、ということです。

2009/08/12現在のGAE SDK for Javaの最新バージョンは1.2.2ですが、どうやら1.2.1から1.2.2に上がった際に後述するApiProxy.Environmentインターフェースの一部が変更されたらしく、1.2.1以前のバージョンを対象に書かれたコードがそのままでは動作しなくなっています。

上に挙げた参考資料も全て1.2.1以前のバージョンを対象としています。更に困ったことに、一番最初に挙げたGAE公式日本語ドキュメントですら1.2.1以前のバージョンを基にした解説となっています。

というか、そもそもGAE公式サイト日本語版のダウンロードページに掲載されているSDKが、未だにバージョン1.2.0どまりなんですよね…。

これに限らず、GAE公式サイトに関しては日本語版の情報はちょっと遅れていますので、何かおかしな点があったらまず英語版のドキュメントを参照する癖を付けておくことをお勧めします。英語に強い方は最初から英語版だけ読んでいたほうがいいかもしれません。

ちなみに、先程の単体テスト解説ページの英語版はこちらです。

この記事でもSDKのバージョンは1.2.2を前提として話を進めていきます。

ローカルサービスの使い方

それではRubyからGAE/Jのローカルサービスを使う手順を追っていってみます。

実行環境の構築

まず最初に行うことは、ローカルサービスを実行するための環境を構築することです。

…と言ってもよくわかりませんが、要点をまとめると

  • GAE/Jのサービスを立ち上げるためには、appengine-web.xmlなどで指定されたパラメータが必要である
  • しかし、ローカルサービスを利用するプログラムはGAE外で動作するため、直接それらのパラメータを知ることができない
  • そのため、かわりにローカルサービスが利用する各種パラメータを設定したクラスを作成してやる必要がある

ということのようです。

実行環境の構築と言っても、やること自体はそんなに難しいことではなく、ApiProxy.Environmentインターフェースを実装するだけです。

これをRubyで書くと次のようになります。

import com.google.apphosting.api.ApiProxy

class BaseEnvironment
  include ApiProxy::Environment
  def getAppId; 'Unit Tests' end 
  def getVersionId; '1.0' end 
  def getRequestNamespace; '' end 
  def getAuthDomain; '' end 
  def isLoggedIn; false end 
  def getEmail; '' end 
  def isAdmin; false end 
  def getAttributes; {} end 
end

Javaインターフェースの実装は、JRubyではincludeで実現できます。

ローカルサービスは実際にGoogleアカウントでの認証を行ったりはしませんので、かわりにgetAuthDomain, isLoggedIn, getEmail, isAdminあたりのメソッドの戻り値を変更することで認証状態を制御したりします。

これらのメソッドの細かい意味はまだ全て把握できていません。良い解説ページなどをご存知の方は是非ご教示ください。

ローカルサービスの立ち上げ

「立ち上げ」という言い回しは正確ではないのですが…まぁ、わかりやすいので。正しい解説は公式のドキュメントなどを参照してください。

先程作ったApiProxy.Environmentの実装クラスと、ApiProxyの実装であるApiProxyLocalImplクラス、2つのインスタンスを作成し、それぞれをApiProxyにセットします。

Rubyで書くと次のようになります。先程のコードからそのまま続いていると考えてください。

import com.google.appengine.tools.development.ApiProxyLocalImpl

class BaseApiProxyLocalImpl < ApiProxyLocalImpl; end 

ApiProxy.environment_for_current_thread = BaseEnvironment.new
ApiProxy.delegate = BaseApiProxyLocalImpl.new(java.io.File.new('.'))

BaseApiProxyLocalImpl.newの引数に*3カレントディレクトリを渡していますが、ここで渡したディレクトリを基準としてDatastoreなどのローカルサービスが使用するファイルが保存されます。

より正確に言うと、"(渡したディレクトリ)/WEB-INF/appengine-generated/"というディレクトリが作成され、その中に"local_db.bin"などのファイルが配置されます。カレントディレクトリ以下に作られたくない場合は他の適当なディレクトリを指定してください。

ちなみに、ApiProxyLocalImplから一旦継承したクラスをインスタンス化している理由は、直接ApiProxyLocalImplクラスをインスタンス化しようとすると次のようなエラーが出てしまうためです。

`new_proxy': no public constructors for Java::ComGoogleAppengineToolsDevelopment::ApiProxyLocalImpl (TypeError)

これの解決方法がわかればBaseApiProxyLocalImplクラスは不要になるのですが。

ストレージを利用しないデータストア

ここまででローカルサービスを利用する準備は整いましたが、Datastoreサービスについてはもう一つオプションのステップがあります。

先程説明したように、ローカルサービスのDatastoreも実際にファイルにデータを保存するため、プロセスをまたいでデータを持ち越すことができます。

しかし、テストによっては毎回空のデータストアが立ち上がってくれた方が嬉しい場合もありますよね。

そのようなニーズのために、Datastoreサービスが「ストレージから読み込まず、書き込みもしない」ようになる…つまりメモリ内だけで完結するようになるオプションが用意されています。

これも先程のコードからそのまま続いているものと考えてください。

import com.google.appengine.api.datastore.dev.LocalDatastoreService

ApiProxy.delegate.set_property(LocalDatastoreService::NO_STORAGE_PROPERTY, 'true')

これを追加することで、Datastoreサービスがメモリ内で完結するようになります。ただし、WEB-INF以下のディレクトリだけは作成されますので注意してください。

ローカルサービスへのアクセス

あとは通常通りGAE/JサービスのAPIを呼び出すだけです。GAE/J及びGAE/JRuby上で普通に動作するコードであれば、特に手を加える必要はありません。

ローカルサービスの終了

使い終わったら後片付けをしましょう。

まず、Datastoreサービスがストレージを利用しないように設定していた場合(NO_STORAGE_PROPERTY)は、次の1ステップが必要になります。NO_STORAGE_PROPERTYを設定していないのであれば必要ありません。

ApiProxy.delegate.getService('datastore_v3').clear_profiles

何をしているのか正確には把握していませんが、おそらく保存しない不要なデータをクリアしているのでしょう。

次に、Datastore APIを一度でも呼び出しているのであれば、Datastoreサービスを停止するために次のコードを実行する必要があります。

ApiProxy.delegate.getService('datastore_v3').stop

これがないとDatastoreサービスが生き続けるためか、プログラム終了後もプロセスが終了してくれません。Datastore APIを一度も利用していないのであれば不要です。*4

最後に、ApiProxyにセットしていた二つのインスタンスを空にリセットします。

ApiProxy.delegate = nil
ApiProxy.environment_for_current_thread = nil

このステップは無くてもいいらしいですが、せっかくなのでキレイキレイしておきましょう。

ローカルサービスを利用するスクリプトの実行方法

これでローカルサービスを使えるようになる…わけですが、ローカルサービスを実現するAPI群はGAE SDKの中に含まれていますので、適切にCLASSPATHを通しておく必要があります。これを忘れると、当然ですが、クラスが未定義だと言われて実行できません。

CLASSPATHを通す必要のあるファイルは以下の3つです。

  • ${GAE_HOME}/lib/impl/appengine-api-stubs.jar
  • ${GAE_HOME}/lib/impl/appengine-api.jar
  • ${GAE_HOME}/lib/impl/appengine-local-runtime.jar

環境変数GAE_HOMEにはGAE SDKルートディレクトリのパスが設定されていると考えてください。

これらを環境変数CLASSPATHに設定しておくか、あるいはjrubyコマンドの引数に次のような形で渡してやります。

$ jruby -J-classpath ${GAE_HOME}/lib/impl/appengine-api-stubs.jar:${GAE_HOME}/lib/impl/appengine-api.jar:${GAE_HOME}/lib/impl/appengine-local-runtime.jar unit_tests.rb

"-J"はjrubyコマンドのオプションで、後に続く引数をそのまま*5JVMに渡してくれます。

ローカルサービスを使いやすくするLocalGAEライブラリ

以上のような手続きを毎回行うのもそれなりに面倒ですので、その辺を扱いやすいようにまとめた簡単なライブラリを作成しました。

GitHubの以下のリポジトリに置いてあります。

ライブラリの使い方

ライブラリ本体は"local_gae.rb"のみです。使い方はシンプルで、

require 'local_gae'

LocalGAE::Service.start
# ここにローカルサービスを使う処理
LocalGAE::Service.stop

とするだけです。上で述べた環境構築やサービスの立ち上げ、終了処理などは全てLocalGAE::Serviceクラスが請け負います。

LocalGAE::Service.startメソッドはオプションを取ることができ、

require 'local_gae'

# :data_dir = データを保存する基準ディレクトリを変更する
LocalGAE::Service.start(:data_dir => '/tmp')
# ここにローカルサービスを使う処理
LocalGAE::Service.stop

# :no_storage = Datastoreがストレージを利用しないようにする
LocalGAE::Service.start(:no_storage => true)
# ここにローカルサービスを使う処理
LocalGAE::Service.stop

のように指定します。"data_dir"のデフォルトはカレントディレクトリになっています。

また、startメソッドにブロックを渡すこともできます。

require 'local_gae'

LocalGAE::Service.start(:no_storage => true) do
  # ここにローカルサービスを使う処理
end

ブロックを抜けるときに自動でstopしますので、stopのし忘れを防げます。

実行スクリプトの使い方

スクリプトを実行する際に、先程実行したような長いコマンドを毎回入力しなくて済むように、簡単なシェルスクリプト"jruby_local_gae.sh"を同梱しました。

中身はこんな感じです。

#!/bin/sh
JRUBY=${JRUBY_HOME:-/usr/local/jruby}/bin/jruby
LIB_IMPL=${GAE_HOME:-/usr/local/appengine-java-sdk}/lib/impl
${JRUBY} -J-classpath ${LIB_IMPL}/appengine-api.jar:${LIB_IMPL}/appengine-local-runtime.jar:${LIB_IMPL}/appengine-api-stubs.jar $@

あらかじめ環境変数JRUBY_HOMEとGAE_HOMEを適切に設定しておくか、あるいはスクリプト内のデフォルト値を書き換えて使用してください。

使い方は単にjrubyコマンドの代わりにjruby_local_gae.shを呼び出すだけです。

$ ./jruby_local_gae.sh unit_tests.rb
サンプルの単体テストスクリプトについて

LocalGAEを使った単体テストの書き方のサンプルとして、Datastoreサービスのシンプルなラッパーライブラリ"simple_datastore.rb"と、その単体テスト"test_simple_datastore.rb"を同梱しました。

低レベルなDatastore APIを使うこのライブラリは、ベタベタにGAE/J依存な癖に開発サーバ上でのテストが面倒なスクリプトのサンプルとしては割とうってつけなのではないかと思います。

あくまでサンプルにするつもりだったのですが、書いてみたらそれなりの長さになってしまったため、ここではコードは載せません。

$ ./jruby_local_gae.sh test_simple_datastore.rb 

とするとテストが通るのが確認できると思いますので、興味のある方は色々弄ってみてください。

ちなみにこの単体テスト、本当はRSpecで書いてみたかったのですが、個人的にまだ勉強中で理解に怪しい部分があるため、今回は妥協してTest::Unitで書いています。

実行環境の違いについて

以上で説明は全て終了ですが、ローカル環境で単体テストを行うにあたり、注意しておくべき点があります。

それは、ローカルサービスは特定のAPIを肩代わりしてくれるだけであり、実行環境はあくまでも素のJavaである、ということです。

例えばGAE/Jのサンドボックス内ではファイルの書き込みが禁止されていますが、ローカルで動かすプログラムではいくらでもファイルの書き込みを行うことができます。

このあたりを把握していないと、「ローカルでは動くのにGAE/J上で動かない」という状況になってしまいかねません。*6

GAE/Jのサンドボックスで出来ることと出来ないことをきちんと意識してテストを書くことをお勧めします。

まとめ

ローカルGAE/J環境は完全なGAE/J環境の再現ではありませんが、それでも開発サーバを介さずに単体テストを行えるのは非常に便利です。是非活用していきましょう。

*1:今まで使っていた"JRuby for GAE/J"よりこの略称のほうが簡潔なので、今後こちらを使おうと思います。

*2:Atomマシン上のVMサーバなんぞで開発してる私が悪いのかもしれませんが…。

*3:正確にはそこに渡しているjava.io.File.newの引数に

*4:不要ですが、別に実行しても構いません。

*5:正確には結構複雑に処理しているようですが

*6:とはいえ、そんなにそのようなパターンがあるとも思いませんが。