はじめに

品質担保やバグの早期発見のために、Androidアプリの単体テストを行うことは重要です。しかし、単体テストの導入には多くのハードルがあります。本記事では、Androidアプリの単体テストの基本技術を解説します。特にViewModelをテスト対象として想定しています。ViewModelでテスト可能な技術力を磨けば、ビジネスロジック層やデータアクセス層などへの単体テストは容易です。

環境

下記ライブラリを使用します。

testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.11.1'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
testImplementation "io.mockk:mockk:1.13.9"
testImplementation "org.jetbrains.kotlin:kotlin-reflect:1.9.10"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'

mockkはKotlinに特化したモックライブラリです。Mockitoはsuspend関数のモック化に弱いなどの弱点があるので、Kotlinで書かれたコードにはmockkを使うことを筆者はおすすめします。

robolectricはContextを使うものなどに対応するために使います。

テストのファイル構造

プロジェクトの初期作成時にテストを指定しないとテスト用のディレクトリが作成されていないこともあります。その場合は下記のように作成すると良いです。

app - src - main - (既存の実実装)
          - test - java - XXX(comなど) - XXX(exampleなど) - XXX(myTestApplicationなど) - XXXViewModelTest.kt

XXXフォルダはmain以下と同じような構成で作れば問題ないです。この記事では触れませんがE2Eテストの時はandroidTestを使うため、testと同じようにandroidTestを作成します。

テストの基本構造

テストのライブラリやテストの書き方自体に不慣れな時期は下記をテンプレートにして書くことをオススメします。

@ExperimentalCoroutinesApi
@RunWith(RobolectricTestRunner::class)
class XXXXViewModelTest {
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

   @Before
    fun setUp() {
        Dispatchers.setMain(Dispatchers.Unconfined)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
        clearAllMocks() // 全体テストの時にやらないと影響が出てくる
        unmockkAll() // mockStaticしているときは必ず入れる
    }
}

instantTaskExecutorRuleはこの記事では触れませんが、慣れないうちはとりあえずつけておくことをオススメします。テストに慣れてきたら調べてみてください。

setUpメソッドは各テストを走らせる前に走るものになります。また、その反対でtearDownメソッドは各テストが終わった後に走るものになります。対象となるViewModelで使っているAPIアクセスインターフェースなどがあれば、setUpメソッドでmock化しておくと良いです。

各メソッドへのテストの仕方

先ほどのテストの基本構造を使って、各メソッドへのテストを書いていきます。

class XXXXViewModelTest {
    @Test
    fun `API001へのアクセス 正常系テスト`() {
        // ここにテストの内容を書く
        // 以下サンプル
        val result = viewModel.simpleTest()
        assertEquals("testdesu", result)
    }
}

@Testアノテーションをメソッドの上につけるとそのメソッド内容がテストされるようになります。また、メソッド名は日本語で書いたほうがわかりやすかったりするため、このサンプルのように書くことをオススメします。

テストを書くときのコツは情報の流れを意識することです。プログラムの流れは入力に対して出力があるため、テストするメソッドに対する適切な入力を考え、その出力はどのような想定しているかを考えることが重要です。慣れないうちは、テストを書く前に入力と出力をメモ用紙に書いてみるのをオススメします。例えば、下記コードがあったとします。

fun methodSample(x: Int): Int {
    return if (x > 0) {
        x
    } else {
        -1
    }
}

このコードは入力が0より大きい場合はそのまま返し、0以下の場合は-1を返します。このメソッドに対してテストを書く場合、入力が0より大きい場合と0以下の場合を考える必要があります。その出力はそれぞれ入力と同じ値と-1になることが想定されます。筆者ならこれを次のように紙にメモします。

case1: 入力が0より大きい場合 => 入力値と同じ値
case2: 入力が0以下の場合 => -1

このようにメモを取り、入力の日本語をそのままメソッドにすると大抵は上手くテストを書くことができます。

@Test
fun `入力が0より大きい場合`() {
    val input = 1
    val result = methodSample(input)
    assertEquals(input, result)
}

@Test
fun `入力が0以下の場合`() {
    val input = 0
    val result = methodSample(input)
    assertEquals(-1, result)
}

このようにテストを作っていきます。今回のメソッドは1と0の間で結果が変わります。これを境界値と呼び、入力が0より大きい場合のテストは999でも999999でも問題ないですが、境界値を使うことでより品質の高いテストを書くことができます。同様に入力が0以下の場合も境界値の0を使ってあげると良いです。これをやることで、開発中についうっかりif (x >= 0)としてしまった場合にテストがこけてくれるため、バグを防ぐことができます。

テストダブル

テストダブルは慣れないうちは理解に苦しむ概念ですが、これなしではテストコードを作るには難しいです。慣れないうちは、外部APIとの通信オブジェクトなどのダミーデータを作るものと覚えてみてください。テストダブルを完全に理解するには入力と出力を正しく理解することが必要ですが、それについて触れると1つの記事では収まらないため、ここでは使い方だけを紹介します。また、テストダブルは深く理解するといくつかの概念が出てきますが、mockkにおいては2つの概念を理解すれば十分であり、論理的に厳密には定義が異なるが使う分に不都合がないように解説します。

