araki tech

for developers including me

【番外編】Android Kotlin日本語チュートリアル – 単体テストを書いてみる

Android Kotlin日本語チュートリアル

Android Kotlin日本語チュートリアル

本記事は連載Android Kotlin日本語チュートリアルの番外編その1です。

番外編その1では、Androidにおける単体テストについて学んでいきます。

最初に注意してほしいのは、テストのライブラリはこの記事で紹介しているもの以外にもたくさんあって、どれが良い悪いではありません。

みなさんの好みやプロジェクトによって適するものは変わってくるので参考程度にしてください。

また、本記事はAndroid Kotlin日本語チュートリアル⑨の続きとして話を進めますので、この記事単体ではもしかしたら文脈が読みづらいかもしれません。

そういう方は HiroshiARAKI/AndroidKotlinTutrialでソースを公開していますので参考にしてください。

単体テスト

ユニットテスト (UT: Unit Test) とも呼ばれますが、Android開発では2種類存在します。

  • android Test (Instrumentation Test / インストルメンテーション テスト)
    • エミュレータ (Android環境) を必要とするテスト
  • Test (ローカル単体テスト)
    • エミュレータを必要としないテスト

android Testはデータベース周りなど、実際にAndroid環境が必要なテストを指し、ただのTestはAndroidに依存しない単体テストを指しています。

それぞれ見ていきますが、まずはローカル単体テストから書いてみましょう。

MockKを使ったTest

普通の単体テストは src/test/ ディレクトリ下で管理されます。

まずは必要なライブラリをgradleに追記します。

今回はMockKと呼ばれるKotlinのテストライブラリを使ってみます。

MockKはテスト対象のモジュールにおいて関係ないものをモック化 (いわゆるフェイク挿入) して必要最低限のインスタンスを生成するためのライブラリです。

他にもMockitoなどのモック化ライブラリはありますが、Kotlinで実装している以上、Kotlinで提供されているMockKを使ってみます。

dependencies {
    // ...

    testImplementation "junit:junit:4.+"
    testImplementation "com.google.truth:truth:1.0.1"
    testImplementation "androidx.arch.core:core-testing:2.1.0"
    testImplementation "io.mockk:mockk:1.12.0"

    // ...
}

今回、通常のテストで必要なテストライブラリは以上です。

TimeUtilTest

ちょうど、依存関係がほとんど無いTimeUtilというクラスを持っていますので、これの単体テストを作ってみましょう。

まずは src/test/java/[package name]/ 下にTimeUtilTestを作成してください。

TimeUtilのような単純なクラスであれば下記のようなコードでテストが書けます。

import org.junit.Test
import com.google.common.truth.Truth.assertThat
import tech.araki.smartmemo.util.TimeUtil.toDateString
import tech.araki.smartmemo.util.TimeUtil.toDatetimeString


class TimeUtilTest {

    @Test
    fun `Long toDateString test`() {
        assertThat(TIME_20211209_1700.toDateString()).isEqualTo("2021/12/09")
    }

    @Test
    fun `Long toDatetimeString test`() {
        assertThat(TIME_20211209_1700.toDatetimeString()).isEqualTo("2021/12/09 17:00")
    }

    companion object {
        private const val TIME_20211209_1700 = 1639036800000L  // 2021/12/09 17:00
    }
}

下記のようにテストを実行してみると…

Android Kotlin日本語チュートリアル

Android Kotlin日本語チュートリアル

成功すればチェックマークがつきます。

上記は無事成功したようです!

Kotlinでは、通常のTestはバッククウォート ` ` で関数名を文章のように定義できます。

数字で始めたり、空白を含んだり、マルチバイト文字もOKです。

あと、好みによりますがテストで使用するデータはcompanion objectでまとめておくと全体の見た目が良くなります

MemoDetailViewModelTest

それでは、MemoDetailViewModelを対象に、もう少し複雑な単体テストを書いてみます。

MemoDetailViewModelには下記の、データベースなどには依存しない独立したメソッドが存在します。

