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!

コメントを投稿