[play1-test] Async-http-clientのエンコード問題

  playframework1

play1で小さなシステムを組むときは効率優先でテストまで作らないことが多いのですが、ちょっとシステムが複雑になってきたのでテストを書くことにしました。

playframework1にはテスト機能もセットに含まれています。テストの種類としては

  • 単体テスト (Unit test)
  • 機能テスト (Functional test)
  • Seleniumテスト (Selenium test)

の3つのテスト機能が提供されていて、フレームワーク側のフォーマットに従うと簡単に実装できます。

機能テストはControllerに対して疑似パラメータを送ってテストすることができます。ブラウザ経由ではないので、画面からは入力できないようなパラメータも試すことができます。

Seleniumテストはブラウザ経由のテストなのですが、ここで使われるブラウザは同梱されているHtmlUnitというヘッドレスブラウザ。単純なhtmlだけなら不足はないのですけど、JavaScriptエンジンが弱いのが欠点です。最近のSPAフレームワークとかは無理と考えた方がいいです。

で、ひとまず全画面が表示できるか、みたいな簡易なテストであれば機能テストで実装できます。controllerやroutesをいじっているといつの間にかリンク切れとか起きちゃうことがありますからね。大事なことです。

問題発生

ことろで最近、効率優先でフィールド名に使うことが多くなりました。そうするとフォーム入力でも

<form method="POST">
  <input type="text" name="data.パラメータ" />

のような name値が日本語のタグが出てくるようになります。

これは問題なく動作します。

一方、この画面の機能テストを作成する場合は下記のようになるわけですが

    Map<String, String> param = new HashMap<String, String>() {
        {
            put("data.パラメータ", "編集テスト");
        }
    };
    Response response = POST("/", param);

これがどうやらうまくいきません。よくよく追いかけてみますとPOSTデータをエンコードする際に文字化けしているようです。

value値はRequest.encodingに合わせてUTF-8でエンコードしているのですが、name値はUS_ASCII固定でのエンコードとなっていました。

原因箇所

テストでのPOSTリクエストデータの生成を担っているのが async-http-client というライブラリです。play-1.5.3に同梱されているのは async-http-client-1.9.40.jar という少し前の版です。

play.test.FunctionalTest では下記のように com.ning.http.client.multipart.StringPartオブジェクトを生成してパラメータを処理しています。

    public static Response POST(Request request, Object url, Map<String, String> parameters, Map<String, File> files) {
        List<Part> parts = new ArrayList<>();

        for (String key : parameters.keySet()) {
            StringPart stringPart = new StringPart(key, parameters.get(key), request.contentType, Charset.forName(request.encoding));
            parts.add(stringPart);
        }

StringPart PartBase を継承しており name値のエンコードはこの PartBase に実装されている箇所で行っています。最新の version 2.12.3 では org.asynchttpclient.request.body.multipart.part.MultipartPart.java に同じコードが残っています。

  protected void visitDispositionHeader(PartVisitor visitor) {
    visitor.withBytes(CRLF_BYTES);
    visitor.withBytes(CONTENT_DISPOSITION_BYTES);
    visitor.withBytes(part.getDispositionType() != null ? part.getDispositionType().getBytes(US_ASCII) : FORM_DATA_DISPOSITION_TYPE_BYTES);
    if (part.getName() != null) {
      visitor.withBytes(NAME_BYTES);
      visitor.withByte(QUOTE_BYTE);
      visitor.withBytes(part.getName().getBytes(US_ASCII));
      visitor.withByte(QUOTE_BYTE);
    }
  }

part.getName().getBytes(US_ASCII) とUS_ASCIIに強制的にエンコードされている箇所がそれです。

対策について

play1のテストで使うだけであれば該当の FuncionalTest.POST() をベースに自前のPOSTを作成し、StringPart の代わりに独自実装の MyStringPart を用意すればOKです。

    public static Response POST(Request request, Object url, Map<String, String> parameters) {
        List<Part> parts = new ArrayList<>();

        for (String key : parameters.keySet()) {
            MyStringPart stringPart = new MyStringPart(key, parameters.get(key), request.contentType, Charset.forName(request.encoding));
            parts.add(stringPart);
        }
public class MyStringPart extends com.ning.http.client.multipart.StringPart {

    public MyStringPart(String name, String value, String contentType, Charset charset) {
        super(name, value, contentType, charset, (String)null);
    }

    @Override
    public void visitDispositionHeader(PartVisitor visitor) throws IOException {
        visitor.withBytes(CRLF_BYTES);
        visitor.withBytes(CONTENT_DISPOSITION_BYTES);
        visitor.withBytes(this.getDispositionType() != null ? this.getDispositionType().getBytes(StandardCharsets.US_ASCII) : FORM_DATA_DISPOSITION_TYPE_BYTES);
        if (this.getName() != null) {
           visitor.withBytes(NAME_BYTES);
           visitor.withByte((byte)34);
           visitor.withBytes(this.getName().getBytes(StandardCharsets.UTF_8));
           visitor.withByte((byte)34);
        }
     }
}

とりあえずこれでplay1の機能テストは通るようになりました。

終わりに

本来は githubの方へプルリクエストを投げた方がいいんでしょうね。

ちょっとやり方を勉強してみますか・・。

LEAVE A COMMENT