前置き
個人開発でAndroidアプリを作成中なのですが、それにあたって単体テストを記述する際にあれやこれやと苦労しました。
どなたかの参考になればなと思い、どのような設定やテストコードを書いたらテストが動くようになったかについて記述します。
(動くようになった!の記事である性質が強く、ViewModelの使い方・フォルダ分け・DI等いろいろ未熟なところがあるとは思いますので、適宜取捨選択して参考にしていただきたいです。。。(ご指摘や参考意見をいただけるのも嬉しいです))
以下のような条件のもとテストを書きました
- テスト対象メソッドがContextを使う
- ロケールで若干挙動が異なる(ファイル取得先のパスが異なる・関数の戻り値が異なる)のでロケールごとのテストを用意する
- assetsフォルダ内に格納したCSVファイルを読み取って処理する
- 他テストファイルでJUnit5を利用中(プロジェクト内でJUnit4,5が混在)
参考サイト
あとは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) } } }
補足
- ロケール設定は@Configアノテーションではうまくいかなかったので、以下サイトのコードをもとに記述しました。
Setting locale using `Config(qualifiers='zh')` doesn't seem to work. · Issue #3066 · robolectric/robolectric · GitHub - いろんなパターンを試したくてParameterizedTest形式で書ければベストだったのですが、JUnit4での実行だからそれは無理なのかなと思って、1パターンずつ関数を作って記述しています。