2010年1月5日火曜日

#AppEngine 用のアプリケーションの自動テストについて(2) - Datastoreに関するテスト

前回に続く、テストシリーズ第二回です。

テストに必要な初期テストデータ

Datastoreの操作をテストする際に初期データを必要としないものは特に準備は必要ありませんが、初期データを投入済みの状態でテストを開始したい場合もよくあります。 そういった時にsetUp()setUpBeforeClass()メソッドで初期データを投入する方法がありますが、 その方法だと初期データが大きい時にテストに時間がかかってしまうという問題があり、スローテストと呼ばれる問題を引き起こしてしまいます。

そこでAppEngineの開発環境でのDatastoreの特徴を確認すると、メモリ上で実行するオプションを指定しない限りは テストフォルダ配下の"WEB-INF/appengine-generated/local_db.bin" というファイルにデータが書き込まれます。 一度local_db.binに書き込まれたデータは、次回以降に同じテストフォルダから起動したときに、 起動直後から以前に書き込んだデータが存在している状態として再利用する事ができます。 そこで、以下のような手順でテストケースの仕組みを作っておくと便利です。

  1. テストデータ専用のフォルダを用意する。何種類かの初期テストデータを用意することも多いので、 例えば"testdata/${初期データセット名}"のようにテストデータのルートフォルダ配下にさらにフォルダを用意すると良いかもしれません。
  2. テストの直前に、上記フォルダにすでに初期テストデータが存在するかをチェックし、存在していない場合は初期データを作成する
    1. 初期テストデータを作成する処理もテスト時と同様にAppEngine環境を起動した後で通常のDatastore操作と同じ処理を行えば良いが、 AppEngine環境を起動する際のApiProxyLocalImplクラスのコンストラクタには "testdata/dataset1"のように初期テストデータ用のフォルダを指定する。
    2. 初期テストデータを作成した後は一旦AppEngine環境を終了する。
  3. すでに初期テストデータが存在する状態であれば、テスト用フォルダに初期テストデータフォルダをコピーする。
  4. "war/WEB-INF/datastore-indexes.xml"が存在していれば、それをテスト用フォルダ配下のWEB-INFフォルダにコピーする。
  5. 初期テストデータが投入された状態でテストを行う

このように"testdata/${初期データセット名}/WEB-INF"をテスト用のフォルダにコピーするだけで初期データの準備ができるため、 テストの準備にかかる時間も大幅に節約することができるようになります。 また、初期テストデータもSCMで管理すれば初期テストデータの内容を変えた場合以外はその作成処理にかかる時間も節約できます。

以下のソースはこれらの処理を行う例です。

import java.io.*;
import java.util.*;

import org.apache.commons.io.FileUtils;
import org.junit.*;
import com.google.appengine.api.datastore.*;
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 DatastoreTest2 {

  @Test public void datastoreTest01() {
    // 何も操作していないが、初期データが読み込まれている。
    int size = DatastoreServiceFactory.getDatastoreService().prepare(
        new Query("child")).asList(FetchOptions.Builder.withOffset(0)).size();
    assertEquals(2, size);
  }

  @Test public void datastoreTest02() {
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    Entity entity = new Entity("child");
    service.put(entity);
    int size = service.prepare(new Query("child")).asList(
        FetchOptions.Builder.withOffset(0)).size();
    assertEquals(3, size);
  }

  @Before public void setUp() throws IOException {
    File initialDataFolder = new File("testdata/2/");
    if (initialDataFolder.exists() == false) {
      // 初期テストデータが存在しなければ作成する
      createinitialData(initialDataFolder);
    }
    // 初期テストデータをテストフォルダにコピーする
    File testFolder = new File("bin/2/");
    FileUtils.copyDirectory(initialDataFolder, testFolder);
    // インデクス定義ファイルが存在すれば、それもテストフォルダにコピーする
    File datastoreIndexes = new File("war/WEB-INF/datastore-indexes.xml");
    if (datastoreIndexes.exists()) {
      FileUtils.copyFile(datastoreIndexes, new File("bin/2/WEB-INF/datastore-indexes.xml"));
    }
    setUpAppEngine(testFolder);
  }

  @After public void tearDown() {
    tearDownAppEngine();
  }

  static void createinitialData(File initialDataFolder) {
    try {
      setUpAppEngine(initialDataFolder);
      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));
    } finally {
      tearDownAppEngine();
    }
  }

  static void setUpAppEngine(File testFolder) {
    ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(testFolder) {};
    Environment environment = new UnitTestEnvironment();
    ApiProxy.setEnvironmentForCurrentThread(environment);
    ApiProxy.setDelegate(apiProxyLocal);
  }

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

Exceptionのシミュレート

例えばDatastore操作を行う際、すべての操作についてApiProxy.DatastoreTimeoutException例外が投げられる可能性を考慮する必要があります。 こういったAppEngine特有の例外への対応が実装されているか?もできるだけテストしておいた方が良いと思われます。

そういった際に使用できるのがApiProxy#setDelegate()を使ったApiProxy.Delegateの入れ替えです。 サービスの実行をフックするような処理を行うことになります。

