概要
Kotlinを利用したプロジェクトのユニットテストでMockサーバーに[WireMock] (http://wiremock.org/)を使ったサンプルコードです。
WireMockの使い方には、JUnitのテストコード内で使う方法とjarファイルで起動するスタンドアローンで使う方法がありますが、この記事ではJUnitで使う方法の説明になります。
ちなみに[MockLab] (http://get.mocklab.io/)というWebサービスもあります。
環境
- Windows 10 Professional
- Java 1.8.0_162
- Kotlin 1.2.21
- JUnit 4.12
- WireMock 2.14
- Intellij IDEA 2017.3
参考
- [WireMock] (http://wiremock.org/)
- [tomakehurst/wiremock - GitHub]
(https://github.com/tomakehurst/wiremock)
導入
プロジェクトの依存関係に下記を追加します。
dependencies {
testCompile("junit:junit:4.12")
testCompile("com.github.tomakehurst:wiremock:2.14.0")
}
テスト対象コード
ユニットテストのテスト対象コードとして、下記のダミーコードを使用しました。
このコードでMockサーバーのスタブに対してリクエストを発行します。
HttpClientのライブラリに[Fuel] (https://github.com/kittinunf/Fuel)を利用しました。
GETリクエスト用
fun getSomething(urlPath: String, params: List<Pair<String, String>> = emptyList()): String {
val (_, res, result) = "http://localhost:8089$urlPath".httpGet(params)
.header("Accept" to "application/json")
.header("User-Agent" to "Mozilla/5.0")
.header("X-TEST-Header" to "custom test header")
.header("Cookie" to "session=1234-5678-9012")
.responseString()
return when(result) {
is Result.Success -> {
// for debug
res.headers["Content-Type"]?.let { println(it) }
res.headers["Cache-Control"]?.let { println(it) }
res.headers["X-TEST-Options"]?.let { println(it) }
result.get().also { println(it) }
}
is Result.Failure -> {
error("api call failure")
}
}
}
POSTリクエスト用
fun postSomething(urlPath: String, body: String = "{}", params: List<Pair<String, String>> = emptyList()): String {
val (_, _, result) = "http://localhost:8089$urlPath".httpPost(params)
.header("Content-Type" to "application/json")
.header("User-Agent" to "Mozilla/5.0")
.header("X-TEST-Header" to "custom test header")
.body(body, Charset.defaultCharset())
.responseString()
return when(result) {
is Result.Success -> {
result.get().also { println(it) }
}
is Result.Failure -> {
error("api call failure")
}
}
}
JUnitのルール
Mockサーバーを起動・停止するためのJUnitのルールを定義します。
メソッドルール
テストメソッドの単位でMockサーバーを起動・停止したい場合はRuleアノテーションを使用します。
@Rule
@JvmField
val wireMockRule: WireMockRule = WireMockRule(options().port(8089))
クラスルール
起動・停止をテストクラスの単位にしたい場合はClassRuleアノテーションを使用します。
companion object {
@ClassRule
@JvmField
val wireMockClassRule: WireMockClassRule = WireMockClassRule(options().port(8089))
}
GETリクエストのシンプルなスタブ
GETリクエストを受けてレスポンスにJSON文字列を返すシンプルなスタブの例です。
@Test
fun `test simple get example`() {
// setup
val responseData = """
|{
| "id": 77,
| "name": "john",
| "age": 20
|}
""".trimMargin()
// スタブの設定
stubFor(get(urlPathEqualTo("/my/member/77"))
.willReturn(
okJson(responseData)
)
)
// exercise
// テスト対象コードの実行
val result = getSomething("/my/member/77")
// verify
// テスト対象コードの検証
assertThat(result).contains("\"id\": 77", "\"name\": \"john\"", "\"age\": 20")
// APIが1回呼ばれること
verify(1, getRequestedFor(urlPathEqualTo("/my/member/77")))
// リクエストヘッダーのAcceptがapplication/jsonであること
verify(getRequestedFor(urlPathEqualTo("/my/member/77"))
.withHeader("Accept", matching("application/json"))
)
}
スタブの設定はstubForメソッドで行います。この例ではlocalhost:8089/my/member/77
にgetリクエストが来たらokJsonメソッドに渡すデータをレスポンスするという設定です。
okJsonというメソッド名から予想できる通り、レスポンスにはHTTPステータスが200、Content-Typeヘッダーに'application/json'がセットされます。
stubFor(get(urlPathEqualTo("/my/member/77"))
.willReturn(
okJson(responseData)
)
}
URLのマッチングにurlPathEqualToメソッドを使用していますが、これも含めて以下のようなメソッドがあります。
- urlEqualTo : クエリストリングも含めてすべてが一致している
- urlPathEqualTo : URLのパスが一致している(クエリストリングは別)
- urlMatching : 正規表現によるマッチング
- urlPathMatching : 正規表現によるURLのパスのマッチング
- anyUrl : どのようなURLでも一致する
テスト対象のコードが期待したリクエストを発行したかどうかをverifyメソッドで検証します。
このコードはリクエストの発行回数を検証しています。
verify(1, getRequestedFor(urlPathEqualTo("/my/member/77")))
テキストファイルを利用する
レスポンスデータにテキストファイルを利用することができます。
テキストファイルの配置ディレクトリは、デフォルトではsrc/test/resources/__files
になります。この場所はMockサーバーのオプションで変更可能です。
この例では下記のperson.jsonというファイルを使用しています。
{
"id": 88,
"name": "tom",
"age": 40
}
withBodyFileメソッドにファイル名を指定すると、レスポンスにそのテキストファイルの内容を使います。
また、このサンプルの通り任意のレスポンスヘッダーやステータスコードを指定することが可能です。
@Test
fun `test get example with text file`() {
// setup
val headers = HttpHeaders()
.plus(HttpHeader("Content-Type", "application/json"))
.plus(HttpHeader("Cache-Control", "no-cache"))
stubFor(get(urlPathEqualTo("/my/member/88"))
.willReturn(
aResponse()
.withStatus(200)
.withHeaders(headers)
.withBodyFile("person.json")
)
)
// exercise
// テスト対象コードの実行
val result = getSomething("/my/member/88")
// verify
// テスト対象コードのassertion
assertThat(result).contains("\"id\": 88", "\"name\": \"tom\"", "\"age\": 40")
verify(1, getRequestedFor(urlPathEqualTo("/my/member/88")))
verify(getRequestedFor(urlPathEqualTo("/my/member/88"))
.withHeader("Accept", matching("application/json"))
)
}
レスポンスを遅延させる
withFixedDelayメソッドで指定する時間で、スタブにレスポンスを遅延させることができます。単位はミリ秒です。
この例では10秒間、レスポンスを遅延させます。
stubFor(get(urlPathEqualTo("/my/member/88"))
.willReturn(
aResponse()
.withStatus(200)
.withHeaders(headers)
.withBodyFile("person.json")
.withFixedDelay(1000 * 10)
)
)
GETリクエストの少し複雑なスタブ
スタブに任意のヘッダーやクエリストリング、クッキーの設定を行うこともできます。
@Test
fun `test complex get example`() {
// setup
val responseData = """
|{
| "id": 99,
| "name": "jack",
| "age": 30
|}
""".trimMargin()
stubFor(get(urlPathMatching("/my/member/[0-9]*"))
.withQueryParam("ref", matching("[a-zA-Z]*"))
.withHeader("Accept", equalTo("application/json"))
.withHeader("User-Agent", equalTo("Mozilla/5.0"))
.withHeader("X-TEST-Header", equalToIgnoreCase("custom test header"))
.withCookie("session", equalTo("1234-5678-9012"))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withHeader("Cache-Control", "no-cache")
.withHeader("X-TEST-Options", "debug")
.withBody(responseData)
)
)
// exercise
// テスト対象コードの実行
val result = getSomething("/my/member/99", listOf("ref" to "YDpXhvOfkR"))
// verify
// テスト対象コードの検証
assertThat(result).contains("\"id\": 99", "\"name\": \"jack\"", "\"age\": 30")
verify(1, getRequestedFor(urlPathEqualTo("/my/member/99")))
verify(getRequestedFor(urlPathMatching("/my/member/[0-9]*"))
.withQueryParam("ref", matching("[a-zA-Z]*"))
.withHeader("Accept", matching("application/json"))
)
}
スタブの設定に一致しないリクエストが発行された場合、例えばこのスタブはrefというリクエストパラメータは半角英字だけを受け取る設定ですが、それ以外の文字が含まれた値でリクエストするとテストは失敗します。
withQueryParam("ref", matching("[a-zA-Z]*"))
このスタブに対して/my/member/99?ref=abc123
のようなリクエストを発行すると、テストは失敗しコンソールに下記のような原因が出力されます。
左側の"Closest stub"がスタブの設定、右側の"Request"が発行したリクエストの内容です。
Request was not matched
=======================
-----------------------------------------------------------------------------------------------------------------------
| Closest stub | Request |
-----------------------------------------------------------------------------------------------------------------------
|
GET | GET
/my/member/[0-9]* | /my/member/99?ref=abc123
|
Accept: application/json | Accept: application/json
User-Agent: Mozilla/5.0 | User-Agent: Mozilla/5.0
X-TEST-Header: custom test header | X-TEST-Header: custom test header
|
Query: ref [matches] [a-zA-Z]* | ref: abc123 <<<<< Query does not match
|
Cookie: session=1234-5678-9012 | 1234-5678-9012
|
|
-----------------------------------------------------------------------------------------------------------------------
BAD Requestのスタブ
GETリクエストに対しHTTPステータス400のBad Requestをレスポンスさせるスタブの例です。
HTTPステータス400を返すのにbadRequestメソッドを使用していますが、withStatus(400)でも同じ結果になります。
他に次のようなステータス別のメソッドが用意されています。
- 401 : unauthorized()
- 403 : forbidden()
- 404 : notFound()
- 500 : serverError()
このテスト対象コードはHTTPステータス200以外は例外をスローするように実装しているので、AssertJで例外がスローされることを期待する検証コードを書いています。
@Test
fun `test bat request example`() {
// setup
stubFor(get(urlPathEqualTo("/my/member/abc"))
.willReturn(
badRequest()
)
)
// exercise & verify
// テスト対象コードの実行
assertThatExceptionOfType(IllegalStateException::class.java).isThrownBy {
getSomething("/my/member/abc")
}
.withMessage("api call failure")
verify(1, getRequestedFor(urlPathEqualTo("/my/member/abc")))
}
POSTリクエストのシンプルなスタブ
JSON文字列をPOSTするリクエストを受けて"success"という文字列を返すスタブの例です。
@Test
fun `test simple post example`() {
// setup
val postData = """
|{
| "id": 120,
| "name": "jack",
| "age": 50
|}
""".trimMargin()
stubFor(post(urlPathEqualTo("/my/member"))
.withHeader("Content-Type", equalTo("application/json"))
.withRequestBody(
equalToJson("{\"id\":120, \"name\":\"jack\", \"age\":50}")
)
.willReturn(
aResponse()
.withStatus(200)
.withBody("success")
)
)
// exercise
val result = postSomething("/my/member", postData)
// verify
// テスト対象コードのassertion
assertThat(result).isEqualTo("success")
verify(1, postRequestedFor(urlPathEqualTo("/my/member")))
verify(postRequestedFor(urlPathEqualTo("/my/member"))
.withHeader("Content-Type", matching("application/json"))
)
}
withRequestBodyメソッドで、このスタブのPOST時のリクエストボディの設定を行っています。
equalToJsonメソッドで設定すると、実際のPOSTリクエストのリクエストボディと一致している必要があります。
matchingJsonPathメソッドを使うと部分一致で設定できます。
matchingJsonPath("name", equalToIgnoreCase("jack"))