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さんが これに関する調査を行い易くするためのプロダクトを公開 してくれていますので、興味があれば参考にすると良いと思います。

コメントを投稿