/**
 * メモの新しい破棄日を取得する。
 * 現在の時刻より前の日付が選択されている場合は更新を行われずに[updateExpireFailEvent]を通知する。
 * @param memo
 * @param targetDateTime 更新対象の[DatetimePicker.DateTimeWrapper]
 */
fun getNewExpireDateTime(
    memo: Memo,
    targetDateTime: DatetimePicker.DateTimeWrapper
): Long {
    val originalExpireTime = memo.expireTimeMillis
    val now = System.currentTimeMillis()
    val newExpireTime = targetDateTime.toTimeMillis(originalExpireTime.toZonedDateTime())

    if (now > newExpireTime) {
        _updateExpireFailEvent.postValue(Unit)
        return memo.expireTimeMillis
    }
    return newExpireTime
}

しかし私たちが作成したMemoDetailViewModelは引数でApplication(Context)を必要としているAndroid依存なクラスです。

とは言え、テストではこのApplicationに関するメソッドは対象外です。

そこでモック化です。

実装例を見るのが早いと思いますが、要はApplicationをフェイクなもので置き換えて (モック化して) テストしてしまおう、という算段です。

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.google.common.truth.Truth.assertThat
import io.mockk.mockk
import org.junit.Rule
import org.junit.Test
import tech.araki.smartmemo.data.Memo
import tech.araki.smartmemo.dialog.DatetimePicker
import tech.araki.smartmemo.viewmodel.MemoDetailViewModel
import java.time.Instant
import java.time.ZoneId


class MemoDetailViewModelTest {
    // 内部でLiveDataを扱う場合に下記の実行ルールを設定する必要がある
    // Ruleは必ずpublicにする
    @get:Rule
    val rule = InstantTaskExecutorRule()

    // App をモック化する
    private val mockApp: App = mockk(relaxed = true)
    // モック化されたAppを注入してインスタンスを生成
    private val mockViewModel = MemoDetailViewModel(app = mockApp)

    /** 通常ケース */
    @Test
    fun `getNewExpireDateTime - normal case`() {
        val newExpiredTime = mockViewModel.getNewExpireDateTime(MEMO, DATE_WRAPPER_21000101_0000)
        assertThat(newExpiredTime).isEqualTo(TIME_21000101_0000)
    }

    /** 新しい破棄日が過去なために起き変わらないケース */
    @Test
    fun `getNewExpireDateTime - not replaced new expired time`() {
        val newExpiredTime = mockViewModel.getNewExpireDateTime(MEMO, DATE_WRAPPER_20000101_0000)
        assertThat(newExpiredTime).isEqualTo(TIME_20211209_1700)
    }

    companion object {
        private const val TIME_20000101_0000 = 946652400000L  // 2000/01/01 00:00
        private const val TIME_20211209_1700 = 1639036800000L  // 2021/12/09 17:00
        private const val TIME_21000101_0000 = 4102412400000L  // 2100/01/01 00:00

        private val DATETIME_20000101_0000 = Instant.ofEpochMilli(TIME_20000101_0000).atZone(ZoneId.systemDefault())
        private val DATETIME_21000101_0000 = Instant.ofEpochMilli(TIME_21000101_0000).atZone(ZoneId.systemDefault())

        private val MEMO = Memo(0, "", "", 0, 0, TIME_20211209_1700)

        private val DATE_WRAPPER_20000101_0000 = DatetimePicker.DateTimeWrapper(
            year = DATETIME_20000101_0000.year,
            month = DATETIME_20000101_0000.monthValue,
            dayOfMonth = DATETIME_20000101_0000.dayOfMonth,
            hour = DATETIME_20000101_0000.hour,
            minute = DATETIME_20000101_0000.minute
        )

        private val DATE_WRAPPER_21000101_0000 = DatetimePicker.DateTimeWrapper(
            year = DATETIME_21000101_0000.year,
            month = DATETIME_21000101_0000.monthValue,
            dayOfMonth = DATETIME_21000101_0000.dayOfMonth,
            hour = DATETIME_21000101_0000.hour,
            minute = DATETIME_21000101_0000.minute
        )
    }
}

