2010年1月2日土曜日

AppEngine用のアプリケーションの自動テストについて(1)

AppEngine用のアプリケーションのテストの手法について、公式ドキュメントがあまりにも貧弱なためそれを補足する資料を作ろうと思います。 DatastoreはもちろんMail送信やQueueへのTask投入等のテストを行う説明まで何回かに分けて徐々に書いていき、 最終的にはそれらのエントリを清書してWikiにまとめたいと思います。これはその第一回目で、テストのための仕組みの説明と基本的なテストの手順について説明します。

文中でApiProxyと書かれているクラスはcom.google.apphosting.api.ApiProxyの事です。

この説明での「AppEngine環境」とは、データストアサービスなどのAppEngineで提供されている各種サービス群を利用するための環境のことをさします。 テストのためにAppEngine環境を起動するには、大きくわけると

  • ApiProxy.setEnvironmentForCurrentThread()
  • ApiProxy.setDelegate()
のふたつの処理が必要になります。

ApiProxy.setEnvironmentForCurrentThread(ApiProxy.Environment)

AppEngineの実行環境ではスレッドごとにApiProxy.Environmentのインスタンスが必要となるので、 AppEngineの実行環境がApiProxy#getCurrentEnvironment()を経由してApiProxy.Environmentのインスタンスを取得できるように設定する必要があります。 プロダクション環境や開発用のWebコンテナ経由で起動した場合はこれが自動的に設定されますが、それらを経由せず起動する場合は独自にインスタンスを作成・設定してやる必要があります。

ApiProxy.setDelegate(ApiProxy.Delegate<ApiProxy.Environment>)

AppEngineの実行環境では色々なサービスが存在しており、それらのサービスへのAPIが用意されています。 例えばデータストアサービスであればDatastoreServiceFactory#getDatastoreService()を使うことでそれらのサービスへのAPIを使用することができます。
これら各サービスへのAPIは内部的にApiProxy#getDelegate()で取得したApiProxy.DelegateのインスタンスのmakeSyncCall()というメソッドを通してサービスの実装へ処理を委譲します。 ApiProxy.Delegateのインスタンスの中で各サービスの実装の解決や、それらとの通信が行われます。 この仕組みはプロダクション環境も開発環境でも同じ動作を行うように実装されており、それぞれの環境ごとにApiProxy#getDelegate()が返すApiProxy.Delegateの実装が違うだけ、となっています。

例えば開発用のWebコンテナ経由で起動された環境ではApiProxy#getDelegate()com.google.appengine.tools.development.ApiProxyLocalImplというクラスのインスタンスが返されます。

ApiProxy.Environment

例えば以下のようなクラスを用意する事になります。

public class UnitTestEnvironment implements com.google.apphosting.api.ApiProxy.Environment {
  public String getAppId() { return "myApplicationId"; }
  public String getVersionId() { return "unittest"; }
  public String getRequestNamespace() { return ""; }
  public String getAuthDomain() { return "gmail.com"; }
  public boolean isLoggedIn() { return true; }
  public String getEmail() { return "unittest@gmail.com"; }
  public boolean isAdmin() { return true; }
  public java.util.Map<String, Object> getAttributes() {
    java.util.Map<String, Object> map = new java.util.HashMap<String, Object>();
    return map;
  }
}

この中で重要なメソッドについてピックアップして説明しておきます。

String getAppId()
Webコンテナ経由で起動した場合にappengine-web.xmlから読み込まれるアプリケーションのIDの事ですが、 Datastoreサービスを使用した際に重要となります。
というのもDatastoreサービスで読み込み・書き込みで使用する時には必ずアプリケーションIDが使用されるからです。 Datastoreサービスでエンティティを保存すると必ずKeyがエンティティに保存されますが、Keyには必ずアプリケーション名が含まれます。 ですので開発環境でひとつのlocal_db.binを使っていたとしても、 環境を起動したときのApiProxy.Environment#getAppId()で書き込んだ時とは別のアプリケーションIDを使ったりすると、 local_db.binに書き込んまれているはずのエンティティが読み込めなかったりします。
boolean isLoggedIn(), String getEmail(), boolean isAdmin()
UserServiceを使用した場合に、ログイン済み・ログインユーザのEmailアドレス・ログインユーザが管理者権限をもっているか、を制御する事ができます。 実行時に制御したいのであればApiProxy.Environmentの実装ないで状態に応じた値を返すようにするか、 ApiProxy.Environmentの実装を複数用意してApiProxy#setCurrentEnvironment()で切り替えるなどすれば良いです。

ApiProxyLocalImpl