モック

モックとはテスト対象ではないが、コードを実行するときに必要なものをダミーデータに差し替えるものです。例えば、ViewModelを初期化するのにログに関する処理をするモジュールを読み込む必要があるが、テスト対象ではそれを必要としていないものです。

class XXXXViewModel constructor(
    private val logRepository: LogRepository
) : ViewModel() {
    fun methodSample(x: Int): Int {
        // logRepositoryは使わない処理
        ...
    }
}

このようなViewModelがあるとき、logRepositoryは使わない処理なので、テスト対象ではないです。このような場合にモックを使います。

val logRepository = mockk<LogRepository>() // モック化
val viewModel = XXXXViewModel(logRepository)

このようにモックを使うことで、テスト対象の処理を行うことができます。また、logRepositoryに存在するsendLogというメソッドを使うが、テスト対象の出力には直接関わらないものもモックの対象になります。厳密にはこれはモックではなく、スタブになるのですが、mockkを使う分にはモックとして覚えておいて問題ありません。業務では、APIアクセスするモジュールなどがそれに該当します。

class XXXXViewModel constructor(
    private val logRepository: LogRepository
) : ViewModel() {
    fun methodSample(x: Int): Int {
        val res = logRepository.sendLog("$xが入力されました") // 出力には関係ない
        if (res.isSuccess) {
            Timber.d("ログ送信成功")
        }
        ...
    }
}

このような場合、logRepositoryはモック化しますが、sendLogメソッドに対する仮想処理も同時に実装する必要があります。

val logRepository = mockk<LogRepository>() // モック化
every { logRepository.sendLog(any()) } returns Result.success(Unit)
val viewModel = XXXXViewModel(logRepository)

このようにeveryを使って、sendLogメソッドの返り値を設定することが出来ます。ただし、sendLogメソッドがsuspend関数である場合は、coEveryを使って設定します。

val logRepository = mockk<LogRepository>() // モック化
coEvery { logRepository.sendLog(any()) } returns Result.success(Unit)
val viewModel = XXXXViewModel(logRepository)

API返り値が異常系のテストする場合は、Result.failureを使うと良いです。このようにすることで、APIサーバーの状況に依存することなく、テストを行うことができます。基本的にテストでは状態を持たないようにすることが重要です。状態を持つと、APIサーバーが悪いのかテストが悪いのかがすぐに分からなくなり、そのテストコードの価値はなくなります。このときにモックが使えるか考えてみてください。

スパイ

スパイは結構分かりにくい概念になるので、慣れないうちは正しく使うパターンを覚えてから理解していくことをオススメします。Androidにおいてスパイが必要な場面はLiveDataをチェックするときです。LiveDataは直接的な出力ではないので、今まで習った技術でテストするのは難しいです。そのときにスパイを使います。

class XXXXViewModel constructor(
) : ViewModel() {
    val liveData = MutableLiveData<String>()

    fun methodSample(x: Int): Int {
        liveData.postValue("$xが入力されました")
    }
}

このようなViewModelがあるとき、methodSampleの出力はliveDataを通じて出力されます。これをテストするには次のようにします。

val liveDataObserver = spyk(MutableLiveData<String>()) // スパイ化
viewModel.liveData.observeForever(liveDataObserver)
viewModel.methodSample(1)
verify(exactly = 1) {
    liveDataObserver.onChanged("1が入力されました")
}

このようにスパイを使い、LiveDataが絡むコードのテストをすることができます。また、スパイはテストを完了させているコードを使うコードのテストの時にも効果的に使えます。

class XXXXViewModel constructor(
) : ViewModel() {
    val liveData = MutableLiveData<String>()

    fun methodSample(x: Int): Int {
        val res = addFive(x)
        if (res > 10) {
            liveData.postValue("$resが出力され、10より大きいです")
        } else {
            liveData.postValue("$resが出力されました")
        }
    }

    fun addFive(x: Int): Int { // 既にテストが完了しているメソッド
        val res = x + 5
        return res
    }
}

このようなコードだとあまり恩恵を感じませんが、addFiveメソッドが複雑で、addFiveの出力に依存してmethodSampleへの入力が複雑になるときはaddFiveのテストが完了していることを前提にmethodSampleのテストを簡潔にすることができます。ただし、このようになる時はそもそもコード品質に問題があることが多いので元のコードを見直してみることをオススメします。業務上の理由などで仕方なく、そのままのコードでテストを行う時に検討してみてください。

val viewModel = spyk(XXXXViewModel(), recordPrivateCalls = true) // recordPrivateCallsはprivateメソッドのスタブ化を可能にする
every { viewModel["addFive"](any()) } returns 10 // どんな値が来ようが, addFiveは10を返す

