Kotlin Multiplatform (KMP)でのテストのすべて

本記事はKINTOテクノロジーズアドベントカレンダー2024の24日目の記事です🎅🎄
はじめに
こんにちは、Raselです。現在、KINTOテクノロジーズ株式会社でAndroidエンジニアとして働いています。今日は、Kotlin Multiplatform (KMP) でのテストへのアプローチについて簡単に紹介します。
KMPでのクロスプラットフォームテストにより、AndroidとiOS間で共有されるコードの信頼性を確保できます。シンプルなテストは素晴らしい開始点ですが、多くのアプリケーションでは、より複雑なセットアップを行って、ビジネスロジックの検証、非同期関数の処理、コンポーネント間の相互依存性のテストなどを行う必要があります。
この投稿では、非同期コードの処理、依存関係のテスト、パラメーター化されたテストの使用、複雑なセットアップの構築など、高度なテストシナリオについて説明します。詳細を掘り下げてみましょう!
高度なクロスプラットフォームテストの設定
共通のテスト依存関係を構成する
こちらは、アサーション、コルーチン、モック用のテストライブラリを使用した強化されたセットアップです。
// In shared module’s build.gradle.kts
plugins {
id("dev.mokkery") version "2.5.1" // Mocking library for multiplatform
}
sourceSets {
val androidInstrumentedTest by getting {
dependencies {
implementation(libs.core.ktx.test)
implementation(libs.androidx.test.junit)
implementation(libs.androidx.espresso.core)
implementation(libs.kotlin.test)
}
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.kotlin.test.annotations.common)
}
iosTest.dependencies {
implementation(libs.kotlin.test)
}
}
このセットアップにより、非同期テストや、テストに必要な依存関係のモック化を処理できるようになります。
KMPにおける基本的なテスト
KMPを使用すると、commonTest
で共有テストを簡単に記述できると同時に、androidTest
と iosTest
でプラットフォーム固有のテストが可能になります。開始方法の概要は次のとおりです。
class GrepTest {
companion object {
val sampleData = listOf(
"123 abc",
"abc 123",
"123 ABC",
"ABC 123",
)
}
@Test
fun shouldFindMatches() {
val results = mutableListOf<String>()
// Let's get the matching results with our global function grep which is defined in commonMain module
grep(sampleData, "[a-z]+") {
results.add(it)
}
// Check results with expectations
assertEquals(2, results.size)
for (result in results) {
assertContains(result, "abc")
}
}
}
commonTest
におけるこの簡単なテストを使用すると、プラットフォーム間で commonMain
モジュールの共有ロジックを検証できます。
高度なテストシナリオ
1.コルーチンを使用した非同期関数のテスト
多くの共有KMPプロジェクトでは、非同期作業にコルーチンを使用します。コルーチンを効果的にテストするには、テストでコルーチンの実行を制御および進行を制御できるツールであるkotlinx-coroutines-testを使用する必要があります。
サンプルシナリオは次のとおりです。ユーザーデータを非同期的に取得するUserRepositoryを持っているとします。データ検索をシミュレートするためにコルーチンフローを制御するテストを記述します。
@OptIn(ExperimentalCoroutinesApi::class)
class UserRepositoryTest {
private val testDispatcher = StandardTestDispatcher()
private val userRepository = UserRepository(testDispatcher)
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher) // Set test dispatcher as main
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain() // Reset main dispatcher
}
@Test
fun `fetch user successfully`() = runTest {
val user = userRepository.fetchUser("123")
assertEquals("John Doe", user.name)
}
}
この例では、
Dispatchers.setMain(testDispatcher)
がデフォルトのディスパッチャを置き換えることで、コルーチンの実行を制御できるようにします。runTest {}
は、コルーチンのセットアップとクリーンアップを自動的に処理し、サスペンド関数の管理を容易にします。runTest
内のフローを制御して、遅延やその他の非同期動作をシミュレートできます。
2.モックの使用と相互関係の検証
マルチプラットフォームセットアップで依存関係をモックすると、クラス間の複雑な相互作用のテストを効率化します。ここでは、APIクライアントをモックしてネットワーク呼び出しをシミュレートし、リポジトリとのやり取りを確認します。
class NewsRepositoryTest {
private val testDispatcher = StandardTestDispatcher()
private val apiService = mock<ApiService> {
everySuspend { getNews(defaultSectionName) } returns TopStoryResponse(mockNewsArticles)
}
private val repository = NewsRepository(apiService, testDispatcher)
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher) // Set test dispatcher as main
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain() // Reset main dispatcher
}
@Test
fun `fetch news list for home section successfully`() = runTest {
val articleList:List<Article> = repository.fetchNews()
assertEquals(mockNewsArticles.size, articleList.size)
assertEquals(mockNewsArticles.first(), articleList.first())
assertEquals(mockNewsArticles.last(), articleList.last())
verifySuspend {
apiService.getNews(defaultSectionName)
}
}
}
const val defaultSectionName = "home"
この例では、
mock<ApiService>()
を使用すると、明示的な動作定義がなくてもモックがデフォルト値を返すことができます。verifySuspend
は、getNews()
が適切に呼び出されたかどうかを確認し、リポジトリ内の正しいフローを確保します。everySuspend
は、getNews()
の呼び出しごとに戻されるモックデータを設定します。NewsRepository
のgetNews()
メソッドは、ApiService
からTopStoryResponse
としてデータを取得し、結果をList<Article>
として返します。
3.さまざまな入力シナリオに関するパラメーター化されたテスト
KMPは現在、JUnit5などのライブラリと同様に、パラメーター化されたテストにネイティブに対応していません。ただし、Kotlinの機能を使用してパラメーター化されたようなテスト動作を実現する回避策があり、さまざまな入力を使用して関数を簡潔かつ再利用可能な方法でテストできます。以下は、DateUtils関数のさまざまな日付フォーマットをテストする例です。
class DateUtilsTest {
private val testCases = listOf(
Triple("2024-11-18T00:00:00", "dd MMM yyyy", "18 Nov 2024"),
Triple("2024-11-18T15:34:00", "hh:mm", "03:34"),
Triple("2024-11-18T15:34:00", "dd MMM yyyy hh:mma", "18 Nov 2024 03:34PM"),
)
@Test
fun `check date format`() {
testCases.forEach { (dateString, outputFormat, expectedOutput) ->
val actualOutput = formatDatetime(dateString = dateString, inputFormat = "yyyy-MM-dd'T'HH:mm:ss", outputFormat = outputFormat)
assertEquals(expectedOutput, actualOutput, "Failed for date $dateString and output format $outputFormat")
}
}
}
[!注記] ここで、
formatDatetime()
は、日付文字列をフォーマットするためにDateUtils
内で定義されたグローバル関数です。この例では、
- 入力ごとに予想される結果を定義して、コードを重複させることなくテスト範囲を簡単に拡張できます。
- プラットフォーム固有のテストライブラリに依存せずに、
androidTest
とiosTest
の両方で動作します。
複雑なセットアップによるプラットフォーム固有のテスト
一部のテストケースでは、特にAndroidの SharedPreferences
またはiOSの NSUserDefaults
を使用する場合、プラットフォーム固有のAPIまたは動作を使用する必要があります。両方のプラットフォームの内訳は次のとおりです。
Android固有のテスト:SharedPreferencesのテスト
Android では、SharedPreferences
を使用してデータストレージをテストする必要がある場合があります。AndroidXの ApplicationProvider
を使用してテストコンテキストにアクセスするセットアップを次に示します。
@RunWith(AndroidJUnit4::class)
class SharedPreferencesTest {
private lateinit var sharedPreferences:SharedPreferences
private val expectedAppStartCount = 10
@BeforeTest
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
sharedPreferences = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
}
@AfterTest
fun tearDown() {
sharedPreferences.edit().clear().apply()
}
@Test
fun checkAppStartCountStoresCorrectly() {
sharedPreferences.edit().putInt(KEY_APP_START_COUNT, expectedAppStartCount).apply()
val actualAppStartCount = sharedPreferences.getInt(KEY_APP_START_COUNT, -1)
assertEquals(expectedAppStartCount, actualAppStartCount)
}
companion object {
private const val SHARED_PREF_NAME = "test_prefs"
private const val KEY_APP_START_COUNT = "app_start_count"
}
}
このプラットフォーム依存的なテストは、androidInstrumentedTest
内に配置する必要があり、実行するにはエミュレーターまたは実際のAndroidデバイスが必要になります。
iOS固有のテスト:NSUserDefaultsのテスト
iOSでは、同様のロジックを NSUserDefaults
でテストできます。通常、このテストは、以下に示すように iosTest
において記述します。
class NSUserDefaultsTest {
private lateinit var userDefaults:NSUserDefaults
private val expectedAppStartCount = 10
@BeforeTest
fun setUp() {
userDefaults = NSUserDefaults.standardUserDefaults()
}
@AfterTest
fun tearDown() {
userDefaults.removeObjectForKey(KEY_APP_START_COUNT) // Clean up after test
}
@Test
fun `test app start count stores correctly in NSUserDefaults`() {
userDefaults.setObject(expectedAppStartCount, forKey = KEY_APP_START_COUNT)
val actualAppStartCount = userDefaults.integerForKey(KEY_APP_START_COUNT)
assertEquals(expectedAppStartCount, actualAppStartCount.toInt())
}
companion object {
private const val KEY_APP_START_COUNT = "app_start_count"
}
}
複雑なテストセットアップ:相互依存関係のテスト
高度なテストでは、相互に作用する複数の依存関係をシミュレートする必要がある場合があります。以下は、API クライアントとローカルキャッシュの両方に依存するリポジトリが関与する例です。
class ComplexRepositoryTest {
private val apiService = mock<ApiService>()
private val localCache = mock<LocalCache>()
private val repository = ComplexRepository(apiService, localCache)
@Test
fun `fetch data from local cache`() = runTest {
everySuspend { localCache.getArticles() } returns mockNewsArticles
val data = repository.getNewsArticles()
assertEquals(mockNewsArticles.size, data.size)
verifySuspend {
localCache.getArticles()
}
}
@Test
fun `fetch data from remote source`() = runTest {
everySuspend { localCache.getArticles() } returns emptyList()
everySuspend { apiService.getNews(defaultSectionName) } returns TopStoryResponse(mockNewsArticles)
everySuspend { localCache.insertAllArticles(mockNewsArticles) } returns Unit
val data = repository.getNewsArticles()
assertEquals(mockNewsArticles.size, data.size)
verifySuspend {
localCache.getArticles()
apiService.getNews(defaultSectionName)
localCache.insertAllArticles(mockNewsArticles)
}
}
}
この例では、
- リポジトリは最初にキャッシュをチェックし、キャッシュが空の場合にのみAPIを呼び出します。
- 私たちは相互作用を検証することで、リポジトリが意図したフローに従い、不必要にAPIを呼び出さないようにしています。
上記のすべてにより、共通、Android、およびiOS実装のテストの記述が完了しました。これらのテストを個別に実行することも、allTests
という名前のGradleタスクを使用してすべてのテストを一度に実行することもできます。これにより、プロジェクト内のすべてのテストが、対応するテストランナーで実行されます。
テストを実行するたびに、shared/build/reports/
ディレクトリ内にHTMLレポートが生成されます。
このようにして、当社のKMPの取り組みを進めながら、適切に調整されたテストでアプリの品質を確保することができます。
複雑なKMPテストのベストプラクティス
- テストセットアップを分離する:ヘルパー関数やクラスを作成してモックの設定を行い、繰り返しコードを削減します。
- 非同期動作を制御する:kotlinx-coroutines-testを使用して、コルーチンのタイミングを制御し、遅延やネットワーク応答をシミュレートします。
- クロスプラットフォームテストとプラットフォーム固有のテストをミックスする:ネイティブ動作にはプラットフォーム固有のテストを使用し、共有ロジックにはクロスプラットフォームテストを使用します。
- テスト範囲をモニターする:特に、依存関係のある複雑なフローについては、包括的なカバレッジを確保します。
結論
Kotlin Multiplatformにおけるクロスプラットフォームテストでは、単純なテストシナリオと複雑なテストシナリオを処理でき、AndroidとiOS全体でコードの信頼性を確保するための強力なツールが得られます。再利用可能なテストをセットアップし、非同期動作を制御し、依存関係を分離することで、重要なビジネスロジックを検証して全体的なコード品質を向上させる高度なテストを記述することができます。
これらの手法により、KMPプロジェクトはプラットフォーム間で一貫性のある信頼性の高いパフォーマンスを発揮できるようになり、あらゆる場所のユーザーにシームレスな体験を提供できるようになります。KMPを楽しんでください!
関連記事 | Related Posts

