ラベル Test の投稿を表示しています。 すべての投稿を表示
ラベル Test の投稿を表示しています。 すべての投稿を表示

2010年1月9日土曜日

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

下記のAppEngineアプリケーションの自動テストシリーズに続く、第五回目です。

URLFetchサービスのテスト?シミュレート?

URLFetchのテストと言っても、目的は2種類分かれると思います。 ひとつは「想定通りのリクエストが組み立てられているか」という事です。 こちらはこれまで説明してきた通りの 「サービスをフックしてサービスへリクエストとして送信されるバイト配列をJavaオブジェクトとして組立て直す」という方法でテストができます。

もうひとつの目的は「実際のホストへ通信せずにローカルのテストデータをレスポンスとして使用する」という事です。 自動テスト環境が常に外部に接続された状態とも限りませんし、外部のサービスの影響で自動テストが失敗するといった事態も避けるためには必要な仕組みです。
この仕組を実現するにはこれまでに説明していきたサービスへの通信部分をフックする仕組みを使うことには変わりませんが、それに加えて ApiProxyLocalの実装に処理を委譲するのではなくサービスからのレスポンスも自前で組み立てる という仕組みが必要になります。

java.net.URLを使用したURLFetchを行っている場合の問題

さらに、URLFetchサービスのシミュレートにはもうひとつ問題があります。
例えばDatastoreサービスへのインターフェースとして「JDO/JPA」「低レベルAPI」の複数種類が提供されているのと同様に、 URLFetchサービスへのインターフェースとしては「低レベルAPI」「java.net.URL」のふたつが用意されています。 Datastoreサービスの「JDO/JPA」についてはそれらのAPIの裏側では結局低レベルAPIが使用されているのですが、 自分で起動したAppEngine環境では、URLFetchサービスのjava.net.URLを使った場合に低レベルAPIが使用されません。
つまり、これまで説明してきた「ApiProxy.Delegateを作成し、それをApiProxy#setDelegate()で設定して各種サービスの実行をフックする」 といった手法が簡単には使えないのです。低レベルAPIが使用された時のみApiProxy.Delegate#makeSyncCall()を通るのです。

絶望したっ…となりそうですが、実はプロダクション環境ではjava.net.URLを使った場合でも低レベルAPIが使用される という動作をします。試さなくても「URLFetchの動作に制限がある」というAppEngineの仕様からして、 「プロダクション環境では各種サービスのノード群経由で通信している=ApiProxy.Delegate#makeSyncCall()を通る」はず、という事がわかっているのです。
この振る舞いの差はURL#openConnection()で生成され、実際に通信処理を行うjava.net.URLConnectionの実装クラスの違いにあります。 通常はURL#openConnection()するとsun.net.www.protocol.http.HttpURLConnectionが取得されますが、 AppEngineのプロダクション環境や開発環境で提供されているWebコンテナから起動した環境ではcom.google.apphosting.utils.security.urlfetch.URLFetchServiceStreamHandler$Connection が取得されます。つまりURL#openConnection()した時に生成されるjava.net.URLConnectionをプロダクション環境と同じになるように設定すれば良いのです。 これは、java.net.URL#setURLStreamHandlerFactory()でファクトリを設定することで可能になりますが、 AppEngineのSDK内でもこの設定を行っている箇所があるのでそれを利用した方がラクです。
com.google.appengine.tools.development.StreamHandlerFactoryクラスにinstall()というメソッドがあり、 これを実行すれば目的どおりプロダクション環境と同じ振る舞いをするようになります。例えば以下のコードを実行してみるとこれらの事がよく見えます。

System.out.println(new URL("http://localhost/").openConnection().getClass());
com.google.appengine.tools.development.StreamHandlerFactory.install();
System.out.println(new URL("http://localhost/").openConnection().getClass());

この処理の実行結果は以下のようになります。 一度StreamHandlerFactory.install()を実行するとそれ移行のURL#openConnection() は常にsun.net.www.protocol.http.HttpURLConnectionを返すようになります。

class sun.net.www.protocol.http.HttpURLConnection class com.google.apphosting.utils.security.urlfetch.URLFetchServiceStreamHandler$Connection

URLFetchのリクエスをテストし、レスポンスをカスタマイズするApiProxy.Delegateの実装

今回も前回までの説明と同じように、サービスへのリクエストをフックする ApiProxy.Delegateを作成してApiProxy#setDelegate()でそれを適用する手法でテストします。 前回まではサービスへリクエストされるバイト配列を組み立ててそれをリストに保持してから本来のサービスを実行していましたが、 今回は実際のサービスを実行しないため、ApiProxy#setDelegate()にハンドラとなるクラスをコンストラクタで受け取って、 それをコールバックする事でハンドラにレスポンスの組み立てをさせます。ハンドラ側では、リクエストの評価とレスポンスの組み立てを行うことになります。

import java.io.IOException;
import java.util.concurrent.Future;
import com.google.appengine.api.urlfetch.URLFetchServicePb.*;
import com.google.appengine.repackaged.com.google.protobuf.ByteString;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

public class URLFetchDelegate implements ApiProxy.Delegate<Environment> {
  @SuppressWarnings("unchecked")
  public final ApiProxy.Delegate<Environment> apiProxyLocal = ApiProxy.getDelegate();

  public interface Handler {
    void handle(URLFetchRequest request) throws IOException;
    byte[] getContent(URLFetchRequest request) throws IOException;
    int getStatusCode(URLFetchRequest request) throws IOException;
  }

  final Handler handler;

  public URLFetchDelegate(Handler handler) {
    this.handler = handler;
  }

  public byte[] makeSyncCall(Environment env, String service, String method,
      byte[] request) throws ApiProxyException {
    if (service.equals("urlfetch") && method.equals("Fetch")) {
      try {
        URLFetchRequest requestPb = URLFetchRequest.parseFrom(request);
        handler.handle(requestPb);
        return URLFetchResponse.newBuilder()
          .setContent(ByteString.copyFrom(handler.getContent(requestPb)))
          .setStatusCode(handler.getStatusCode(requestPb))
          .build().toByteArray();
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    } else {
      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);
  }
}

ハンドラのインターフェースには、リクエストを評価するための「handle()」、 レスポンスのbody部分を組み立てる「byte[] getContent()」、レスポンスコードを決定する「int getStatusCode()」を定義しました。

Delegateからコールバックされるハンドラの例

先のURLFetchDelegate.Handlerインターフェースの実装となります。

public void handle(URLFetchRequest request) throws IOException {
  assertEquals(REQUEST_URL, request.getUrl());
  assertEquals(RequestMethod.POST, request.getMethod());
  Map<String, Header> headerMap = getHeaderMapFromRequests(request);
  assertEquals(1, headerMap.size());
  assertEquals("HeaderValue", headerMap.get("X-Header").getValue());
  String payload = URLDecoder.decode(request.getPayload().toString("utf-8"), "utf-8");
  assertEquals("param=自動テスト", payload);
}

public byte[] getContent(URLFetchRequest request) throws IOException {
  return IOUtils.toByteArray(new FileReader("testdata/urlfetch/response1.txt"), "utf-8");
}

public int getStatusCode(URLFetchRequest request) throws IOException {
  return 200;
}

後で例として紹介するテストケースで、低レベルAPIとjava.net.URLを使ってそれぞれ全く同じリクエストをテストする例を上げているため、 上記の例では一種類のリクエストを評価・レスポンスする実装になっています。実際にはもっと複雑で、 リクエストのURLやパラメータを判断してgetContent()やgetStatusCode()で返す値を変えたりすることになると思います。
上記の例ではレスポンスする内容に"testdata/urlfetch/response1.txt"という外部のファイルを使用しています。 ファイルにそのまま記述しておくことでテストデータの管理もしやすいと思います。またファイルやバイト配列の処理をラクにするためにcommons-ioを使用しています。

テストケースの例

このApiProxy.Delegateの実装を使ったテストケースの例は以下のようになります。 先のエントリで説明したとおりにUnitTestEnvironment#getAttribute()メソッドの修正が行われていることが前提です。

また、ファイルのコピーを簡単にするために、commons-ioを利用しています。

import java.io.*;
import java.net.*;
import java.util.*;
import java.util.Map.Entry;
import org.apache.commons.io.IOUtils;
import org.junit.*;
import com.google.appengine.api.urlfetch.*;
import com.google.appengine.api.urlfetch.URLFetchServicePb.URLFetchRequest;
import com.google.appengine.api.urlfetch.URLFetchServicePb.URLFetchRequest.*;
import com.google.appengine.repackaged.com.google.protobuf.Descriptors.FieldDescriptor;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

import static org.junit.Assert.assertEquals;

public class URLFetchTest {

