Android開発で単体テストを書いた(キーワード:Context, assets, Locale)

前置き

個人開発でAndroidアプリを作成中なのですが、それにあたって単体テストを記述する際にあれやこれやと苦労しました。
どなたかの参考になればなと思い、どのような設定やテストコードを書いたらテストが動くようになったかについて記述します。
(動くようになった!の記事である性質が強く、ViewModelの使い方・フォルダ分け・DI等いろいろ未熟なところがあるとは思いますので、適宜取捨選択して参考にしていただきたいです。。。(ご指摘や参考意見をいただけるのも嬉しいです))

以下のような条件のもとテストを書きました

  • テスト対象メソッドがContextを使う
  • ロケールで若干挙動が異なる(ファイル取得先のパスが異なる・関数の戻り値が異なる)のでロケールごとのテストを用意する
  • assetsフォルダ内に格納したCSVファイルを読み取って処理する
  • 他テストファイルでJUnit5を利用中(プロジェクト内でJUnit4,5が混在)

参考サイト

qiita.com

robolectric.org

github.com

jp-seemore.com

knowledge.moshimore.jp

あとはChatGPTにもお世話になりました。

実際のコード

build.gradle.kts (:app)

plugins {
    // 略
    id("de.mannodermaus.android-junit5") version "1.10.0.0"
}

android {
    // 略
    testOptions {
        unitTests.isIncludeAndroidResources = true   
    }
}

dependencies {
    // 略

    implementation("androidx.test:core-ktx:1.5.0")
    implementation("androidx.test.ext:junit-ktx:1.1.5")

    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")

    // For JUnit5
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.1")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.1")
    testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.1")
    testImplementation("org.junit.vintage:junit-vintage-engine:5.10.1")

    // For Mock
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.robolectric:robolectric:4.11.1")

    // 他にも略
}

補足

  • unitTests.isIncludeAndroidResourcesについて
    テスト時にも/src/main/assetsフォルダの中にアクセスしたい際には必要かと思います。
    (これを記述しないと、テスト時にリソース系にアクセスしないorできないのかなと思います。/src/main/assetsフォルダにアクセスせずFileNotFoundの例外が発生しました。)
  • dependenciesについて
    精査しきれておらず恐縮なのですが、テストを書いていくにあたって最終的にこの辺りが必要になっていました。(量が多いので自分の判断で一部省略しています。過不足あったらすみません)

テスト対象のクラス・メソッド

メソッド概要

  • LocalDate型の引数に対して、その日が祝日かどうかを判定します。
  • メソッド内では、assetsフォルダ内に保存した祝日一覧(スマホの設定ロケールに応じた国のもの)のCSVファイルを参照し、引数の日付と一致するならtrueを返却するというロジックです。
  • 独自クラスも作成しているのでそれは後に続けてコードを貼ります。

コード

class CalendarViewModel : ViewModel() {

    fun isHoliday(context: Context, targetDate: LocalDate): Boolean {
        val targetCountry = Country.valueOf(Locale.current)   // Countryは独自クラスです(コードは後述)
        val targetDateString =
            targetDate.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"))

        try {
            val inputStream = context.assets.open(targetCountry.getAssetsDirectory(targetDate))
            inputStream.bufferedReader().use { reader ->
                var line = reader.readLine()

                while (line != null) {
                    val holidayDateString = line.split(",").first()
                    if (holidayDateString == targetDateString) {
                        return true
                    }
                    line = reader.readLine()
                }
            }
        } catch (e: Exception) {
            // TODO とりあえず起動を優先
            println(e)
        }

        return false
    }
}

テスト対象のメソッドで使っている独自クラス

Countryクラス

import androidx.compose.ui.text.intl.Locale
import java.time.LocalDate

enum class Country {
    JA,
    AU,
    US;

    companion object {
        fun valueOf(locale: Locale): Country {
            return when (locale.region) {
                "JP" -> JA
                "AU" -> AU
                else -> US
            }
        }
    }

    fun getAssetsDirectory(date: LocalDate): String {
        val prefix = when (this) {
            JA -> "ja"
            AU -> "au"
            else -> "us"
        }
        return "holidays/${date.year}/holidays-${prefix}.csv"
    }
}

テスト対象のメソッドの呼び出しもと

    val isHoliday = CalendarViewModel().isHoliday(LocalContext.current, LocalDate.of(2024,1,1))

補足

isHolidayメソッドの第二引数は、ここでは説明の都合上2024/1/1でべた書きにしています。

assetsフォルダ

フォルダ構造

CSVファイルの中身は以下の感じです。処理上は日付だけあれば良いのですが、管理の都合上祝日名も入れています。

2024/01/01,New Year's Day
2024/01/08,Coming of Age Day

テストコード

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Before
import org.junit.Test
import org.junit.experimental.runners.Enclosed
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.time.LocalDate
import java.util.Locale

@RunWith(Enclosed::class)
class CalendarViewModelTest {

    @RunWith(RobolectricTestRunner::class)
    class IsHolidayTestForJapan {

        private lateinit var context: Context

        @Before
        fun setUp() {
            context = ApplicationProvider.getApplicationContext()
            Locale.setDefault(Locale.JAPAN)
        }

        @Test
        fun australiaDay() {
            val actual = CalendarViewModel().isHoliday(context, LocalDate.of(2025, 1, 27))
            assertFalse(actual)
        }
    }

    @RunWith(RobolectricTestRunner::class)
    class IsHolidayTestForAustralia {

        private lateinit var context: Context

        @Before
        fun setUp() {
            context = ApplicationProvider.getApplicationContext()
            Locale.setDefault(Locale("en", "AU"))
        }

        @Test
        fun australiaDay() {
            val actual = CalendarViewModel().isHoliday(context, LocalDate.of(2024, 1, 26))
            assertTrue(actual)
        }
    }
}

補足

おわり