Kotlin Multiplatform (KMP)でのテストのすべて

Kotlin Multiplatform Hybrid Mode: Compose Multiplatform Meets SwiftUI and Jetpack Compose

Applying KMP to an Existing App: Our Team’s Experience and Achievements

Mobile App Development Using Kotlin Multiplatform Mobile (KMM)

既存のアプリへのKMPの適用:チームの経験と成果

SwiftUI in Compose Multiplatform of KMP
We are hiring!
【QAエンジニア】QAG/東京・大阪・福岡
QAグループについて QAグループでは、自社サービスである『KINTO』サービスサイトをはじめ、提供する各種サービスにおいて、リリース前の品質保証、およびサービス品質の向上に向けたQA業務を行なっております。QAグループはまだ成⾧途中の組織ですが、テスト管理ツールの導入や自動化の一部導入など、QAプロセスの最適化に向けて、積極的な取り組みを行っています。
【ソフトウェアエンジニア(リーダークラス)】共通サービス開発G/東京・大阪
共通サービス開発グループについてWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームなどの企画・開発を手がけるグループです。KINTOの名前が付くサービスやKINTOに関わりのあるサービスを同一のユーザーアカウントに対して提供し、より良いユーザー体験を実現できるよう、様々な共通機能や顧客基盤を構築していくことを目的としています。