ApiProxy.Environmentとは違い、 ApiProxy.Delegate<ApiProxy.Environment>の実装は既存のcom.google.appengine.tools.development.ApiProxyLocalImplを利用することになります。

このクラスはコンストラクタにjava.io.FileのインスタンスでAppEngine環境を起動するフォルダを指定します。 ここで指定したフォルダ配下にWEB-INF/appengine-generated等のフォルダが生成されます。

ここで指定したフォルダ配下が上書きされる…という事は、Webコンテナ経由で起動した時のWEB-INF/appengine-generated/local_db.binファイルも上書きされるという事です。 そのため単体テストで使用するときには、このフォルダはWebコンテナ経由で起動する時のwarフォルダを指定したくない場合が多々あります。 その時に少し問題になるのがWEB-INF/datastore-indexes.xmlWEB-INF/queue.xmlです。 ApiProxyLocalImplはコンストラクタで指定されたAppEngine環境を起動するフォルダ配下に何かを書き込むだけではなく、 そのフォルダ配下のWEB-INF/datastore-indexes.xmlWEB-INF/queue.xmlを読み込もうとします。 ですので、テスト環境でもQueueを使ったりindexについて厳密に処理したい場合はApiProxyLocalImplのコンストラクタに渡すフォルダに、 それらのファイルをコピーしておく必要があります。

実際にテストを行う手順

ここでの説明は公式ドキュメントとほぼ同じです。

テスト用のライブラリを配置するフォルダを準備する

プロジェクト直下にlib.testというtestライブラリ用のフォルダを作成します。フォルダ名は例ですので自由に決めると良いです。
プロダクト用のライブラリはwar/WEB-INF/libに配置してclasspathに追加しますが、war/WEB-INF/libはプロダクト環境にデプロイされてしまいます。 そのため、テスト用のライブラリはデプロイする必要が無いため別のフォルダを用意します。

テストに必要なライブラリを配置する

AppEngineのSDKを展開したフォルダを「${appengine-sdk}」として記述します。 SDK本体ではなくEclipseのPluginだけをインストールしている場合は、${appengine-sdk}は 「eclipseをインストールしたフォルダ/plugins/com.google.appengine.eclipse.sdkbundle.../appengine-java-sdk-xxx」 となります。「...」や「xxx」はSDKのバージョンやEclipsePluginのバージョンによって異なります。

以下のふたつのjarがテストに必要ですので、testライブラリ用のフォルダにコピーしclasspathに追加します。

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

また上記のappengine用のjarだけではなくJUnitのjarもtestライブラリ用のフォルダに配置してclasspathに追加しておきましょう。

ApiProxy.Environmentの実装を用意する

今回は先のApiProxy.Environmentに記述したUnitTestEnvironmentをそのまま使用することにします。 これは複数必要であれば複数の実装を用意しておき適宜切り替えを行えば良いです。

AppEngine環境を起動する

各テストの実行前に、かならずAppEngine環境を起動する処理が必要となります。 JUnit4では@Before@BeforeClassで修飾されたメソッド内に実装すると良いです。

ApiProxy.Environement environment = new UnitTestEnvironment();
ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(new File(testFolderName)) {
};
ApiProxy.setEnvironmentForCurrentThread(environment);
ApiProxy.setDelegate(apiProxyLocal);

サンプルコード中のtestFolderNameはテスト環境用のフォルダです。 bintarget等の、出力用フォルダを指定しておくのが良いです。

またApiProxyLocal#setProperty(String 設定キー, String 設定値)を使って各サービスの実装の動作を設定することもできます。 例えばファイルを使わずにメモリ上だけでDatastoreを操作したい場合は以下のような処理になります。

ApiProxy.Environement environment = new UnitTestEnvironment();
ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(new File(testFolderName)) {
};
apiProxyLocal.setProperty(LocalDatastoreService.NO_STORAGE_PROPERTY, Boolean.TRUE.toString());
ApiProxy.setEnvironmentForCurrentThread(environment);
ApiProxy.setDelegate(apiProxyLocal);

ファイルを使って処理するが、データを全て削除したい場合には以下の処理を実行します。

LocalDatastoreService datastoreService = (LocalDatastoreService) ((ApiProxyLocal) ApiProxy.getDelegate()).getService(LocalDatastoreService.PACKAGE);
datastoreService.clearProfiles();

QueueにTaskを投入したりdatastore-indexes.xmlの定義を必要とする場合はそれらをテスト環境用のフォルダにコピーしておく必要があります。 詳しくは先のApiProxy.Delegateの説明に書いた内容を参考にしてください。

AppEngine環境を終了する