  static final String REQUEST_URL = "http://uso800.co.jp/detarame";

  URLFetchDelegate delegate = new URLFetchDelegate(new URLFetchDelegate.Handler() {
    ...省略...
  });

  @Test public void lowLevel() throws MalformedURLException, IOException {
    ApiProxy.setDelegate(delegate);

    URLFetchService service = URLFetchServiceFactory.getURLFetchService();
    HTTPRequest httpRequest = new HTTPRequest(new URL(REQUEST_URL), HTTPMethod.POST);
    httpRequest.addHeader(new HTTPHeader("X-Header", "HeaderValue"));
    String queryString = "param=" + URLEncoder.encode("自動テスト", "utf-8");
    httpRequest.setPayload(queryString.getBytes("utf-8"));
    HTTPResponse response = service.fetch(httpRequest);
    assertEquals(200, response.getResponseCode());
    assertEquals("hogehoge", new String(response.getContent()));
  }

  @Test public void standardAPI() throws MalformedURLException, IOException {
    ApiProxy.setDelegate(delegate);

    URL url = new URL(REQUEST_URL);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    conn.setRequestMethod("POST");
    conn.setRequestProperty("X-Header", "HeaderValue");
    conn.setDoOutput(true);
    IOUtils.write("param=" + URLEncoder.encode("自動テスト", "utf-8"), conn.getOutputStream());
    IOUtils.closeQuietly(conn.getOutputStream());
    assertEquals(200, conn.getResponseCode());
    assertEquals("hogehoge", new String(IOUtils.toByteArray(conn.getInputStream())));
  }

  @Before public void setUp() throws IOException {
    setUpAppEngine(new File("bin/urlfetchtest"));
  }

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

  @BeforeClass public static void setUpBeforeClass() {
    com.google.appengine.tools.development.StreamHandlerFactory.install();
  }

  static Map<String,Header> getHeaderMapFromRequests(URLFetchRequest request) {
    Map<String,Header> headerMap = new HashMap<String, Header>();
    Iterator<Entry<FieldDescriptor, Object>> i = request.getAllFields().entrySet().iterator();
    while (i.hasNext()) {
      Entry<FieldDescriptor, Object> next = i.next();
      FieldDescriptor key = next.getKey();
      if (key.getFullName().equals("apphosting.URLFetchRequest.header")) {
        @SuppressWarnings("unchecked")
        Collection<Header> headers = (Collection<Header>) next.getValue();
        for (Header header: headers) {
          headerMap.put(header.getKey(), header);
        }
      }
    }
    return headerMap;
  }

  static void setUpAppEngine(File testFolder) {
    com.google.appengine.tools.development.StreamHandlerFactory.install();
    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);
  }
}

「省略」となっている箇所は、先に説明したURLFetchDelegate.Handlerインターフェースの実装となります。
ここでは説明をわかりやすくするためにテストメソッド内でURLFetch処理を行っていますが、 実際はテスト対象であるサービス層の機能が、内部的にURLFetchサービスの実行を行う事が多いんじゃないかと思います。

追記

公開した直後は、テストケースの例のsetUp()メソッド内にHTTPURLConnectionの実装クラスを調べるためのコードが残っていました。が、これは必要ないので削除しました。ついでに com.google.appengine.tools.development.StreamHandlerFactory.install(); は一回だけ実行すれば良いのでsetUp()からsetUpBeforeClass()に移動しました。@bufferingsさんが指摘してくれました、Thx!

2010年1月6日水曜日

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

下記のAppEngineアプリケーションの自動テストシリーズに続く、第四回目です。

Taskの「投入」をテストする

TaskQueueのテストといっても「Taskの投入」をテストするのか「Taskの実行」をテストするのか、といった2種類のテスト対象が考えられます。 今回は「Taskの投入」をテストする説明をします。「WebHandlerによるTask実行」については、例えば私はテストしやすいように以下のような手法で実装しています。

  • 何らかの機能で、Taskを投入する
  • 投入されたTaskはWebHandler内で実行することになるが、WebHandler内にはほとんど処理を書かず、 int HogeTask.execute(Map<String, String> parameters)という、リクエストパラメータを渡してHttpStatusを返すようなメソッドに処理を委譲する

このようにしておく事で、「Taskの実行」は通常の処理と同じように簡単にテストできる事が多いです。

今回も前回と同じようにサービスへのリクエストをフックする ApiProxy.Delegateを作成し、ApiProxy#setDelegate()でそれを適用する手法でテストします。

Taskの投入をテストするためのApiProxy.Delegateの実装

例えばこのようなApiProxy.Delegateの実装を見てみましょう。 前回とほぼ同じ実装で、サービスへリクエストされたバイト配列を組み立てるオブジェクトが com.google.appengine.api.labs.taskqueue.TaskQueuePb.TaskQueueAddRequestに変わっている程度です。

import java.util.*;
import java.util.concurrent.Future;
import com.google.appengine.api.labs.taskqueue.TaskQueuePb.TaskQueueAddRequest;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

public class TaskQueueDelegate implements ApiProxy.Delegate<Environment> {
  @SuppressWarnings("unchecked")
  public final ApiProxy.Delegate<Environment> apiProxyLocal = ApiProxy.getDelegate();
  
  public final List<TaskQueueAddRequest> tasks = new ArrayList<TaskQueueAddRequest>();
  
  public byte[] makeSyncCall(Environment env, String service, String method,
      byte[] request) throws ApiProxyException {
    if (service.equals("taskqueue") && method.equals("Add")) {
      try {
        TaskQueueAddRequest taskPb = new TaskQueueAddRequest();
        taskPb.mergeFrom(request);
        tasks.add(taskPb);
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }
    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);
  }
}

前回と同様にサービスへリクエストするバイト配列から ProtocolBufferオブジェクトを組み立てて、テストケースから評価できるようにそれを保持しているだけです。

TaskQueueサービスを起動するための準備

以下の二つの手順が必要になります。

  1. ApiProxy#setCurrentEnvironment()に設定するApiProxy.Environmentで実装するメソッドに Map<String, String> ApiProxy.Environment#getAttribute()があるが、 これが返すMapオブジェクトは"com.google.appengine.server_url_key"というキーに何か値が設定されている必要がある。
  2. デフォルトのキュー以外のキューを使用するのであれば、war/WEB-INF/queue.xmlをテスト用のフォルダにコピーしておく必要がある。

最初の手順については、第一回目の説明で UnitTestEnvironmentというクラス名で説明していたApiProxy.EnvironmentのMap<String, String> ApiProxy.Environment#getAttribute()メソッドの実装を修正する必要があります。

public java.util.Map<String, Object> getAttributes() {
  java.util.Map<String, Object> map = new java.util.HashMap<String, Object>();
  map.put("com.google.appengine.server_url_key", "dummy");
  return map;
}

テストケースの例

このApiProxy.Delegateの実装を使ったテストケースの例は以下のようになります。 デフォルトキューにTaskを追加する処理と、名前付きのキューにTaskを追加する処理のふたつをテストしています。 先に説明したとおりにUnitTestEnvironment#getAttribute()メソッドの修正が行われていることが前提です。

また、ファイルのコピーを簡単にするために、commons-ioを利用しています。

import java.io.*;
import java.net.URLEncoder;
import org.apache.commons.io.FileUtils;
import org.junit.*;
import com.google.appengine.api.labs.taskqueue.*;
import com.google.appengine.api.labs.taskqueue.TaskQueuePb.*;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;
import static org.junit.Assert.assertEquals;

public class TaskQueueTest {

  @Test public void addTaskToDefaultQueue() throws UnsupportedEncodingException {
    TaskQueueDelegate delegate = new TaskQueueDelegate();
    ApiProxy.setDelegate(delegate);
    
    Queue queue = QueueFactory.getDefaultQueue();
    queue.add(TaskOptions.Builder.url("/tqHandler").param("key", "あいうえお"));

    assertEquals(1, delegate.tasks.size());
    TaskQueueAddRequest task = delegate.tasks.get(0);
    assertEquals("/tqHandler", task.getUrl());
    assertEquals("key="+URLEncoder.encode("あいうえお", "utf-8"), task.getBody());
  }

  @Test public void addTaskToNamedQueue() throws UnsupportedEncodingException {
    TaskQueueDelegate delegate = new TaskQueueDelegate();
    ApiProxy.setDelegate(delegate);
    
    Queue queue = QueueFactory.getQueue("background-processing");
    queue.add(TaskOptions.Builder.url("/tqHandler").param("key", "あいうえお"));
    
    assertEquals(1, delegate.tasks.size());
    TaskQueueAddRequest task = delegate.tasks.get(0);
    assertEquals("/tqHandler", task.getUrl());
    assertEquals("key="+URLEncoder.encode("あいうえお", "utf-8"), task.getBody());
  }

