Mokcito-KotlinでAndroidアプリのテストコードを書いてみた
目次
この記事は、ニフティグループ Advent Calendar 2023, 19日目の記事です。
はじめに
こんにちは。「ニフティ不動産」アプリのAndroid側の開発を担当している、TS-Elfenです。今回は、 Mockito-Kotlin
を使った、Androidアプリのユニットテストについて紹介したいと思います。Mockito-Kotlin
を使うと、外部のライブラリに依存している実装にもテストコードを書くことができるようになります。
Mockito/Mockito-Kotlinとは?
Mockito
はJavaのユニットテストのために開発された、モックフレームワークです。モックフレームワークとは、システムの一部をテストするために、システムに依存している部分をモックとして置き換えるためのフレームワークです。Mockito
はMITLicenseのライブラリであり、2023/11/27時点で14.3kのStarを有しています。
MockitoはJava用のため、Kotlinでそのまま使用するには使いにくい部分があります。例えば、whenが予約語となっているKotlinのコードでは、下記のようにエスケープシーケンスをつける必要があったり、Nullableの取り扱いがJavaとKotlinで異なるため、特有のエラーが起きるなどの問題も起きます。
val sharedPreferences = mock(SharedPreferences::class.java)
val editor = mock(SharedPreferences.Editor::class.java, RETURNS_DEEP_STUBS)
`when`(sharedPreferences.edit()).thenReturn(editor)
`when`(editor.commit()).thenReturn(true)
`when`(editor.putString(anyString(), anyString())).thenReturn(editor)
Mockito-Kotlinは、MockitoをKotlin向けに使いやすくしたライブラリです。Niek Haarmanさんが作ったライブラリで、2023/11/27時点で3kのStarを有しています。
使用方法&ユースケースごとのサンプル
Mockito-Kotlin
を用いたユニットテストの書き方について、事前準備と代表的な使い方を紹介します。
事前準備
まず、 app
配下のbuild.gradle
に下記を記載します。この時、JVMのバージョンなどでうまくインポートができない場合があるので、自身のプロジェクトの状況に応じてバージョンを調整してください。
testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0"
次に、UT作成の障壁となっている外部依存のオブジェクトを外から渡すように修正します。
// Before
import org.hoge.ExterenalInstance
class HogeDatastore() {
val exterenalInstance = ExterenalInstance.newInstance()
fun getPower() = externalInstance.getPower() // 外部パッケージのインスタンスを内部生成して、メソッド呼び出し
}
例えば、上記のような書き方をしていると、getPower
のテストを書きたい!と思っても、ユニットテストのコンテキストから ExternalInstance
を呼び出しても思った返却値が得られない、呼び出してもExceptionを出力してしまうといった可能性があります。そこで、下記のように、外部のクラスを用いる場合は、引数として受け取るように書き換えるようにします。
import org.hoge.ExterenalInstance
class HogeDatastore @Inject constructor(
private val externalInstance : ExternalInstance//★外部から渡せるようにしておく、Hilt導入済みならModuleを作る
) {
fun getPower() = externalInstance.getPower() // 外部パッケージのインスタンスを内部生成して、メソッド呼び出し
}
import org.hoge.ExterenalInstance
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
class ExternalInstanceModule {
@Provides
fun provideExternalInstance() : ExternalInstance = ExtrenalINstance.newInstance()
}
これによって、モックオブジェクトを用いたユニットテストを書くことができます。
オブジェクトの特定メソッドの戻り値を適当なものに変えたい場合
val mockSharedPreference = mock<SharedPreferecens> { // ★Mock化するインスタンスの指定。
on {
getString(Mockito.any()) // ★Mock化したインスタンスの、挙動を変更するメソッドの指定。
} doReturn "Test" // ★戻り値の指定
on {
getBoolean(Mockito.anyString()) // ★複数ある場合はon{}を繋げて書けばOK
} doReturn false
}
val dataStore = AppConfigurationDataStore( RuntimeEnvironment.systemContext, mockSharedPreference)
dataStore.loadStringValue("testKey").collect { result ->
result.onSuccess {
Truth.assertThat(it).isEqualTo("Test")
}
result.onFailure {
assert(false)
}
}
mock<XXX>
で任意のオブジェクトをモックにし、インラインで挙動を定義することができます。 on{ }
ブロックの中に変更したいメソッド名を記載し、引数を Mockito.anyXXX()
で指定します。今回は、String型を受け取る想定なので anyString()
を使います。この時、型を合わせないとエラーが発生する場合があるので注意しましょう。ブロックの後に、 doReturn
をつけて返したい値を書けば完了となります。
例外をテストしたい場合
@Test
fun MockitoTest2() = runBlocking {
val mockSharedPreference = mock<SharedPreferecens> {
on { getString(Mockito.any()) } doThrow Exception("")
}
val dataStore = AppConfigurationDataStore( RuntimeEnvironment.systemContext, mockSharedPreference)
dataStore.loadStringValue("testKey").collect { result ->
result.onSuccess {
assert(false)
}
result.onFailure {
assert(true)
}
}
}
外部インスタンスが出力するエラーに対して、自身のコードがちゃんと対処できているかを確認することを考えます。この場合は、doThrow
で例外を出力するように書き換えることが可能です。書き換えた後に、DataStoreなどで、エラー処理がちゃんと行えてるかを確認するだけでOKです。
他メソッドはそのまま一部メソッドの挙動を変更してテストしたい場合
private interface MyInterface {
fun getString(value: String): String
}
private open class Foo: MyInterface {
fun yeah(value: String): String = "yeah"
override fun getString(value: String): String = value
}
@Test
fun MockitoTest() = runBlocking {
val foo = spy<Foo> {
on { getString(Mockito.anyString()) } doReturn "hoge"
}
Truth.assertThat(foo.getString("")).isEqualTo("hoge")
Truth.assertThat(foo.yeah("")).isEqualTo("yeah") //★いじっていないyeahメソッドは通常通り動作する
}
mock
ではなくspy
を使うと、変更を要求したメソッドや変数以外は通常通りの動作になります。 mock
では、変更していないメソッドは全てnop
になってしまうので注意してください。
メソッドの呼び出し回数をテストしたい場合
@Test
fun MockitoTest3() = runBlocking {
val mockSharedPreference = mock<SharedPreferecens> {
on { getString(Mockito.any()) } doReturn ""
}
val dataStore = AppConfigurationDataStore( RuntimeEnvironment.systemContext, mockSharedPreference)
dataStore.loadStringValue("testKey").collect {
verify(mockSharedPreference, times(1)).getString("testKey")
}
}
例えば、DataStore
内で、特定の回数、特定のメソッドが呼ばれることを確認したい場合は、verify
とtimes
を使います。verify(<調べたいMock>、times(<呼び出し回数>)).<調べたいメソッド>
でそのMockの特定メソッドがどれだけ呼ばれているかを調べることができます。
外部メソッドに渡されている引数をテストしたい場合
private interface MyInterface {
fun getString(value: String): String
}
private open class StringAdder: MyInterface {
fun add (value: String) : String = getString("add" + value)
override fun getString(value: String): String = "getString" + value
}
@Test
fun MockitoTest() = runBlocking {
val stringAdder = spy<StringAdder> {
on { getString(Mockito.anyString()) } doReturn "hoge"
}
stringadder.add("fuga")
argumentCaptor<String>().apply {
verify(stringadder).getString(capture())
Truth.assertThat(allValues).containsExactly("addfuga")
}
Truth.assertThat(stringadder.getString("")).isEqualTo("hoge")
}
文字列を取得するメソッド getString
を持っている MyInterface
というインターフェースがいたとします。それを継承した、 StringAdder
には、 getString
を文字列を付与してから呼び出す add
というメソッドがいます。この時、 getString
が、自分が作成した add
の想定した呼び方がされているかを調べることを想定します。
この時は、argumentCaptor
という枠組みを使います。まず、調べたいメソッドを呼び出します。その後、argumentCaptor<引数の型>().apply{ }
ブロックの中で、またverify
メソッドを呼び出します。この時、調べたいメソッドの引数に capture()
を入力します。そして、その後は allValues
でキャプチャされた引数を全取得することができるので、想定通りの引数が入っているかをassert
で検証することで、引数に対するテストを行うことができます。
まとめ
本記事では、Mockito-Kotlin
の代表的な使い方について紹介しました。SharedPreference
やFirebaseRemoteConfig
などからの返却値を使ったコードでは、その返却値を擬似的なものに変えることが難しく、テストコードが書けなかったのですが、Mockito-Kotlin
でその課題は解消できました。今後、プロダクトの品質向上に役立てたいと思っています。Mockito-Kotlin
には、今回紹介した以外にもさまざまな使い道があるようなので、ぜひ皆さんも調べて使ってみてください!
参考にさせていただいたURL
掲載内容は、記事執筆時点の情報をもとにしています。