Android Kotlin日本語チュートリアル

少しだけ複雑になりましたが、このようにContextを必要としているクラスのテストも通常の単体テストで書くことができました。

もう一度言いますが、もしテスト対象のメソッドの内部で、データベースアクセスなどAndroidフレームワークと密に関わる処理があれば、エラーが出ます。

そういったメソッドのテストは、この後紹介するandroid Test (Instrumentation Test) で実行する必要があります。

android Test

MemoRepositoryやMemoDaoなどのデータベースを実際に扱うクラスは、android Testで実行する必要があります

今回はMemoDaoについてテストを書いてみましょう。

必要なライブラリの依存関係は下記です。

dependencies {
    // ...

    androidTestImplementation "androidx.test.ext:junit:1.1.3"
    androidTestImplementation "androidx.test:core-ktx:1.4.0"
    androidTestImplementation "com.google.truth:truth:1.0.1"
    androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
}

android TestはandroidTestImplementationで別途依存関係を記述します。

MemoDaoTest

まずは src/androidTest/java/[package name]/ 下にMemoDaoTestを作成しましょう。

これも実装を見ながら解説した方が早いので、実装例を載せます。

以下はMemoDao.insertMemo() のテストをしているコードです。

テスト内容は、5つの仮MemoをデータベースにDAOで挿入し、それら全てフェッチした結果が元々のMemoの内容と一致するか、というものです。

import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import tech.araki.smartmemo.data.Memo
import tech.araki.smartmemo.model.MemoDao
import tech.araki.smartmemo.model.MemoDatabase

@RunWith(AndroidJUnit4::class)
class MemoDaoTest {
    private lateinit var dao: MemoDao
    private lateinit var db: MemoDatabase

    @Before  // テスト前に実行される
    fun setup() {
        // モックではないContextを取得
        val context = ApplicationProvider.getApplicationContext<Context>()
        // メモリ上でDBを作成 = 一時的な仮DB
        db = Room.inMemoryDatabaseBuilder(
            context,
            MemoDatabase::class.java
        ).build()
        dao = db.memoDao()
    }

    @Test
    fun insertMemoTest() {
        MEMO_ITEMS.forEach {
            dao.insertMemo(it)
        }

        val result = dao.fetchAll()

        assertThat(result.map { it.title }).isEqualTo(MEMO_TITLES)
    }

    @After  // テスト後に実行される
    fun tearDown() {
        db.close()
    }

    companion object {
        // 仮のメモタイトル
        private val MEMO_TITLES = List(5) { "Sample Memo $it" }
        // 仮のメモ
        private val MEMO_ITEMS = MEMO_TITLES.map { createMemo(it) }

        private fun createMemo(title: String) = Memo(
            id = 0,
            title = title,
            contents = "",
            createdTimeMillis = 0,
            updateTimeMillis = 0,
            expireTimeMillis = 0
        )
    }
}

普通のRoomDatabaseを構築すると、エミュレータ上で扱っている本物のデータベースと競合してしまうので、メモリ上で一時的なデータベースを利用しています。

android Testと呼ばれるものは、@RunWith(AndroidJUnit4::class)とあるようにAndroidJUnitで実行するものを一般に指しています。

今回は省略しますが、Viewの操作などのテストもandroid Testで可能です。

興味のある方は調べてみると良いでしょう。

おわりに

今回はAndroidアプリのテストについて解説しました。

本来であれば、各実装と同じタイミングでテストは書くべきです。

実際には、テストは後回しということもよくあることだとは思いますが、自身の書いたコードがレアケースに対応できるかどうかもテストを通せば簡単にシミュレーションできるので、軽視できません。

また、キレイな実装 (ソフトウェア開発)というのはテストの書きやすさに比例します

実装時ながら、如何にテストできるロジックを抽出できるか、そして如何にクラス間の依存を減らせるかというのはソフトウェア開発ではとても大事です。

参考