  @Before public void setUp() throws IOException {
    File testFolder = new File("bin/tqtest");
    File queueXml = new File("war/WEB-INF/queue.xml");
    if (queueXml.exists()) {
      FileUtils.copyFile(queueXml, new File("bin/tqtest/WEB-INF/queue.xml"));
    }
    setUpAppEngine(testFolder);
  }

  @After public void tearDown() {
    @SuppressWarnings("unchecked")
    Delegate<Environment> delegate = ApiProxy.getDelegate();
    if (delegate instanceof TaskQueueDelegate) {
      ApiProxy.setDelegate(((TaskQueueDelegate) 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);
  }
}

ここでは説明をわかりやすくするためにテストメソッド内でTask投入処理を行っていますが、 実際はテスト対象であるサービス層の機能が、内部的にTaskの投入やデータストアサービスを行う事が多いんじゃないかと思います。

2010年1月5日火曜日

#AppEngine 用のアプリケーションの自動テストについて(3) - メール送信に関するテスト

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

開発環境ではメールは送信されない

見出しに書いた通り、AppEngineの開発環境ではメール送信サービスを使ってメールを送信しても実際にはメールは送信されません。 メールが送信されたところで自動テストでそれを判断するのは面倒そうですが、 AppEngine環境のサービス実行の仕組みを利用する事でメール送信の成否を自動テストできるようにする方法を説明します。

前回のDatastoreのExceptionをシミュレートする仕組みと同様に、 ApiProxy#setDelegate()を使ってサービスの実行をフックする方法を使います。 前回と比べるとフックを行う対象となるサービスとメソッドがデータストアサービスからメール送信サービスになるという当たり前の違いがあるのですが、 それ以上に違う点として「前回はサービスとそのメソッド名を判断するだけ」だったのに加えて「サービスへ送信されるリクエストを解析する」という処理が加わります。

今回は「メールが送信された」という事の確認に加えて「どんな内容で送信されたか」も確認しようと思いますので、 サービス側へ送られるリクエストを、バイト配列からJavaで扱えるオブジェクトに変換して、それをJUnitでassertします。 第二回で すべてのサービスの実行時に経由するbyte[] makeSyncCall(Environment env, String service, String method, byte[] request) というメソッドの第三引数byte[] requestは実行しようとしているサービスのメソッドへ渡す引数をProtocolBufferでシリアライズしたバイト配列 だという事を説明しました。 第二回で作成したApiProxy.Delegateではこのパラメータには触れませんでしたが、 今回はこれを利用することになります。

例えばこのようなApiProxy.Delegateの実装を見てみましょう。

import java.util.*;
import java.util.concurrent.Future;
import com.google.appengine.api.mail.MailServicePb.MailMessage;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;

public class SendMailDelegate implements ApiProxy.Delegate<Environment> {
  @SuppressWarnings("unchecked")
  public final ApiProxy.Delegate<Environment> apiProxyLocal = ApiProxy.getDelegate();
  
  public final List<MailMessage> messages = new ArrayList<MailMessage>();
  
  public byte[] makeSyncCall(Environment env, String service, String method,
      byte[] request) throws ApiProxyException {
    if (service.equals("mail") && method.startsWith("Send")) { // Send[ToAdmins]
      try {
        MailMessage messagePb = new MailMessage();
        messagePb.mergeFrom(request);
        messages.add(messagePb);
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }
    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);
  }
}

今回もmakeAsyncCall()メソッドとlog()メソッドは気にする必要はありません。 インスタンス変数で既存のApiProxy#getDelegate()で取得できるApiProxy.Delegateを保持している点もこれまでと同様です。

一番重要なmakeSyncCall()メソッド内では"mail"サービスで、"Send"で始まるメソッドを対象にフックを行っています。 "mail"サービスにはメールを送信するメソッドとして"Send"と"SendToAdmin"というメソッドがあり、その両方をフックスするためです。 フックする対象の通信が行われようとした時には以下のような処理を行っています。

  1. com.google.appengine.api.mail.MailServicePb.MailMessageクラスのインスタンスを作成し、 第三引数で渡されたバイト配列を作成したインスタンスのmergeFromメソッドに渡す。
  2. メンバ変数として保持しているリストに作成したインスタンスを追加する。

最初の処理は、典型的なProtocolBufferオブジェクトを組み立てる処理です。 mergeFromメソッドはcom.google.appengine.repackaged.com.google.io.protocol.ProtocolMessage<T>クラスで定義されています。 AppEngineの各サービスへリクエストする/レスポンスを受けるためにバイト配列がやりとりされますが、 これを組み立てるために使用されるProtocolBufferオブジェクトは殆どがこのクラスのサブクラスとして実装されています。

二番目の処理は、テストケースから送信したメールの内容を評価できるように保持するための処理です。

このApiProxy.Delegateを使ったテストケースの例は以下のようになります。

import java.io.*;
import org.junit.*;
import com.google.appengine.api.mail.MailServiceFactory;
import com.google.appengine.api.mail.MailService.Message;
import com.google.appengine.api.mail.MailServicePb.MailMessage;
import com.google.appengine.tools.development.*;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.*;
import static org.junit.Assert.assertEquals;

public class SendMailTest {

  @Test public void sendmail() throws IOException {
    SendMailDelegate sendMailDelegate = new SendMailDelegate();
    ApiProxy.setDelegate(sendMailDelegate);

    Message message1 = new Message();
    message1.setTo("foo@bar.com");
    message1.setSender("hoge@fuga.com");
    message1.setSubject("一通目のタイトルです!");
    message1.setTextBody("こんにちは、一通目の本文です。");
    MailServiceFactory.getMailService().sendToAdmins(message1);
    
    Message message2 = new Message();
    message2.setSender("hoge@fuga.com");
    message2.setSubject("二通目のタイトルです!");
    message2.setTextBody("こんにちは、二通目の本文です。");
    MailServiceFactory.getMailService().sendToAdmins(message2);

    assertEquals(2, sendMailDelegate.messages.size());
    MailMessage messagePb1 = sendMailDelegate.messages.get(0);
    assertEquals(messagePb1.getTo(0), "foo@bar.com");
    assertEquals(messagePb1.getSender(), "hoge@fuga.com");
    assertEquals(messagePb1.getSubject(), "一通目のタイトルです!");
    assertEquals(messagePb1.getTextBody(), "こんにちは、一通目の本文です。");
    MailMessage messagePb2 = sendMailDelegate.messages.get(1);
    assertEquals(messagePb2.getSender(), "hoge@fuga.com");
    assertEquals(messagePb2.getSubject(), "二通目のタイトルです!");
    assertEquals(messagePb2.getTextBody(), "こんにちは、二通目の本文です。");
  }

  @Before
  public void setUp() {
    setUpAppEngine(new File("bin/mailtest"));
  }

  @After
  public void tearDown() {
    @SuppressWarnings("unchecked")
    Delegate<Environment> delegate = ApiProxy.getDelegate();
    if (delegate instanceof SendMailDelegate) {
      ApiProxy.setDelegate(((SendMailDelegate) 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);
  }
}

ここでは説明をわかりやすくするためにテストメソッド内でメール送信処理を行っていますが、 実際はテスト対象であるサービス層の機能が、内部的にメール送信サービスやデータストアサービスを行う事が多いんじゃないかと思います。

このようにmakeSyncCallメソッド内で、各サービスにリクエストされるバイト配列を組み立てなおすような方法を使うことでテストができるようになる事もあります。 どのcom.google.appengine.repackaged.com.google.io.protocol.ProtocolMessage<T>クラスの実装を使えば良いのか、 については調査してみなければ分からないのですが、@marblejenkaさんが これに関する調査を行い易くするためのプロダクトを公開 してくれていますので、興味があれば参考にすると良いと思います。

#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等もシミュレートすることができます。

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.xmlやWEB-INF/queue.xmlです。 ApiProxyLocalImplはコンストラクタで指定されたAppEngine環境を起動するフォルダ配下に何かを書き込むだけではなく、 そのフォルダ配下のWEB-INF/datastore-indexes.xmlやWEB-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はテスト環境用のフォルダです。 binやtarget等の、出力用フォルダを指定しておくのが良いです。

また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.LocalRpcServiceでString 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さんが誤字を指摘してくれましたので、修正しました。

2009年11月7日土曜日

#appengine の「データストアの読み込み専用状態」をエミュレートする

先日もメンテナンスがあり、AppEngineのデータストアが読み込み専用状態になってましたね。データストアへの書き込みを行うアプリケーションではこの「読み込み専用状態」時の対策をする必要があるのですが、「読み込み専用状態」の時の振る舞いをテストする方法を書いておきます。自動テストができる範囲は広ければ広い程よいですもんね。

ApiProxy.Delegateを実装・適用するだけ

ApiProxy.Delegateを実装して、makeSyncCall()メソッド内で「Datastoreへの書き込み」の時に ApiProxy.CapabilityDisabledException( )を投げてやるだけ。私は下記のようなユーティリティを作ってテストしてます。

static <R>R runOnReadOnlyMode(Callable<R> callable) throws Exception {
  @SuppressWarnings("unchecked")
  final ApiProxy.Delegate<Environment> backupedDelegate = ApiProxy.getDelegate();
  Delegate<Environment> delegate = new Delegate<Environment>() {

    public void log(Environment arg0, LogRecord arg1) {
      backupedDelegate.log(arg0, arg1);
    }

    public byte[] makeSyncCall(Environment arg0, String service, String method, byte[] arg3)
        throws ApiProxyException {
      if (service.equalsIgnoreCase("datastore_v3")
          && (method.equalsIgnoreCase("Put") || method.equalsIgnoreCase("Delete"))) {
        throw new ApiProxy.CapabilityDisabledException(service, method);
      }
      return backupedDelegate.makeSyncCall(arg0, service, method, arg3);
    }
  };
  ApiProxy.setDelegate(delegate);
  try {
    return callable.call();
  } finally {
    ApiProxy.setDelegate(backupedDelegate);
  }
}

こんなカンジにできるので、他にもDatastoreTimeoutExceptionやらApiProxyException系の例外、例えばApiProxy.UnknownExceptionなんかが発生したときの振る舞いも簡単に自動試験できますね!

不思議

Datastoreについては上記の方法で簡単にApiProxy.CapabilityDisabledExceptionをエミュレートできるのですが、MemcacheServiceについては簡単には行かない事が判明しています。

  • MemcacheServiceは例外をキャッチした際に、それをログ出力するだけのハンドラでExceptionを握りつぶす実装になっている
  • 先日の読み込み専用モードの時にログが記録されていない気がする=Memcacheサービスは読み込み専用状態に書き込み要求を受けても例外をスローしない…?

前者についてはMemcacheService#setErrorHandler()メソッドに、new com.google.appengine.api.memcache.StrictErrorHandler()を設定してやればちゃんと例外が飛ぶのでまぁなんとかなるのですが、後者については次回のメンテナンスの時になるまで調査ができませんね。ひょっとするとMemcacheサービスの仕様なのかな?という気もします。「常にMemcacheから値を取得できるとは限らない」という前提なので、書き込みに失敗しても問題ない、という仕様なのかな?それであれば、そもそも試験する必要がありませんね。
どなたか前回のメンテナンス時にMemcacheサービスから書き込みエラーとか何かの例外を受け取った人がおられたら、わかる範囲で良いので教えてください!

読み込み専用モードを考慮してアプリ側で工夫できる事もある

がっつりデータストアに書き込む必要がある処理はどうしよーも無いのですが、例えばカウンタの記録など「書き込みがメインでは無いが、書き込み処理も付随する」ような機能はちょっと工夫しておけば読み込み専用時にも稼働させる事ができます。

私が稼働させているAppEngineアプリのひとつに空うさぎというFriendFeed/Twitter/RSSクライアントアプリケーションのファイル管理用の小さいアプリケーションがあるのですが、このアプリの中にファイルのダウンロード件数を記録する、という機能があります。この機能は「ファイルのイメージをレスポンスする」と同時に「ダウンロード数カウンタを増加させるTaskをQueueに投入する」という実装をしています。投入されたTaskはデータストアの書き込みに成功しない場合はHttpStatusの500を返すように実装しているので、読み込み専用時は常に500を返す事になります。その結果、読み込み専用モードが解除されるまではAppEngineのTaskQueueに残り続けるので、読み込み専用モードが解除された頃にはカウンタの整合性はいずれ正しくなる、という動作をします。AppEngineはまだベータバージョンですし、工夫でしのげる場所はうまく実装しておきたいですね。

2009年6月12日金曜日

Google Developer Day/Hackathon編

自分は「MailサービスとURLFetchサービスの単体テスト方法を探る」というカンジの、アプリを作るという事とははずれた事をテーマに作業した。なんとか発表には間に合わせたが、チームじゃなくて個人で作業した方も多かった事で発表者が非常に多くなり、あんまり時間をかけては迷惑だろう(発表順が二人目だった&人数から逆算すると3分くらいか?という計算)、という焦りと外部ネットワークに繋がらなかったという影響のため、実際に動作を見せる事ができなかった、という残念な結果に。その分このエントリで細かく書いておこう、と。ついでに、ハッカソン終了後の改良も追加しておくw

ちなみに、問題無く実行されると以下のように出力される予定でした。originalがプロダクトコードからの実際のリクエストやレスポンスで、modifiedがすげ替えたものの値…。

Mailサービス

Mailは簡単。ドキュメントもあるくらい。

URLFetchサービス

こいつが大変だった。ドキュメントを見ると、単体テスト時はダイレクトに実際のURLに接続します、みたいに書いてある。というのも、URLFetchに関しては通常のjava.net.HttpURLConnectionがを使うため、SDKが起動していない状態だとHttpURLConnection(sun.net.www.protocol.http.HttpURLConnectionとか)が適用されてしまい、ふっつーに指定されたURLにアクセスしてしまうため。

極力プロダクトコード側には仕組みを埋め込みたくない、となるとHttpURLConnectionを自前で実装してそれを適用させるのが近道じゃね?というのが最初の案。この方法なら低レベルでの話だしGAEも全然関係ないだろし、と。書いていくと、すぐに"URLStreamHandler"クラスにエラーマーカーが。

とはいえ所詮Eclipse上のGoogleプラグインのValidateだけの話でJava的にはエラーではないし、Testコードのため実行時には問題は起きないし、まぁいいか?無視しちゃう?とは思ったが、Eclipseを使う事が多い人はずっとエラーマーカーが残ったままってのは気持ち悪いはずなので別の方法を考えてみる(警告のマーカーだったら無視してたかもw)。

んじゃ手を抜くのはあきらめて、たぶんAppEngineの試験という意味では王道であろう、ApiProxyのDelegateをいじるとするか、と。GDDのGAEJセッションでもそんな話が出てたし。

URLFetchでもApiProxyを経由させたい

ApiProxyを経由させるためには、SDK上で実行しているときと同じHttpURLConnectionの実装を適用したい訳で、これを設定しているメソッドを探す。デバッグしていくと、com.google.appengine.tools.development.StreamHandlerFactory.install();の中でURL.setURLStreamHandlerFactory()している事がわかるので、これをTestコード実行時の早い段階で実行してみると、無事にApiProxyを経由している事が確認できた。

ApiProxyLocalを実装

公式ドキュメントの単体テストの資料にあるソースでは

ApiProxy.setDelegate(new ApiProxyLocalImpl(new File(".")){});
とか書かれていたりする、ApiProxy.setDelegate()で設定するモジュール。こいつを実装してやるわけです。その実装の中でドキュメントにあるApiProxyLocalImplのインスタンスを保持し、こいつに各処理を委譲してやります。もちろん、全ての処理を委譲したら何の意味も無いので、一部のメソッドは委譲したりしなかったりするわけで、そのメソッドはbyte[] makeSyncCall(Environment, String, String, byte[])です。一見getService()だな、と思うけど、それは実はどーでも良い(実装の方法に依存するけれども、makeSyncCall->getService()という実行順なので)。

実行時には、まずサービス名とサービスクラスのメソッド名、メソッドの引数がmakeSyncCall()に渡されて、makeSyncCall()がgetService(サービス名)で実行すべきサービスを取得して実行すべきメソッドをディスパッチする、といったカンジ。引数や返り値はbyte[]配列で統一されているが、ProtocolBufferかなー、と。これらを考慮して、以下のように実装してみた。

コメントにある通り、とりあえずURLFetchServiceだけに対応している。が、 serviceMocksというMapを使ってサービス名をキーにLocalRpcServiceの実装を保持するようにしている。

で、TestCaseの抽象クラスは以下のようなカンジに。

そしてURLFetchService用にインターフェースと抽象クラスを用意した。

実際にこれを使用しているテストケースは以下のようなカンジ。テスト対象はここには書かないけど、FriendFeedのAPIを使用するクラスとなっている。ユーザ情報を渡さずにpublic timelineを取得したり、nicknameとremoteKeyでBasic認証を行ってユーザのHome Feedを取得したり。

UrlFetchServiceMock.AbstractUrlFetchServiceMockをインスタンス化し、抽象クラスから取得したApiProxyLocalMockにaddService()をしてます。で、UrlFetchServiceMock.AbstractUrlFetchServiceMockではabstractになっていたURLFetchResponse fetch(URLFetchRequest)、つまり実際にレスポンスデータを作成するメソッドを実装しています。ここではURLやリクエストヘッダを見て返すデータ(テストデータとレスポンスコード)を振り分けています。

datastoreやmailサービスと違い、結構面倒臭いです。面倒くさいですが、テスト時に外に出て行って想定できないデータをとってきたりするのも困るワケで、データを固定に出来る(エラーも試しやすい)とでテストケースも書きやすくなるんじゃないかと。また、作業したソースは以下のSVNRepositoryに存在しています。

余談(SDKのソースについて)

実は作業を始める前に、SDKのソースが無いと不便だと思って、日本のGooglerに「Java版のSDKのソースが見つからないのですが、公開されていますか?」と質問してみた。すると「無いといぅ事は無いんじゃないかなー」と探してくださった。が、結論は「無いっぽいね」となった。それはそれで「んじゃ面倒だけどjadして確認します」とか言って作業に入ったんだけど、その後twitterでid:しげるんば(なぜかリンクされry)が「リバース禁止条項があるかも」と知らせてくれた。たしかに、そう書いてある…。特に改変配布もせず覗くだけなら良いだろうけど、ハッカソンの発表には使えないんじゃね?と思って、実はEasyMock等の使い慣れたやつで無理矢理片付けよぅとも考えていたり。が、松尾さんに相談したところそのまま海外のGooglerに持ち込んでくれて、「ま、改変して配布するとかがなく、こっそり覗くんならいーーんじゃね?」くらいのお話を効かせていただけた。おかげで調査も進んで、本来の姿かな?と思う方法に落ち着かせる事ができた。海外のGooglerの方も「公開されていると思うよ?」とおっしゃってたので、実は公開する気があるけど忘れてるだけ?とかも思う。それであると嬉しい。

また、今回書いた方法よりもいい方法があるんだろぅと思うし、どんくさい方法だな、と思ったらもっと良い方法を是非教えてください!

API Expertの方、Googler様、一緒に開発した方々、ありがとーございました。

2009年6月11日木曜日

GAE/Jでローカル実行時にApiProxyLocalから取得できるサービス一覧メモ

テスト時に必要ですよね。

ApiProxyそのもの

com.google.apphosting.api.ApiProxy.Delegate
下記ApiProxyLocalの親インターフェース
com.google.appengine.tools.development.ApiProxyLocal
上記を継承したインターフェース。getService()メソッドもこのインターフェースで定義されている。
com.google.appengine.tools.development.ApiProxyLocalImpl
ApiProxyLocalの実装

ApiProxyから取得するサービス

com.google.appengine.tools.development.LocalRpcService
ApiProxy#getService()で返るインターフェースの型。以下のサービスは全部これの実装。これを実装したクラスのgetPackage()が返す値が、ApiProxyLocal.getService()で指定すべき値。
ローカル用のサービスの実装
com.google.appengine.api.datastore.dev.LocalDatastoreService
データストアサービスの実装。getPackage()が返す値は"datastore_v3"
com.google.appengine.api.users.dev.LocalUserService
ユーザサービスの実装。getPackage()が返す値は"user"
com.google.appengine.api.urlfetch.dev.LocalURLFetchService
URL Fetchサービスの実装。getPackage()が返す値は"urlfetch"
com.google.appengine.api.mail.dev.LocalMailService
Mailサービス。getPackage()が返す値は"mail"
com.google.appengine.api.memcache.dev.LocalMemcacheService
memcacheサービス。getPackage()が返す値は"memcache"
com.google.appengine.api.images.dev.LocalImagesService
画像サービス。getPackage()が返す値は"images"

サービスの実装を指定している場所

正式なGoogleのSDKに含まれるjarは使ってないからわからんけども、mvnsearchのリポジトリにあるjarでいぅと"appengine-1.0-stubs-1.2.1.jar"の中の"META-INF/services/com.google.appengine.tools.development.LocalRpcService"で以下のように指定されている。

com.google.appengine.api.users.dev.LocalUserService com.google.appengine.api.datastore.dev.LocalDatastoreService
com.google.appengine.api.memcache.dev.LocalMemcacheService
com.google.appengine.api.urlfetch.dev.LocalURLFetchService
com.google.appengine.api.mail.dev.LocalMailService
com.google.appengine.api.images.dev.LocalImagesService

ちなみに、Webコンテナも同様にサービスとして実行時に解決されていて、インターフェースは"com.google.appengine.tools.development.ContainerService"で、定義は"appengine-api-1.0-runtime-1.2.1.jar(例のごとくmvnsearchのリポジトリ内のjarでの話)の、"META-INF/services/com.google.appengine.tools.development.ContainerService"で以下のように定義されている。

com.google.appengine.tools.development.JettyContainerService

2009年5月10日日曜日

MavenからGAE/Jの単体テスト - Java6でも大丈夫だった

先のエントリで「Java6でdatanucleusのエンハンサが実行できないー!」と叫んでいたけど、単なる凡ミスでした、お騒がせしてすいませんでした。手っ取り早く原因をお伝えしますと、エンハンサがJava5のjavacで走っていました!!

  1. $JAVA_HOMEには確かにJava6を設定してあるし、そもそもJava6のjavacが使われていなかったらmaven-compiler-pluginのsource/targetで"1.6"を指定してある箇所でエラーが出るもんなぁ。mavenは間違いなくJava6で走ってる。なのになぜ…?datanuclesとかあんま耳になじみが無いライブラリを使ってるからこんな事になるんじゃねぇの!?
  2. 念のため"$ echo $JAVA_HOME"してみると、ちゃんとJava6のフォルダが指定されている。
  3. がしかし!"$ javac -version"してみると"javac 1.5.0_16"とか帰って来た!?なるほど、確かにJAVA_HOMEの指定しかしていないしな…。。。mavenはそっちを見てるからJava6で走ってるのは間違いないし、これは関係ないだろ。
  4. pom.xmlを眺めていると、maven-datanucleus-pluginの設定に"fork=true"の設定が!

fork=falseしたら問題無く実行されました…。

エンハンサの動作がうさんくせーとか思ってたから、あーまた行き詰まったよイヤだよホントもぅ、とか思って思考が停止してますた。

2009年5月9日土曜日

MavenからGAE/Jの単体テスト - Java6だとダメ?

前回のエントリで書いた方法だと、Java6のプロジェクトでエンハンスに失敗してしまうよぅだ。。。

どさくさにまぎれてgistからのembbedを試してみた。

追記

pomは前回のエントリとほぼ同じで、javaのバージョンを1.6にしただけ。

2009年5月4日月曜日

MavenからGAE/Jの単体テスト

最低限のpomを記載しておく。ポイントは”datanucleus-appengine"と"datanucleus-core"のscopeをruntimeにしておく事。こいつらがビルド時に取り込まれてしまうとmaven-datanucleus-pluginでのエンハンス中にエラー停止してしまう。

先のエントリで書いた単体試験の内容で、$ mvn clean testしてテストが通る事を確認できるはずだ。

<project xmlns="http://maven.apache.org/POM/4.0.0"
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.shin1ogawa</groupId>
  <artifactId>gae-jdo-simple-sample</artifactId>
  <packaging>war</packaging>
  <version>1.0-SNAPSHOT</version>
  <name>GAE JDO Simple Sample</name>
  <dependencies>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>servlet-api</artifactId>
      <version>2.5</version>
    </dependency>
    <dependency>
      <groupId>javax.persistence</groupId>
      <artifactId>persistence-api</artifactId>
      <version>1.0</version>
    </dependency>
    <dependency>
      <groupId>javax.jdo</groupId>
      <artifactId>jdo2-api</artifactId>
      <version>2.3-SNAPSHOT</version>
    </dependency>
    <dependency>
      <groupId>commons-lang</groupId>
      <artifactId>commons-lang</artifactId>
      <version>2.4</version>
    </dependency>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.5</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.hamcrest</groupId>
      <artifactId>hamcrest-library</artifactId>
      <version>1.1</version>
      <scope>test</scope>
    </dependency>
    <!-- GAE for java(compile) -->
    <dependency>
      <groupId>com.google.appengine</groupId>
      <artifactId>appengine-api</artifactId>
      <version>1.0-sdk-1.2.0</version>
    </dependency>
    <!-- GAE for java(test) -->
    <dependency>
      <groupId>com.google.appengine</groupId>
      <artifactId>appengine-api-stubs</artifactId>
      <version>1.0</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>com.google.appengine</groupId>
      <artifactId>appengine-local-runtime</artifactId>
      <version>1.0</version>
      <scope>test</scope>
    </dependency>
    <!-- GAE for java(runtime) -->
    <dependency>
      <groupId>com.google.appengine</groupId>
      <artifactId>datanucleus-appengine</artifactId>
      <version>1.0.0.final</version>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.datanucleus</groupId>
      <artifactId>datanucleus-core</artifactId>
      <version>1.1.0</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <configuration>
          <source>1.5</source>
          <target>1.5</target>
        </configuration>
      </plugin>
      <plugin>
        <!-- http://www.datanucleus.org/products/accessplatform/enhancer.html#maven2 -->
        <groupId>org.datanucleus</groupId>
        <artifactId>maven-datanucleus-plugin</artifactId>
        <version>1.1.0</version>
        <configuration>
          <verbose>true</verbose>
          <mappingIncludes>**/entity/*.class</mappingIncludes>
          <fork>true</fork>
          <enhancerName>ASM</enhancerName>
          <api>JDO</api>
        </configuration>
        <executions>
          <execution>
            <phase>compile</phase>
            <goals>
              <goal>enhance</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <configuration>
          <excludes>
            <exclude>**/TestEnvironment.java</exclude>
            <exclude>**/Abstract*.java</exclude>
          </excludes>
        </configuration>
      </plugin>
    </plugins>
  </build>
  <repositories>
    <repository>
      <id>DataNucleus</id>
      <url>http://www.datanucleus.org/downloads/maven2</url>
    </repository>
    <repository>
      <id>mvnsearch</id>
      <url>http://www.mvnsearch.org/maven2</url>
    </repository>
  </repositories>
  <pluginRepositories>
    <pluginRepository>
      <id>DataNucleus</id>
      <url>http://www.datanucleus.org/downloads/maven2</url>
    </pluginRepository>
  </pluginRepositories>
</project>

これでHudsonのMavenプロジェクトでのCIも可能になりますね!

2009年5月3日日曜日

GAE/Jの単体テスト

基本はGoogleCodeで提供されているドキュメント、「Java/How-To/Unit Testing」の通りでおk。

単体テストに必要なモジュール

以下のモジュールがテスト実行環境に必要。

  • appengine-local-runtime
  • appengine-api-stubs.jar

Eclipse Plug-inを導入した場合は、"${ECLIPSE_HOME}/plugins/com.google.appengine.eclipse.sdkbundle_1.2.0.v200904062334/appengine-java-sdk-1.2.0/lib/impl"のようなフォルダに配置されている。バージョン番号を含んだフォルダ名なので、後々変わるかもしれない。mavenのリポジトリでは"http://www.mvnsearch.org/maven2/com/google/appengine/"配下のフォルダにあるのでここから拾えば良い。

テスト実行環境用のAPIプロキシの実装クラスを準備する

package com.shin1ogawa;

import com.google.apphosting.api.ApiProxy;

public class TestEnvironment implements ApiProxy.Environment {
  public String getAppId() { return "Unit Tests"; }
  public String getVersionId() { return "1.0"; }
  public void setDefaultNamespace(String s) {}
  public String getRequestNamespace() { return null; }
  public String getDefaultNamespace() { return null; }
  public String getAuthDomain() { return null; }
  public boolean isLoggedIn() { return true; }
  public String getEmail() { return null; }
  public boolean isAdmin() { return false; }
}

com.google.appengine.api.users.UserServiceFactory#getUserService()からcom.google.appengine.api.users.Userオブジェクトを取得してテストに使用するのであれば、isLoggedIn()でtrueを返し、getAuthDomain()とgetEmail()でnull以外を返してやれば良い。

テストクラスを作成する - その1

まずは@Beforeと@Afterだけ。

package com.shin1ogawa.service;

import static org.junit.Assert.assertEquals;

import java.io.File;
import java.util.List;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import com.google.appengine.api.datastore.dev.LocalDatastoreService;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserServiceFactory;
import com.google.appengine.tools.development.ApiProxyLocalImpl;
import com.google.apphosting.api.ApiProxy;
import com.shin1ogawa.TestEnvironment;
import com.shin1ogawa.entity.Board;

public class BoardServiceTest {
  @Before
  public void setUp() {
    ApiProxy.setEnvironmentForCurrentThread(new TestEnvironment());
    ApiProxy.setDelegate(new ApiProxyLocalImpl(new File("target/testDataStore")) {
    });
    ApiProxyLocalImpl proxy = (ApiProxyLocalImpl) ApiProxy.getDelegate();
    proxy.setProperty(LocalDatastoreService.NO_STORAGE_PROPERTY,
        Boolean.TRUE.toString());
  }

  @After
  public void tearDown() {
    ApiProxyLocalImpl proxy = (ApiProxyLocalImpl) ApiProxy.getDelegate();
    LocalDatastoreService datastoreService = (LocalDatastoreService) proxy
        .getService("datastore_v3");
    datastoreService.clearProfiles();
    ApiProxy.setDelegate(null);
    ApiProxy.setEnvironmentForCurrentThread(null);
  }
}

"new ApiProxyLocalImpl(new File("target/testDataStore")) {}"としているが、ここで指定したフォルダ配下にローカルストレージ用のファイルと使用したインデックスが配置される。ただし、今回はApiProxyへの設定で"LocalDatastoreService.NO_STORAGE_PROPERTY"を"true"に設定しているので、実際のファイルへの保存は行われない。あらかじめテストデータが格納された状態でのテストを行いたい場合は、この設定を"false"にした状態でデータの書き込みを行い、それをテストケース毎に保存しておくと良い。

テストクラスを作成する - その2

次に実際のテストクラス。今回は"Board"というEntityクラスと、それに関するアクセサを提供する"BoardService"というクラスがある事を想定して、そのServiceクラスのテストケースとして記述している。こんなカンジで、実際のテストメソッド内では特に特殊な事は何も必要無い。

  @Test
  public void create01() {
    User user = UserServiceFactory.getUserService().getCurrentUser();
    BoardService test = new BoardService();
    Board board = test.create(user, "test title1");
    List<Board> list = test.list();
    assertEquals(1, list.size());
    assertEquals("test title1", board.getTitle());
  }

  @Test
  public void delete01() {
    User user = UserServiceFactory.getUserService().getCurrentUser();
    BoardService test = new BoardService();
    Board board1 = test.create(user, "test title1");
    Board board2 = test.create(user, "test title2");
    List<Board> list = test.list();
    assertEquals(2, list.size());
    assertEquals("test title1", board1.getTitle());
    assertEquals("test title2", board2.getTitle());
    test.delete(board1);
    list = test.list();
    assertEquals(1, list.size());
    assertEquals("test title2", list.get(0).getTitle());
  }

注意点

上記の内容については「Googleが提供しているEclipseのPlug-inでプロジェクトを作成し、自分に必要なライブラリをEclipse的にAdd Build pathした環境」で動作するためのもの。MavenとEclipseを組み合わせているとはまる可能性大。自分はそれではまった。MavenからEclipseプロジェクトを作成していると、datanucleus-core内のモジュールがCLASSPATHに重複しているとなんだかエラーが出てしまい、JDOのモデルクラスのエンハンスが正しく行われなかったり、まぁ色々あったりする。

ただし、Mavenからの単体テストの実行もうまくできる方法がわかっているので、それも後で書く。

2008年12月30日火曜日

UbuntuにTestLinkをインストールしてみた

相方がリリースしているASlimTimerの仕組みがガラッと変わる部分が出てくるとの事で、テストをどーすっかなーと考えていた。自動テストができればいいんだけど、SwingやWebAppと違ってAIRで使えるライブラリとして唯一存在を知っているfluintも不慣れ過ぎなので、自動テストはあきらめて全て手動テストとしつつ、勉強がてらTestLinkを使ってみよぅか、と考えた。自宅のUbuntuが引っ越し以来空っぽのままなのでそこへインストールする事にした。
  • 自分はLinux弱者である。
  • Windows向けにはAll-in-oneなTestLinkがリリースされているみたいだけど、Linuxの場合はApache,MySQL,PHPのインストールから必要そぅ。
などなど、色々難しそうだと思ったけど、なんとなく導入できたのでWikiにまとめておいた。
今回はsourceforge.netに置いてあった1.7.4をインストールしたけど、1.8.0からはXML-RPCも使えるし、いずれはそっちへ移行するだろなー。

2008年1月22日火曜日

書評、その他メモ

ソフトウェアテストPRESS Vol.6

今回はテストエンジニアでなくても身近に感じられる記事が多かったので読める範囲が広かった。
  • Annex Testlinkに関する記事 これは非常に参考になった。以前に興味を持って調べていたけど、日本語化部会があるとは知らなかった!さっそくMLにも登録しておいた。新しく知った情報として、Testlink用の様々なプラグインが開発されているらしく、XML-RPCのI/Fも開発されているんだとか。これがあれば、下流のテストに関してはMylynとの連携、とかも可能になりそう。あと、Tracのプラグインの様にGanttがあれば訴えやすいかも!
  • Annexテストエンジニアのためのデータモデリング入門 ERについてはいいんだが、テストエンジニアってのは幅広いスキルが求められるんだな、と納得した。あと、アナログなハックは大好きなので。
  • 特集2第4章コラム 仕様書のないテストをどうするか 結構直面する機会が多そうなんだけど、こういった記事は確認という意味でもありがたい。
  • 特集3のTestPartner チェックする必要がありそぅだ。
全般を通じて思った事…といぅか、元々興味がある内容として「テストデータはどーする?」みたいな事をいつも考えているんだけど、その辺りについての記事を読ませて欲しいなぁ。大量データが必要なバッチなどの場合は?データは持ち出せない場合、どぅ対処する事が多いのか?とか。 後、テストフェーズのアウトソーシングをやって、「出した側が原因で失敗した例」をいくつか分析して見せて欲しいと思う。とりあえず流行だからといって準備もそこそこに「第三者テストやりましょう!」とか言うだけの人に考慮させるネタとして良いし、失敗を見るのは学ぶ事が多いし。

Haloscanが重い!

以前メモしたHaloscanを使うことでBloggerでもTrackbackを打ったり打たれたりできる…の方法を紹介したけど、このHaloscanがやけに重くて(特に日本時間の深夜)不便。で、これじゃぁ使いにくいて事で、はずそぅとして少し焦ったのでメモ。 Bloggerの「Template」「Edit Html」で開いたHTMLのソースに「haloscan」の文字が無い!ブログのソースを見ると、投稿のパーツ内にあるよぅだが、「Edit HTML」ではそこまで表示されていない!どんな仕組みなのか!と思ったら、HTMLの上に「Expand Widget Templates」というチェックボックスがあって、これをチェックするだけでOKOK。落ち着いて見てる人ならこんな事にはならない。もちつけ、俺。

RTMのGMAILプラグイン

ポップアップされた編集用Windowで、アレコレ編集しているとGMAILに対するショートカットが反応してしまう場合があるが、ひとつのフィールドを編集し終わった時に「Tab」でフィールドのフォーカスを抜ければこの問題は発生しないっぽい。その判断はGMAILの一覧の上部、普段Mailに対する操作の応答が表示される箇所に「[編集フィールド] for task [タスク名] changed.」が表示されているかどうか。これが表示されていない場合は現象が出やすい。特に何かのフィールドを編集後のtagの編集中に出やすい希ガス。

HPのキャンペーンPCが安い

機能ついたーで流れたのが目に止まったんだけど、HPのキャンペーン中のPCがヤケに安い。
  • CPU Amd Athlon64 3500+(2.2GHz,512LB L2)
  • Memory 512MB(スロットふたつあり、片方に512MB追加で+\6,600)
  • OS なし
  • HDD 80GB
2008年4月24日までのキャンペーン。もう少しで$100PCだ!

2008年1月8日火曜日

GUIの自動試験ツール、FEST

年末年始にGUIの自動試験ツールであるFESTを試したのでその紹介を。ちなみに、FESTとは「Fixtures for Easy Software Testing」の略称で、いくつかのProjectの組み合わせとなっている。昔「abbot」というProjectだったものの後継だと思われる。
  1. いつものようにmaven projectを作成し、pom.xmlを以下のように編集する。
    <project xmlns="http://maven.apache.org/POM/4.0.0"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
      <modelVersion>4.0.0</modelVersion>
      <groupId>shin1o</groupId>
      <artifactId>studyFest01</artifactId>
      <packaging>jar</packaging>
      <version>1.0-SNAPSHOT</version>
      <name>studyFest01</name>
      <url>http://maven.apache.org</url>
      <dependencies>
        <dependency>
          <groupId>junit</groupId>
          <artifactId>junit</artifactId>
          <version>4.4</version>
        </dependency>
        <dependency>
          <groupId>fest</groupId>
          <artifactId>fest-swing</artifactId>
          <version>0.7</version>
        </dependency>
        <dependency>
          <groupId>fest</groupId>
          <artifactId>fest-assert</artifactId>
          <version>0.7</version>
        </dependency>
      </dependencies>
      <build>
        <plugins>
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
              <source>1.5</source>
              <target>1.5</target>
            </configuration>
          </plugin>
        </plugins>
      </build>
      <repositories>
        <repository>
          <id>fest</id>
          <name>fest</name>
          <url>http://fest.googlecode.com/svn/trunk/fest/m2/repository</url>
        </repository>
      </repositories>
    </project>
    
    2008/01/04に0.8がリリースされている模様。
  2. 適当に試験対象のFrameを作成する。加算をするFrameをサンプルとして使用しているけど、中身はどーでもいい。html上で長くなりすぎないよぅにformatもしていないのでコピペする場合はctrl+fで。
    package shin1o;
    
    import javax.swing.*;
    
    @SuppressWarnings("serial")
    public class MyFrame extends JFrame {
      JTextField text1, text2, text3;
      JButton button1, button2;
    
      public MyFrame() {
        getContentPane().setLayout(new java.awt.BorderLayout());
        setDefaultCloseOperation(DISPOSE_ON_CLOSE);
    
        JPanel panel = new JPanel(new java.awt.FlowLayout());
        text1 = new JTextField(); text1.setName("text1");
        text1.setPreferredSize(new java.awt.Dimension(125, 25));
        text2 = new JTextField(); text2.setName("text2");
        text2.setPreferredSize(new java.awt.Dimension(125, 25));
        text3 = new JTextField(); text3.setName("text3");
        text3.setEditable(false);
        text3.setPreferredSize(new java.awt.Dimension(125, 25));
        panel.add(text1); panel.add(text2); panel.add(text3);
        getContentPane().add(panel, java.awt.BorderLayout.NORTH);
    
        button1 = new JButton(new AbstractAction() {
          public void actionPerformed(java.awt.event.ActionEvent event) {
            int int1 = 0, int2 = 0;
            try {
              int1 = Integer.parseInt(text1.getText());
            } catch (NumberFormatException e) {
              text1.requestFocusInWindow();
            }
            try {
              int2 = Integer.parseInt(text2.getText());
            } catch (NumberFormatException e) {
              text2.requestFocusInWindow();
            }
            text3.setText(String.valueOf(int1 + int2));
          }
        });
        button1.setText("add"); button1.setName("button1");
        button2 = new JButton(new AbstractAction() {
          public void actionPerformed(java.awt.event.ActionEvent event) {
            text1.setText(""); text2.setText(""); text3.setText("");
          }
        });
        button2.setText("clear"); button2.setName("button2");
        panel = new JPanel(new java.awt.FlowLayout());
        panel.add(button1); panel.add(button2);
        getContentPane().add(panel, java.awt.BorderLayout.SOUTH);
    
        setTitle("MyFrame"); setSize(400, 150);
      }
    }
  3. ここからがFESTの使い方。まずは以下のようなカンジがFESTの入り口となる。
    package shin1o;
    
    import java.awt.event.KeyEvent;
    
    import org.fest.assertions.Assertions;
    import org.fest.swing.core.MouseButton;
    import org.fest.swing.core.RobotFixture;
    import org.fest.swing.fixture.FrameFixture;
    import org.junit.*;
    
    public class MyFrameTest {
      FrameFixture frame;
      RobotFixture robot;
    
      @Before
      public void setUp() {
        frame = new FrameFixture(new MyFrame());
        robot = frame.robot;
        frame.show().target.setLocation(200, 200);
        robot.delay(1000);
      }
    
      @After
      public void tearDown() {
        robot.cleanUp();
      }
    
      @Test
      public void test01() {
        // 100
        frame.textBox("text1").pressAndReleaseKeys(KeyEvent.VK_1,
            KeyEvent.VK_0, KeyEvent.VK_0);
        Assertions.assertThat(frame.textBox("text1").text()).isEqualTo("100");
      }
    }
    このTestCaseをJUnitに実行してもらうと、text1に"100"という文字が入力され、それをassertしている処理が成功する。

重要なClass…操作系

「org.fest.swing.fixture.FrameFixture」「org.fest.swing.core.RobotFixture」のふたつのインスタンスが「操作のエミュレート」の面で見た場合に肝となるClass。
  1. FrameFixtureインスタンスを作成する。コンストラクタの第一引数にはWindowのインスタンスを渡す。
  2. RobotFixtureをFrameFixtureから取得する。
  3. FrameFixtureのインスタンスやRobotFixtureのインスタンスを使って色々操作をする。
注意点として、RobotFixtureのインスタンスはひとつしか作成してはいけないという事。FrameFixtureを複数作成する時は、二つめ以降のFrameFixtureのコンストラクタの第一引数にひとつめのFrameFixtureから取得できるRobotFixtureを指定し、第二引数にWindowのインスタンスを渡すこと。今日これではまって困った。RobotFixtureのインスタンスを複数作成してしまった瞬間に処理が帰ってこなくなってしまぅんだヨー!年始の学習が役に立つ場面がさっそく来たぜ!とか思ってたら開始早々に出鼻を挫かれるとか泣けた。Alex Ruiz@FESTの中の人、Thanksでした。 ちなみにこのインターフェースは…そぅ、あちこちで流行りの「fluentなインターフェース」ですね。

重要なClass…評価系

「org.fest.assertions.Assertions」にある「assertThat()」メソッド。assertThat()はJUnitでも4.4から同じ名称のメソッドが追加されてたね。どっちが先かは知らんけど、FESTのassertThatはこれまた流れるようなインターフェースで記述することになります。ちなみに、actual, expectedの値を指定する順番がJUnitのassertメソッドと比べると逆になるので注意が必要かも。

もぅ少しまともなTestを書いてみる

せっかく加算できるんだから、加算させてみよう。といぅ事でFrameFixtureをメインに使って操作をし、org.fest.assertions.Assertionsを使って評価をするよぅにtest01()を修正してみる。
@Test public void test01() {
  // 100
  frame.textBox("text1").pressAndReleaseKeys(KeyEvent.VK_1,
      KeyEvent.VK_0, KeyEvent.VK_0);
  Assertions.assertThat(frame.textBox("text1").text()).isEqualTo("100");
  // 200
  frame.textBox("text2").pressAndReleaseKeys(KeyEvent.VK_2,
      KeyEvent.VK_0, KeyEvent.VK_0);
  Assertions.assertThat(frame.textBox("text2").text()).isEqualTo("200");
  robot.delay(1000);
  // click "add"
  frame.button("button1").click();
  Assertions.assertThat(frame.textBox("text1").text()).isEqualTo("100");
  Assertions.assertThat(frame.textBox("text2").text()).isEqualTo("200");
  Assertions.assertThat(frame.textBox("text3").text()).isEqualTo("300");
  robot.delay(1000);
  // click "clear"
  frame.button("button2").click();
  Assertions.assertThat(frame.textBox("text1").text()).isEqualTo("");
  Assertions.assertThat(frame.textBox("text2").text()).isEqualTo("");
  Assertions.assertThat(frame.textBox("text3").text()).isEqualTo("");
  robot.delay(1000);
}
text1に"100"、text2に"200"を入力し、「add」してみたり「clear」してみてその結果をassertThat().isEqualTo()で評価している。

同じTestを違う書き方で書く

次はFrameFixtureではなくRobotFixtureメインで操作をしてみる。FrameFixtureのFinderを使わないので、Frameをとっかかりに外から各Componentにアクセスできることが前提条件となる。ただ、Finderが必要ないため各ComponentにsetName()しなくても良いのはラクかもしれん。そして、慣れているorg.junit.Assert#assertEquals()で評価を行う。
@Test public void test02() {
  MyFrame myFrame = (MyFrame) frame.target;
  // 100
  myFrame.text1.requestFocusInWindow();
  robot.pressAndReleaseKeys(KeyEvent.VK_1, KeyEvent.VK_0, KeyEvent.VK_0);
  Assert.assertEquals("100", myFrame.text1.getText());
  // 200
  myFrame.text2.requestFocusInWindow();
  robot.pressAndReleaseKeys(KeyEvent.VK_2, KeyEvent.VK_0, KeyEvent.VK_0);
  Assert.assertEquals("200", myFrame.text2.getText());
  robot.delay(1000);
  // click "add"
  robot.click(myFrame.button1, MouseButton.LEFT_BUTTON, 1);
  Assert.assertEquals("100", myFrame.text1.getText());
  Assert.assertEquals("200", myFrame.text2.getText());
  Assert.assertEquals("300", myFrame.text3.getText());
  robot.delay(1000);
  // click "clear"
  robot.click(myFrame.button2, MouseButton.LEFT_BUTTON, 1);
  Assert.assertEquals("", myFrame.text1.getText());
  Assert.assertEquals("", myFrame.text2.getText());
  Assert.assertEquals("", myFrame.text3.getText());
  robot.delay(1000);
}

さらに違う書き方

といっても、test02()とほとんど変わらない。そもそも、test02()では「各Componentを直接参照できる」んだから、操作系の細かいエミュレートはどーでも良い時なんかはそのままJComponentのメソッドを使えばえぇがな!というやり方。test02()では主にKeyの入力系を「requestFocusInWindow()して1文字ずつKeyCodeを送る」というやり方をしている。
myFrame.text1.requestFocusInWindow();
robot.pressAndReleaseKeys(KeyEvent.VK_1, KeyEvent.VK_0, KeyEvent.VK_0);
これが面倒なので、次の一行にまとめるだけ。
myFrame.text1.setText("100");
Testをあんまり真面目に書きすぎても時間がもったいないし、手を抜けるとこは抜いた方が良い。日本語の問題も心配だし。 Swingが信用ならないからリアルにエミュレートしたいところだが、そもそもそんな処理の違い(リアルにエミュレートするか直接setText()するか)で問題が発生するなら、おそらく業務的な要件といぅよりはFramework側だったりライブラリ側の問題だろぅから、あんまり重要では無いだろぅし。 こんなカンジでかなり便利に使える模様です。ちなみにこのエントリでは最も気になっていたFEST-Swing、FEST-Assertしか使って無いけど、他にもFEST-Mock、FEST-Reflectというモジュール群があります。FEST-Mockは想像がつくけど、FEST-Reflectionってのがどんなものか気になる。ひょっとして試験フェーズに特化したDI/AOPコンテナだったり?とか思うとwktkするなぁ!

2007年12月20日木曜日

テスト工程の外だし

外部にTest工程を依頼するような話をする場合、発注者側は「Test対象」「Test対象と同期が取れた仕様」を全部同時に揃える必要があると思いますが、従来の設計手法でこれが可能ですかね?「それに対するTestCase、テストシナリオ」も外だしするならもっと大変。先に開発プロセスの見直しが必須ですよね。もしくは、開発プロセスの見直しを迫られる可能性を考慮して準備しておく必要があるでしょうね。 そして、これらを全部同時に揃えることが可能とした場合、そのタイミングがTest受注側の「新しい回帰が始まるタイミング」となるわけですが、このリリースを連続的にできるんでしょうか。Testですから、全部Greenになってしまえば次の「新しい回帰が始まるタイミング(次の「Test対象、それに対する正しい仕様」のリリース)」までは作業が無い(やっても意味が無い)わけですが、この間の工数ってのはどこが負担するんでしょうね?Testする側はリリースがあるたびに大量の人間の確保が必要ですから、人の確保は必要ですし。 「TestCase、シナリオ作成は受注側で」となるなら、受注側は仕様の把握は必須ですし、その間に仕様が変わる場合は、それについては常に同期を取っておかなければ無駄なTestCase、シナリオを作成してしまう恐れもあるし。ここまで考えると、まず大幅に開発プロセスの見直しを行ってからじゃないと、テスト工程の外だし作業は簡単に破綻するんでしょうね。テスト工程の受注側にも大きな迷惑をかけるだけ、会社間の空気も悪くなるだけ、というイヤーな結果だけを残すんでしょうね。 何の準備もせずテスト工程の外だしという流行に乗るだけ乗って、失敗して文句たれるだけ、の会社がたーくさん出てくるのでしょうかね。そんな風な、ちっとも進歩しない会社は、みじめですね。業界を悪くする一端を担うことに責任感を感じてでもいるのでしょぅか。

2007年11月5日月曜日

Maven2でSeleniumを使用する。

plugins要素に追加

  <plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>selenium-maven-plugin</artifactId>
    <executions>
      <execution>
        <id>selenese-ie</id>
        <phase>integration-test</phase>
        <goals>
          <goal>selenese</goal>
        </goals>
        <configuration>
          <browser>*iexplore</browser>
        </configuration>
      </execution>
    </executions>
    <configuration>
      <suite>src/test/selenium/AppTestSuite.html</suite>
      <startURL>http://localhost:8080</startURL>
      <multiWindow>true</multiWindow>
    </configuration>
  </plugin>

pluginRepositories要素に追加

  <pluginRepository>
    <id>mojo-snapshot</id>
    <url>http://snapshots.repository.codehaus.org/</url>
  </pluginRepository>
参考:Maven2でSelenium RC - まとばと!

Blogged with Flock