ApiProxy.Delegate

byte[] makeSyncCall(Environment env, String service, String method, byte[] request)
もっとも重要なメソッドで、基本的にすべてのサービスの実行時にこのメソッドを経由します。
Environment env
アプリケーションが実行されているスレッドで使用されているEnvironementです。
String service
実行しようとしているサービス名です。"datastore_v3"や"memcache"等の文字列です。
String method
実行しようとしているサービスのメソッド名です。Datastoreサービスなら"Put"や"Get"等の文字列です。
byte[] request
実行しようとしているサービスのメソッドへ渡す引数をProtocolBufferでシリアライズしたバイト配列です。
Future<byte[]> makeAsyncCall(Environment env, String service, String method, byte[] request, ApiConfig config)
1.3.0が最新リリースの時点ではあまり気にする必要がありません(気になる人は"makeAsyncCall"でぐぐってみましょう)。
void log(Environment env, LogRecord logRecord)
ログ出力用のメソッドですが、重要ではありません。

以下に、Datasotreへの一回目のリクエスト時に必ずDatastoreTimeoutExceptionが発生するApiProxy.Delegateの例をしめします。 makeSyncCallメソッド以外は、最初に保存したApiProxyLocalに丸投げをしています。

class TimeoutExceptionDelegate implements ApiProxy.Delegate<Environment> {
  @SuppressWarnings("unchecked")
  public final ApiProxy.Delegate<Environment> apiProxyLocal = ApiProxy.getDelegate();
  boolean first = true;
  
  @Override
  public byte[] makeSyncCall(Environment env, String service, String method, byte[] request)
      throws ApiProxyException {
    if (first && service.equals("datastore_v3")) {
      first = false;
      throw new DatastoreTimeoutException("TimeoutExceptionDelegate");
    }
    return apiProxyLocal.makeSyncCall(env, service, method, request);
  }

  @Override
  public Future<byte[]> makeAsyncCall(Environment env, String service,
      String method, byte[] request, ApiConfig config) {
    return apiProxyLocal.makeAsyncCall(env, service, method, request, config);
  }

  @Override
  public void log(Environment env, LogRecord logRecord) {
    apiProxyLocal.log(env, logRecord);
  }
};

通常のAppEngine環境の起動後にこのクラスを適用し、DatastoreTimeoutExceptionに対応しているかどうかを確認するためのテストケースの例が以下のようになります。

  1. setUp時に通常どおりAppEngine環境の起動を行い、その後でApiProxy#setDelegate()TimeoutExceptionDelegateを設定しています。
  2. その後テストをおこなっていますが、TimeoutExceptionへ未対応のテストは当然TimeoutExceptionDelegateが発生し、エンティティの保存が行われません。 TimeoutExceptionへ対応したテストはエンティティが正常に保存されます。
  3. tearDown時に、まずApiProxy#getDelegate()TimeoutExceptionDelegateかどうかをチェックし、 TimeoutExceptionDelegateだった場合はそのインスタンスに保存されたApiProxyLocalを取得し、ApiProxy#setDelegate()で設定することで元に戻しています。
  4. その後通常通りAppEngine環境の終了処理を行っています。

リトライ対応したメソッドで成功時にbreakが抜けていたので追加しました、指摘してくれた@bluerabit777jpさんThx!

import java.io.*;

import org.junit.*;
import com.google.appengine.api.datastore.*;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

import static org.junit.Assert.assertNotNull;

public class DatastoreTest3 {

  @Test(expected=DatastoreTimeoutException.class)
  public void datastoreTest01() {
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    Entity entity = new Entity("child");
    service.put(entity);
  }

  @Test public void datastoreTest02() {
    DatastoreService service = DatastoreServiceFactory.getDatastoreService();
    Entity entity = new Entity("child");
    Key key = null;
    for (int i = 0; i < 5; i++) { // 最大5回リトライする
      try {
        key = service.put(entity);
        break;
      } catch (DatastoreTimeoutException e) {
        continue;
      }
    }
    assertNotNull(key);
  }

  @Before public void setUp() throws IOException {
    setUpAppEngine(new File("bin/3"));
    ApiProxy.setDelegate(new TimeoutExceptionDelegate());
  }

  @After public void tearDown() {
    @SuppressWarnings("unchecked")
    Delegate<Environment> delegate = ApiProxy.getDelegate();
    if (delegate instanceof TimeoutExceptionDelegate) {
      ApiProxy.setDelegate(((TimeoutExceptionDelegate)delegate).apiProxyLocal);
    }
    tearDownAppEngine();
  }

  static void setUpAppEngine(File testFolder) {
    ApiProxyLocal apiProxyLocal = new ApiProxyLocalImpl(testFolder) {};
    Environment environment = new UnitTestEnvironment();
    ApiProxy.setEnvironmentForCurrentThread(environment);
    ApiProxy.setDelegate(apiProxyLocal);
  }

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

このようにApiProxy#setDelegate()を使ったサービス実行をフックする事ができます。DatastoreTimeoutException以外にも、データストアのメンテナンス中の時に発生するCapabilityDisabledException等もシミュレートすることができます。

コメントを投稿