各テストやテストケースの実行後に、起動した環境を終了する処理が必要となります。 JUnit4なら、起動処理を@Before@BeforeClassで行っているでしょうから、 それに対応する@After@AfterClassで修飾されたメソッド内に実装することになります。

ApiProxy.setDelegate(null);
ApiProxy.setEnvironmentForCurrentThread(null);

基本的には上記の処理だけで良いですが、local_db.binがクローズされるタイミング等の問題で 上記の処理だけではなく以下のようにしておいた方が良い場合もあるかもしれません。

if (ApiProxy.getDelegate() != null) {
  ((ApiProxyLocal) ApiProxy.getDelegate()).stop();
}
ApiProxy.setDelegate(null);
ApiProxy.setEnvironmentForCurrentThread(null);

目的のテストを記述し、実行する

上記の準備を整えるだけで単純なテストは既に実行できる状態になっています。以下はその全体の例です。

import java.io.File;
import java.util.*;
import org.junit.*;
import com.google.appengine.api.datastore.*;
import com.google.appengine.api.datastore.dev.LocalDatastoreService;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.Environment;

import static org.junit.Assert.assertEquals;

public class DatastoreTest {

  @Test public void datastoreTest01() {
    Entity parent = new Entity("child");
    parent.setProperty("prop1", "fuga");
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    service.put(parent);

    int size = service.prepare(new Query("child")).asList(FetchOptions.Builder.withOffset(0)).size();
    assertEquals(1, size);
  }
  
  @Test public void datastoreTest02() {
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    Key parentKey = service.allocateIds("parent", 1).getStart();
    Iterator<Key> childKeys = service.allocateIds(parentKey, "child", 2).iterator();
    Key childKey1 = childKeys.next();
    Key childKey2 = childKeys.next();
    Entity parent = new Entity(parentKey);
    parent.setProperty("prop1", "hoge");
    Entity child1 = new Entity(childKey1);
    child1.setProperty("prop1", "foo");
    Entity child2 = new Entity(childKey2);
    child2.setProperty("prop1", "bar");
    service.put(Arrays.asList(parent, child1, child2));

    int size = service.prepare(new Query("child")).asList(FetchOptions.Builder.withOffset(0)).size();
    assertEquals(2, size);
  }

  @Before
  public void setUp() {
    ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(new File("bin/datastoreTest")) {};
    Environment environment = new UnitTestEnvironment();
    ApiProxy.setEnvironmentForCurrentThread(environment);
    ApiProxy.setDelegate(apiProxyLocal);
  }

  @After
  public void tearDown() {
    ApiProxyLocal apiProxyLocal = (ApiProxyLocal) ApiProxy.getDelegate();
    if (apiProxyLocal != null) {
      LocalDatastoreService datastoreService = (LocalDatastoreService) apiProxyLocal.getService(LocalDatastoreService.PACKAGE);
      datastoreService.clearProfiles();
      apiProxyLocal.stop();
    }
    ApiProxy.setDelegate(null);
    ApiProxy.setEnvironmentForCurrentThread(null);
  }
}

おまけ: 開発環境での各サービスの実装クラス

特に使用する機会は無いと思いますが、開発環境でサービスの実装の振る舞いを変更する設定値だとかを知りたいときなどに役に立つかもしれません。全てのクラスがcom.google.appengine.tools.development.LocalRpcServiceを実装しており、 ${appengine-sdk}/lib/impl/appengine-api-stubs.jarに含まれています。
一覧の各サービス名にくっついているカッコ内太字で表記されている文字列はApiProxy.Delegate#makeSyncCall()への引数として渡されるサービスの内部的な名称です。 これは開発環境ではcom.google.appengine.tools.development.LocalRpcServiceString getPackage()として定義されています。

BlobstoreService(blobstore)
com.google.appengine.api.blobstore.dev.LocalBlobstoreService
DatastoreService(datastore_v3)
com.google.appengine.api.datastore.dev.LocalDatastoreService
ImagesService(images)
com.google.appengine.api.images.dev.LocalImagesService
MailService(mail)
com.google.appengine.api.mail.dev.LocalMailService
MemcacheService(memcache)
com.google.appengine.api.memcache.dev.LocalMemcacheService
TaskQueue(taskqueue)
com.google.appengine.api.labs.taskqueue.dev.LocalTaskQueue
URLFetchService(urlfetch)
com.google.appengine.api.urlfetch.dev.LocalURLFetchService
UserService(user)
com.google.appengine.api.users.dev.LocalUserService
XmppService(xmpp)
com.google.appengine.api.xmpp.dev.LocalXmppService

追記

@mokkouyouさんが誤字を指摘してくれましたので、修正しました。

コメントを投稿