このようにテスト対象のメソッドを持つViewModel自体をスパイ化することで、addFiveメソッドの出力を固定にすることができます。

評価

評価も考えて書くとテストの品質が良くなります。そこで次のような評価を考えてみてください。

MatcherAssert.assertThat(
    "入力値が0以上の時はその値を返す",
    targetValue,
    CoreMatchers.equalTo(targetValue)
)

このように評価を書くことで、テスト失敗時にどのようなテストが失敗したかメッセージで把握しやすくなります。また、LiveDataの評価はassertThatではなく、verifyを使うことが多いです。

verify(exactly = 1) {
    liveDataObserver.onChanged("1が入力されました")
}

verifyの主な引数は次のようになります。

  • exactly: メソッドが正確に何回呼び出されたか
  • atLeast: メソッドが少なくとも何回呼び出されたか
  • atMost: メソッドが最大で何回呼び出されたか

これらの引数を正しく使うことでLiveDataの無駄な発火を検知することが出来、結果的にパフォーマンス向上にも繋がったりします。また、LiveDataだけでなくメソッドに対しても使うことが出来ます。他引数や似たような評価メソッドがあるので慣れてきたらこちらを読むことをオススメします。

privateメソッド

privateメソッドのテストについて、色々な意見があります。

  • privateメソッドの呼び出し元のpublicメソッドにテストを当てれば良い
  • privateメソッドをやめてpublicメソッドにする
  • リフレクションをかけてprivateメソッドをテストする

などがあります。privateメソッドのままリフレクションを使ってテストするのが良さそうに見える開発者が多いかもしれませんが、リフレクションは型安全性を損なうなどの否定的な観点もあります。また、privateメソッド自体カプセル化されているもののため、publicメソッドに比べてアクセスされる回数も少なく、それ自体が外に出るものではないのでテストをかける必要がないという意見もあります。どの選択かはチーム方針に依存することが多く、開発者はprivateメソッドへのテストの仕方も理解しとくべきです。基本的にKotlinでは下記のようにprivateメソッドにアクセスします。

val privateMethod = targetViewModel.javaClass.getDeclaredMethod("privateMothodName", String::class.java)
privateMethod.isAccessible = true
privateMethod.invoke(targetViewModel, "")

このようにしてprivateメソッドにアクセス可能にします。また次のような高階関数を持つものに対するやり方は少し難しいです。

private fun privateMethod(complete: () -> Unit) {
    ...
}

このような場合は次のようにします。

val privateMethod = targetViewModel::class.java.getDeclaredMethod("privateMethod", Function0::class.java)
privateMethod.isAccessible = true
privateMethod.invoke(targetViewModel, complete)

Kotlinの言語仕様の話になるのでFunction0の説明は割愛しますが、気になる方はこちらを読んでみてください。また、さらにそれがsuspend関数だった場合は次のようにします。

val privateMethod = targetViewModel::class.java.getDeclaredMethod("privateMethod", Function0::class.java, Continuation::class.java)
privateMethod.isAccessible = true
privateMethod.suspendFuncInvoke(targetViewModel, complete)

suspend関数を同期的にするために次のような拡張関数も用意する必要があります。

suspend fun Method.suspendFuncInvoke(obj: Any, vararg args: Any?) : Any? =
    suspendCoroutineUninterceptedOrReturn { cont ->
        invoke(obj, *args, cont)
    }

このようにしてsuspendなprivateメソッドにアクセスが可能になります。仕組みが気になる方はこちらを読んでみてください。このようにprivateメソッドに対するテストはKotlinについてかなり深く理解している必要があります。筆者の見解は複雑性から来る保守性の悪さからprivateメソッドはテスト対象外にするか、publicメソッドに変更することを提案するのが良いと思います。それでもどうしても必要な場合は、リフレクションを使ってテストしてみてください。

テストコード内でsuspend関数を使う

あまり使うことはないと思いますが、テストコード内で既存のデータ挿入APIを使用して、データを差し込むなどでsuspend関数を使う時は次のようにします。

@Test
fun testMethod() = runTest() {
}

もしくはrunBlockingでも可能です。

最後に

いくつかのセクションで紹介したテストの1つ1つの技を組み合わせれば、多くのViewModelやそれより下層のモジュールにテストを当てることができるようになります。テストが個人的な知識不足ではなく単純に作りにくいと感じたら実実装のコード品質が悪いことがあります。改修時にバグを引き起こす危険性があるため、テストの観点だけでなく、リファクタの観点からもコード評価することをオススメします。また、今回の記事でまとめた内容で対応できないテスト対象はKotlinやライブラリの知識を深く要求されることが多いです。例えば、handler.postが絡むコードなどはhandlerをmock化し、その実行引数Runnableを取り出して、走らせるようにしないといけなかったりします。その時はanswersを使ってinvocation.argsにアクセスしたりなどが必要になります。壁にぶつかったらKotlinの知識を深めることをオススメします。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる