概要
HTTP Client のテストで役立つライブラリ MockWebServer について調べてみました。
MockWebServer とは
OkHttp のテストをするために作ったのだろうと思われるライブラリで、OkHttp のリポジトリ内にプロジェクトがあります。ライセンスは Apache License, Version 2.0 です。
HTTP 通信部分を Mock 化するのに使えます。今日日、 HTTP 通信を使わないアプリケーションもそうそうないでしょうから、需要は結構ありそうな感じがします。
OkHttp とは
Android 向けの便利なライブラリを大量に作っている Square, Inc. の開発した HTTP Client です。普通の Java ライブラリなので、Web アプリケーションや業務アプリケーションでも利用できます
Android 界隈では Apache の HTTP Client が Android OS 5.x から非推奨→削除となったことで、その代替ライブラリとして注目されています。
Square, Inc. とは
アメリカの決済サービス企業、日本では Android の便利なライブラリを作ってくれる企業として知られています。CEO は Twitter の共同創業者ジャック・ドーシー氏です。
Trend
Java テストツールのトレンド 2014/1~2016/5 で Google Trend のグラフを添付していたのがわかりやすかったので真似させてもらいます。いずれも2012年1月から2016年6月までのグラフです。
MockWebServer
OkHttp
使い始める
build.gradle
依存を1つ追加するだけで使えます。なお、依存に OkHttp が含まれています。
dependencies {
......
testCompile 'com.squareup.okhttp3:mockwebserver:3.2.0'
}
(任意) Eclipse Collections
下記のクラスを使うために Eclipse Collections を追加しました。必須ではないですが、あると便利です。
- Interval (Java SE8 の IntStream を便利にした感じのもの)
- Procedures#throwing (Lambda 式の中で Try-Catch を書かなくて済むので、ネストを深くしなくて済むという視覚的効果を期待)
dependencies {
......
compile 'org.eclipse.collections:eclipse-collections:7.1.0'
}
公式のサンプルコードをいじる
GitHub repository の README.md に書かれていたサンプルコードを読んでも意味がまったくわからなかったので、自分でわかるように書き直してみました。
import java.io.IOException;
import org.eclipse.collections.impl.block.factory.Procedures;
import org.eclipse.collections.impl.list.Interval;
import org.junit.Test;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
public class MockServerTest {
@Test
public void test() throws IOException, InterruptedException {
// Create a MockWebServer. These are lean enough that you can create a new
// instance for every unit test.
final MockWebServer server = new MockWebServer();
// Schedule some responses.
server.enqueue(new MockResponse().setBody("hello, world!"));
server.enqueue(new MockResponse().setBody("sup, bra?"));
server.enqueue(new MockResponse().setBody("yo dog"));
// Start the server.
server.start();
// Ask the server for its URL. You'll need this to make HTTP requests.
final OkHttpClient client = new OkHttpClient();
Interval.oneTo(3).each(Procedures.throwing(i -> {
final Request request = new Request.Builder().url(server.url("/v1/chat/")).build();
final Response response = client.newCall(request).execute();
System.out.println(i);
System.out.println(response.headers());
System.out.println(response.body().string());
}));
// Shut down the server. Instances cannot be reused.
server.shutdown();
}
}
6 11, 2016 6:44:23 午後 okhttp3.mockwebserver.MockWebServer$3 execute
情報: MockWebServer[64145] starting to accept connections
6 11, 2016 6:44:23 午後 okhttp3.mockwebserver.MockWebServer$4 processOneRequest
情報: MockWebServer[64145] received request: GET /v1/chat/ HTTP/1.1 and responded: HTTP/1.1 200 OK
1
Content-Length: 13
OkHttp-Sent-Millis: 1465638263689
OkHttp-Received-Millis: 1465638263695
hello, world!
6 11, 2016 6:44:23 午後 okhttp3.mockwebserver.MockWebServer$4 processOneRequest
情報: MockWebServer[64145] received request: GET /v1/chat/ HTTP/1.1 and responded: HTTP/1.1 200 OK
2
Content-Length: 9
OkHttp-Sent-Millis: 1465638263698
OkHttp-Received-Millis: 1465638263698
sup, bra?
6 11, 2016 6:44:23 午後 okhttp3.mockwebserver.MockWebServer$4 processOneRequest
情報: MockWebServer[64145] received request: GET /v1/chat/ HTTP/1.1 and responded: HTTP/1.1 200 OK
3
Content-Length: 6
OkHttp-Sent-Millis: 1465638263699
OkHttp-Received-Millis: 1465638263701
yo dog
6 11, 2016 6:44:23 午後 okhttp3.mockwebserver.MockWebServer$3 acceptConnections
情報: MockWebServer[64145] done accepting connections: socket closed
どうやらこれは単純な Mock エントリポイントを作って取得のテストをしているわけではない、と気付きました。
本当に最小限のことだけやるテスト
まだわかりにくいので、本当に Hello world レベルの UnitTest を勉強のために書いてみます。
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import java.io.IOException;
import org.junit.Test;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
public class MockServerTest {
@Test
public void test() throws IOException, InterruptedException {
final MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse().setBody("hello, world!"));
server.start();
final OkHttpClient client = new OkHttpClient();
final Request request = new Request.Builder().url(server.url("/hello")).build();
final Response response = client.newCall(request).execute();
assertEquals("13", response.header("Content-Length"));
assertNotNull(response.header("OkHttp-Sent-Millis"));
assertNotNull(response.header("OkHttp-Received-Millis"));
assertEquals("hello, world!", response.body().string());
server.shutdown();
}
}
順を追って説明します。
MockWebServer
Mock の Web サーバです。次に説明する MockResponse のキューを持っています。
MockResponse
Mock の レスポンスを定義するオブジェクトです。Body や Header や Status code を持たせることができます。
MockWebServer の用意
MockWebServer オブジェクトを初期化し、そのキューに Mock のレスポンスを追加していきます。今回はシンプルにテキストだけを Body に詰めます。キューを用意し終わったら MockWebServer オブジェクトの start メソッドを呼んでください。
final MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse().setBody("hello, world!"));
server.start();
OkHttpClient で HTTP 通信する
下記はほぼ OkHttp の処理をするコードです。
final OkHttpClient client = new OkHttpClient();
final Request request = new Request.Builder().url(server.url("/hello")).build();
final Response response = client.newCall(request).execute();
MockWebServer オブジェクトの url メソッドを使い、引数で渡す任意のパスで仮 URL を発行します。このメソッドで得られるオブジェクトは HttpUrl という、OkHttp 固有のクラスですが、toString() すれば通常の URL 文字列を取得できます。HTTP Client はその仮 URL にアクセスします。
server.url("/hello") // http://localhost:64243/hello
ポート番号は指定しない限り、MockWebServer オブジェクト単位で変わっているようです。
Response の操作
レスポンスヘッダ
特に指定していない場合は下記3つが入っています。
- Content-Length
- OkHttp-Sent-Millis
- OkHttp-Received-Millis
assertEquals("13", response.header("Content-Length"));
assertNotNull(response.header("OkHttp-Sent-Millis"));
assertNotNull(response.header("OkHttp-Received-Millis"));
Body
キューに登録した通りの Body が入っています。
assertEquals("hello, world!", response.body().string());
終了
使い終わった MockWebServer オブジェクトは shutdown します。
server.shutdown();
MockResponse をいじる
自身を返すタイプの Setter を実装しているのでメソッドチェインでつなげられます。最近のライブラリはこういうのが増えたと感じます。
final MockResponse mock = new MockResponse()
.setBody("hello, world!")
.setHeader("Mock-Header", "mock")
.setHeader("Mock-HeaderB", "mock")
.setHeader("Mock-HeaderB", "mocker")
.setResponseCode(403)
.setResponseCode(404);
setHeader
任意のヘッダを key - value ペアで設定します。複数回呼び出した場合は複数登録できます。 key が重複する場合は最後の値で上書きされます。上記の例だと key="Mock-HeaderB" の value は "mocker" になります。
setResponseCode
HTTP Status code を int で設定します。最後に指定されたものが有効となります。なお、3xx系や4xx系や5xx系を指定した場合はリクエスト失敗として扱われるようです。
setStatus
Status メッセージ(curl の --head オプションをつけると先頭に出てくる "HTTP/1.1 200 OK" のような文字列)を設定できます。ルールに則っていないメッセージを渡すと、
.setStatus("Rotten Apple Error.")
HTTP リクエスト時に下記のような例外が発生します。
java.net.ProtocolException: Unexpected status line: Rotten Apple Error.
at okhttp3.internal.http.StatusLine.parse(StatusLine.java:69)
……省略……
ルールに則ったメッセージであれば登録できますし、Status code も上書きされてしまいます。
.setStatus("HTTP/1.1 666 Rotten Apple Error.")
実装を覗いたところ、 setResponseCode メソッドから呼び出されているようで、どう見ても内部的に利用するメソッドです。何で public で公開されているのかよくわかりません。
public MockResponse setResponseCode(int code) {
/* 省略 */
return setStatus("HTTP/1.1 " + code + " " + reason);
}
Dispatcher を使う
GitHub repository の README.md サンプルで示されているのは MockResponse の queue を使う形式です。当然ながら queue は1回取り出したらなくなります。queue が空っぽの状態で MockWebServer にリクエストすると、レスポンスが返ってこないままタイムアウトまで停止してしまいます。
「何か違うんだよなあ」と思ったところ、README.md の下の方に Dispatcher というのが紹介されていました。
Dispatcher を使うコード全体
import java.io.IOException;
import org.eclipse.collections.impl.block.factory.Procedures;
import org.eclipse.collections.impl.factory.Lists;
import org.junit.Test;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
public class MockServerTest {
@Test
public void test() throws IOException, InterruptedException {
final MockWebServer server = new MockWebServer();
final Dispatcher dispatcher = new Dispatcher() {
@Override
public MockResponse dispatch(final RecordedRequest request) throws InterruptedException {
if (request == null || request.getPath() == null) {
return new MockResponse().setResponseCode(400);
}
switch (request.getPath()) {
case "/hello":
return new MockResponse().setBody("Hello world!").setResponseCode(200);
case "/transferred":
return new MockResponse().setResponseCode(301);
case "/forbidden":
return new MockResponse().setResponseCode(403);
default:
return new MockResponse().setResponseCode(404);
}
}
};
server.setDispatcher(dispatcher);
server.start();
final OkHttpClient client = new OkHttpClient();
ArrayAdapter.adapt("/hello", "/transferred", "/forbidden", "/notexists", "/hello")
.each(
Procedures.throwing(path -> {
final HttpUrl url = server.url(path);
final Request request = new Request.Builder().url(url).build();
final Response response = client.newCall(request).execute();
System.out.println(url.toString());
System.out.println(response.message());
System.out.println(response.isSuccessful());
System.out.println(response.code());
System.out.println(response.headers());
})
);
server.shutdown();
}
}
実行結果
このように、何度同じ URL にアクセスしても同じ body を取得でき、存在しないパスにはアクセスできない MockWebServer が出来上がります。
6 11, 2016 8:21:14 午後 okhttp3.mockwebserver.MockWebServer$3 execute
情報: MockWebServer[58567] starting to accept connections
6 11, 2016 8:21:15 午後 okhttp3.mockwebserver.MockWebServer$4 processOneRequest
情報: MockWebServer[58567] received request: GET /hello HTTP/1.1 and responded: HTTP/1.1 200 OK
http://localhost:58567/hello
OK
true
200
Content-Length: 12
OkHttp-Sent-Millis: 1465644075148
OkHttp-Received-Millis: 1465644075154
http://localhost:58567/transferred
Redirection
false
301
Content-Length: 0
OkHttp-Sent-Millis: 1465644075158
OkHttp-Received-Millis: 1465644075159
6 11, 2016 8:21:15 午後 okhttp3.mockwebserver.MockWebServer$4 processOneRequest
情報: MockWebServer[58567] received request: GET /transferred HTTP/1.1 and responded: HTTP/1.1 301 Redirection
http://localhost:58567/forbidden
Client Error
false
403
Content-Length: 0
OkHttp-Sent-Millis: 1465644075161
OkHttp-Received-Millis: 1465644075163
6 11, 2016 8:21:15 午後 okhttp3.mockwebserver.MockWebServer$4 processOneRequest
情報: MockWebServer[58567] received request: GET /forbidden HTTP/1.1 and responded: HTTP/1.1 403 Client Error
http://localhost:58567/notexists
Client Error
false
404
Content-Length: 0
OkHttp-Sent-Millis: 1465644075163
OkHttp-Received-Millis: 1465644075164
6 11, 2016 8:21:15 午後 okhttp3.mockwebserver.MockWebServer$4 processOneRequest
情報: MockWebServer[58567] received request: GET /notexists HTTP/1.1 and responded: HTTP/1.1 404 Client Error
http://localhost:58567/hello
OK
true
200
Content-Length: 12
OkHttp-Sent-Millis: 1465644075165
OkHttp-Received-Millis: 1465644075165
6 11, 2016 8:21:15 午後 okhttp3.mockwebserver.MockWebServer$4 processOneRequest
情報: MockWebServer[58567] received request: GET /hello HTTP/1.1 and responded: HTTP/1.1 200 OK
6 11, 2016 8:21:15 午後 okhttp3.mockwebserver.MockWebServer$3 acceptConnections
情報: MockWebServer[58567] done accepting connections: socket closed
以下、詳細を見ていきます。
import
OkHttp 本体にも同名のクラスがあるので注意してください。
import okhttp3.mockwebserver.Dispatcher;
Dispatcher の定義
指定された Path に対し MockResponse を振り分けることができます。
Dispatcher は abstract クラスであり、dispatch メソッドを実装する必要があります。dispatch メソッドは RecordedRequest オブジェクトの request を引数に取り、request が持つ Path を見て、返す MockResponse を分けます。Queue ではないので、何度同じ Path にアクセスが来ても同様の MockResponse を返します。
final Dispatcher dispatcher = new Dispatcher() {
@Override
public MockResponse dispatch(final RecordedRequest request) throws InterruptedException {
if (request == null || request.getPath() == null) {
return new MockResponse().setResponseCode(400);
}
switch (request.getPath()) {
case "/hello":
return new MockResponse().setBody("Hello world!").setResponseCode(200);
case "/transferred":
return new MockResponse().setResponseCode(301);
case "/forbidden":
return new MockResponse().setResponseCode(403);
default:
return new MockResponse().setResponseCode(404);
}
}
};
Java SE7 以降であれば String に対し switch-case が使えます。が、switch で評価する式( ()の中身)に null が入ってしまうと NullPointerException が発生しますので、null チェックを書かないといけません。
Dispatcher の設定
MockWebServer オブジェクトに先ほどの Dispatcher を設定します。
server.setDispatcher(dispatcher);
リクエスト
下記の Path に対し、順に HTTP リクエストを実施します。
- /hello
- /transferred
- /forbidden
- /notexists
- /hello
ArrayAdapter.adapt("/hello", "/transferred", "/forbidden", "/notexists", "/hello")
.each(
Procedures.throwing(path -> {
final HttpUrl url = server.url(path);
final Request request = new Request.Builder().url(url).build();
final Response response = client.newCall(request).execute();
System.out.println(url.toString());
System.out.println(response.message());
System.out.println(response.isSuccessful());
System.out.println(response.code());
System.out.println(response.headers());
})
);
Dispatcher で振り分けの設定をしていない Path である /notexists は 404 となります。
また、/hello は2回呼び出しても同じ Body が取得できます
まとめ
MockWebServer は HTTP クライアントや HTTP 接続部分のユニットテストを書く際に役立つライブラリです。通常の利用では Dispatcher を定義して、それを MockWebServer オブジェクトにセットして起動し、 HTTP 接続のテストコードを書くとよいでしょう。