KotlinでMockテストのまとめ
1ヶ月ほどkotlinで開発をしてきて、不慣れなkotlinであってもテストをしっかり書いていこうと目標を立て臨んだ1ヶ月。
おかげでkotlinにおけるMockテストの知見が溜まってきたので、この機会にまとめていきます。
javaではJMockitでMockテストを書いてきたけど、いざkotlinでとなると弊害が多くMockitoやPowerMockに置き換えながら試していった。
どんなテストをするのか
トランザクションで扱う複数の関数のテストを次のように実現できるとプログラムを網羅的にテストできるでしょう。
- クラス全体のパブリック関数のモック
- クラス一部分のパブリック関数のモック
- 呼び出す関数の引数のモック(anyString()やany()を使う)
- クラスのプライベート関数のモック
- クラスのプライベート関数のアサーションテスト
何をつかったか
次のテストライブラリを使いました。
- Powermockはトランザクションで実行されるプライベート関数のモックに使います。
- PowermockにはMockitoが内包されています。Mockitはクラス全体のパブリック関数のモックや一部分のパブリック関数のモックに使います。
github.com
テストケースはJUnitではなく、kotlintestを使いました。BehaviorSpecを使うとテストコードの見通しが良くなりますね。後述しますがPowermockを使う場合はJUnitでないと動かないところがあるのでJUnitも使っています。
それではテストコードのまとめです
テスト対象とするクラスの説明
次のようなinterfaceを用意しました。
interface Ppap { fun leftHand(sing: Boolean): String fun rightHand(sing: Boolean): String fun woo(right: String, left: String): String }
ピコ太郎のPPAPソングをクラス表現しました。
- leftHand(sing: Boolean)関数は左手に持っているモノを返す関数です。引数のsingがtrueだと歌詞を返し、falseだとモノの名前を返します。(rightHand(sing: Boolean)関数も右手に持つモノで振る舞いは一緒です)
- woo(right: String, left: String)は右手と左手のモノを合わせた歌詞を返します。
Ppapの実装クラスです。
open class PpapImpl constructor(val right: String, val left: String) : Ppap { val iHaveA = "I have" override fun leftHand(sing: Boolean): String { if (sing) return "$iHaveA $left." else return getObj(left) } override fun rightHand(sing: Boolean): String { if (sing) return "$iHaveA $right." else return getObj(right) } override fun woo(right: String, left: String): String = "%s%s.".format(left, right) private fun getObj(obj: String) = obj.split(Regex("\\s+")).get(1) }
こちらのテストコードを見て頂くとプログラムのイメージが伝わるはずです。
PpapImplを使ってPPAPソングを歌うPpapSongクラスを用意しました。
class PpapSong constructor(val ppap: PpapImpl) { fun sing(): String { return "%s %s woo %s".format( ppap.rightHand(true), ppap.leftHand(true), ppap.woo(ppap.rightHand(false), ppap.leftHand(false))) } }
PpapImplの関数をrightHand()からleftHand()、woo()と順番に呼び出しています。
PpapImplクラスの関数をモックしながらPpapSongクラスのテストをしました。
クラス全体のパブリック関数のモック
はじめにクラス全体のパブリック関数のモックです。Mockitoを活用していきます。
val ppapMock: PpapImpl = mock(PpapImpl::class.java) // PpapImplをモック対象にする val ppapSong = PpapSong(ppapMock) Mockito.`when`(ppapMock.rightHand(sing = true)).thenReturn("I have a Ebi.") // 各メソッドをモック Mockito.`when`(ppapMock.leftHand(sing = true)).thenReturn("I have a Bin.") Mockito.`when`(ppapMock.rightHand(sing = false)).thenReturn("Ebi") Mockito.`when`(ppapMock.leftHand(sing = false)).thenReturn("Bin") Mockito.`when`(ppapMock.woo("Ebi", "Bin")).thenReturn("EbiInBin.") val actual = ppapSong.sing() actual shouldBe "I have a Ebi. I have a Bin. woo EbiInBin."
Mockitoをkotlinで使ってみた、のようなコードです。
クラス一部分のパブリック関数のモック
次に一部分の関数をモックするパターンです。Mockitoのspyを使います。
val target: PpapImpl = PpapImpl(right = "a Pen", left = "an Apple") val spy = spy(target) // PpapImplをspy対象にする val ppapSong = PpapSong(spy) Mockito.`when`(spy.leftHand(sing = true)).thenReturn("I have a PineApple.") // 左手をAppleからPineAppleにモックする Mockito.`when`(spy.leftHand(sing = false)).thenReturn("PineApple") val actual = ppapSong.sing() actual shouldBe "I have a Pen. I have a PineApple. woo PineApplePen."
leftHandの呼び出しのみモックしました。他の関数はすべてモックされず実行しています。
最後が`ApplePen`ではなく`PineApplePen`になっているのが確認できますね。
関数の引数のモック(anyString()やany()を使う)
次に関数の引数をany()などを使い任意の引数にマッチさせたりマッチが難しい場合にany()で任意の値でマッチさせるケースです。
※マッチが難しいケースとしては引数にプリミティブな型を使っていないケースや、現在時刻のLocalDateTimeを引数にしているケースなどが考えられます。
val target: PpapImpl = PpapImpl(right = "a Pen", left = "an Apple") val spy = spy(target) val ppapSong = PpapSong(spy) Mockito.`when`(spy.woo(anyString(), anyString())).thenReturn("PenPineAppleApplePen.") // wooメソッドの引数は任意でマッチさせてReturnの文字列を書き換えています val actual = ppapSong.sing() actual shouldBe "I have a Pen. I have an Apple. woo PenPineAppleApplePen."
このケースではプリミティブな型のためanyString()を利用しています。
プリミティブ型以外の場合はany()やanyObject()を使うところですが、kotlinでは嵌まりました。
詳細は以下の記事に詳しく載っていますので参照ください。
Kotlin + Mockitoでany<T>()やeq<T>()を使いたい - JDBな人生
テスト対象の関数の引数がNon-Nullなオブジェクトかどうかkotlinがチェックするため起こる現象で解決する方法も記載頂いています。
私はこのケースでド嵌まりしまして、この記事に救われました。
クラスのプライベート関数のモック
次にクラスのプライベート関数のモックです。このケースはPowerMockを使います。
@RunWith(PowerMockRunner::class) @PrepareForTest(PpapImpl::class) class PpapSong_Private_Method_MockTest { @Test fun rightHand_test() { val target: PpapImpl = PpapImpl(right = "a Pen", left = "an Apple") val spy = PowerMockito.spy(target) PowerMockito.doReturn("Ebi").`when`(spy, "getObj", "a Pen") val actual = spy.rightHand(false) assertThat(actual, `is`("Ebi")) } }
- PpapImplのrightHand関数をモックしています。doReturn()を`Ebi`にしました。
- クラスに@RunWithと@PrepareForTestのアノテーションを指定します。
- @PrepareForTestにはテスト対象のクラスを指定します。今回はPpapImplクラスになります。
- PowerMockRunnerクラスの実装を見ると今のところはJUnitのみをサポートしているようなので、PowerMockを使う場合はJUnitでテストケースを書きます。
クラスのプライベート関数のアサーションテスト
最後にクラスのプライベート関数のアサーションテストです。
given("getObj method") { val target = PpapImpl(right = "a Pen", left = "an Apple") var param: String `when`("param is 'a Pen'") { then("method should return 'Pen'") { param = "a Pen" val actual = Deencapsulation.invoke<String>(target, "getObj", param) actual shouldBe "Pen" } } `when`("param is 'an Apple'") { then("method should return 'Apple'") { param = "an Apple" val actual = Deencapsulation.invoke<String>(target, "getObj", param) actual shouldBe "Apple" } } `when`("param is 'Ebi'") { then("method throws exception") { param = "Ebi" shouldThrow<ArrayIndexOutOfBoundsException> { Deencapsulation.invoke<String>(target, "getObj", param) } } } }
まとめ
objectやenumクラスの例はありませんでしたがプロジェクト内ではテストケースを書いており上記で紹介した書き方と同様な書き方でテストができています。
kotlinでもモックテストをガシガシ書けますし、これからはJava製のモックライブラリではなくkotlin純正のライブラリが出てくることを期待しています。
mockito-kotlinというkotlinで書かれたモックライブラリがありましたがMockitoをラッピングしているライブラリとなっています。どこかのエントリでこちらの使用感の報告もできればしたいです。
ソースを公開しています
ソースコードを公開しています。
github.com
Kotlin + Spring Boot/ResponseEntityを使ったJSONレスポンスにJacksonの@JsonPropertyを有効にする
Spring Bootを使ってkotlinで書いています。サーバサイドでkotlinを使うと新たな発見があるのでいいですね。
ControllerのレスポンスにResponseEntityを使ったところdata classのプロパティを@JsonPropertyでリネームしたのに有効になりませんでした。
自分のググラビリティが低く解決方法が見つからず小一時間ほど費やしてしまいました。同じような人(いれば)のために解決方法をメモします。
こんな環境で試しました
- Spring Bootは1.4.2
- kotlinは1.0.4
次のようなdata classを用意します。
data class Member( val userId: Long, val name: String, @JsonIgnore val age: Int, @JsonProperty("isGold") val gold: Boolean = false )
@JsonProperty("isGold")のようにJsonレスポンスではgoldのプロパティ名をisGoldにしたいです。
次のようなコントローラーでMemberクラスを返します。
@RequestMapping(value = "/member/{id}", method = arrayOf(RequestMethod.GET)) open fun getUser(@PathVariable id: Long): ResponseEntity<Member> { return ResponseEntity(Member(id, "name", 20, true), HttpStatus.OK) }
次のようなレスポンスになります
curl -X GET http://localhost:8080/test/member/1 | python -m json.tool { "gold": true, "name": "name", "userId": 1 }
ageは @JsonIgnoreで隠れているのに、goldがisGoldではありません。
こんな状況でした。
kotlinモジュールを使う
kotolinモジュールを追加すると解決します。
github.com
Gradle:
compile "com.fasterxml.jackson.module:jackson-module-kotlin:2.8.4"
kotlinモジュールを追加すると次のようなレスポンスになります
curl -X GET http://localhost:8080/test/member/1 | python -m json.tool { "age": 20, "isGold": true, "name": "name", "userId": 1 }
isGoldになりました!!・・??、@JsonIgnoreしたageがignoreされていない。。
kotlinモジュールを有効にしたら@JsonIgnoreの宣言は以下のように変える必要があります。
data class User( val userId: Long, val name: String, @get:JsonIgnore val age: Int, @JsonProperty("isGold") val gold: Boolean = false )
data classを更新して再度レスポンスをとると
curl -X GET http://localhost:8080/test/member/1 | python -m json.tool { "isGold": true, "name": "name", "userId": 1 }
期待する結果となりました:clap:
kotlinで@JsonPropertyの使い方を調べていたら棚から牡丹餅のように@JsonIgnoreの使い方も学べました。
ソースを公開しています
ソースコードを公開しています。
github.com
ElasticsearchのScroll APIをためしてみた
気になっていたElasticsearchのScroll APIの使用感を記録します。最近の開発でScroll APIを採用したい欲求がありましたが、使用感を調べる前で採用は見送りました。このままだと気になったまま使わないことになりそうなので、この機会にまとめます。
※ version 2.4をつかいました。
Scroll APIは通常のSearch requestのoffset/limitでページング取得をしないため処理中のデータ抜けが防げるメリットがあります。またScroll APIは初回リクエスト時の結果をスナップショットすることで安定した応答速度を担保します。
スナップショットをとるためリアルタイムのデータ処理の利用には向いていません。(スナップショットの挙動について試してみたので後述しています)
どんなふうに使うか?
通常のクエリとscroll=1mを加えたリクエストを送ります。(size=1にしています)
curl -XGET 'http://localhost:9200/_search?scroll=1m&size=1&pretty' -d ' { "query" : { "match" : { "category_id" : 100 } } }'
次のような検索結果(1件)と合わせて_scroll_idが返ってきます。
{ "_scroll_id" : "cXVlcnlUaGVuRmV0Y2g7NTs4OkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7OTpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzEwOkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7MTE6RlFCOTVUYkhSbGFGblFWUGdVai1hdzsxMjpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzA7", ・・・ "hits" : { "total" : 52, ・・・ } }
2件目の取得を行うために/_search/scrollのエンドポイントへscroll_idをRequest Bodyに加えてリクエストします。クエリは必要ありません。
curl -XGET 'http://localhost:9200/_search/scroll?pretty' -d ' { "scroll_id": "cXVlcnlUaGVuRmV0Y2g7NTs4OkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7OTpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzEwOkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7MTE6RlFCOTVUYkhSbGFGblFWUGdVai1hdzsxMjpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzA7" }'
- 2回目以降はscroll_idを送ることで初回のリクエスト時に送った検索条件の結果が返ってきます。
- 3回目以降も同様にscroll_idを送ることで3件目、4件目、5件目・・・と結果を取得できます。
- 初回にsize=1としたため、2回目以降の結果も1件になります。
- またscroll=1mとしたことで初回にリクエストした検索条件の結果を1分間の有効期限でスナップショットが取られます。
使い終わったscroll_idは破棄をする
スナップショットを残して置くのはコストがかかるためscrollが終われば次のようにscroll_idをクリアします。
curl -XDELETE localhost:9200/_search/scroll -d ' { "scroll_id" : ["cXVlcnlUaGVuRmV0Y2g7NTs4OkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7OTpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzEwOkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7MTE6RlFCOTVUYkhSbGFGblFWUGdVai1hdzsxMjpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzA7"] }'
複数のscroll_idをまとめてクリアもできます。
Scroll APIを使うときのメモ
- Scroll APIの初回のリクエストはscroll_idを取得するためのものではなく、検索結果に加えてscroll_idが返ってくる。
- Aggregationを含んだリクエストの場合、Aggregationの結果は初回のみ返ってくる。
- ソート条件に制約がなければsort orderは_docにすることで安定した応答速度が得られる。
kotlin + 公式Elasticsearch ClientでScroll APIをためしてみる
せっかくなのでkotlinでコードからScroll APIをためしてみました。使ったクライアントは公式のElasticsearch Clientです。
※ version 2.4.3をつかいました
スナップショットは本当に有効なのか?
scroll=1mと設定してインデックスされたデータをscroll取得している間に、新しいソースをインデックスしても取得結果のtotal件数に変化がないか試してみました。
以下のような流れで検証します。
scrollIdが取得できれば再帰的にログ出力を繰り返し、その間に新しいソースを1件追加していきます。
実行した結果は次のようになりました。
[INFO ] totalCount={104}, id={AVkna34Rhpv5RJ12skTc} // 初回取得時のtotal件数は104件 [INFO ] complete add source id={AVkqcnqbMJXjH5tvLcGB} //新しいソースの追加が成功 [INFO ] totalCount={104}, id={AVkna_83hpv5RJ12skTd} // total件数は初回取得時の104件から変わらずスナップショットが有効であることが確認できた [INFO ] complete add source id={AVkqcntGMJXjH5tvLcGC} [INFO ] totalCount={104}, id={AVknbG17hpv5RJ12skTf} ・・・
Scroll APIの仕様のとおりスナップショットが有効の状態であれば新しいソースを追加したとしてもスナップショットを指すscroll_idでリクエストをすると全体の件数は変わらないことが確認できました。
まとめ
- ページング処理ではないため取りこぼしがない。(そもそも初回時点のスナップショットのデータを対象にスクロールするので取りこぼしはないと思われる)
- offset/limitのページング処理のコードがなくなり、コードがシンプルになった。
- スナップショットの有効期限が切れた場合、SearchContextMissingExceptionがスローされる。
- SearchContextMissingExceptionを捕捉して新規スクロールを始める必要があるが、例外が起きた時点のソースIDを始点にスクロールを開始する・・・なかなか例外処理は複雑。
- 取得したscroll_idを用いた次のスクロールに有効期限を添えることで常にスナップショットの有効期限を更新すれば例外処理を避けることができそう。
- いまのプロジェクトに導入したくなってきたが、本番での処理時間を測定した上で最適な有効期限の設定をする必要があるため様子見する。