Slide 1

Slide 1 text

テストコードを書きながら Compose Multiplatformを乗りこなす 2024/05/13 Jetpack Composeの課題〜モバイルアプリの品質改善を考える〜 坂上 晴信(@subroh_0508)

Slide 2

Slide 2 text

2 プロダクト本部 開発部 DevHR 坂上 晴信 Harunobu Sakaue 【¥431,893】にしこりさぶろ〜 @subroh_0508 🎤経歴 1995年生まれ。東京の離島・伊豆大島出身。 メインの技術スタックはKotlin・Android・Rails・React。 東京高専情報工学科を卒業後、2016年4月に株式会社 TOKIUMに新卒入社。6年半に渡りAndroid・Webの領域で プレイングマネージャーとして経験を積み、2023年1月から DevHRとしてエンジニア採用・組織づくりにフルコミットで 携わる。 リアルのすがた インターネットのすがた

Slide 3

Slide 3 text

TOKIUMの志 未 来 へ つ な が る 時 を 生 む TOKIUMは、より良い世界を志す人の「未来へつながる時」を生むために存在します。 それは、誰かのために、調べ、考え、挑戦するための時間です。 TOKIUMは、時間のインフラでありたい。 最適なテクノロジーと、常識にとらわれない自由な発想と、泥臭さもいとわない行動力で、 人と事業を未来へ向けてもっと加速させていきたいのです。 自己紹介 / 会社紹介 3 Android/Webエンジニアとして約7年 人事職にロールチェンジしてから、現在2年目

Slide 4

Slide 4 text

話すこと 4 ✓ Compose MultiplatformのUIテストについて、実行環境の設定方法を共有 ➔ Android (for Instrumented Test)・iOS・Desktopの各ビルドターゲット用に Composable関数の挙動を検証するテストコードを実行する方法の紹介 ➔ Android用のCompose Multiplatformのテストコードについて ローカルJVM上で実行する方法の紹介 複数ターゲットに向けたビルドが前提のCompose Multiplatformにおいて テストコードは品質の維持・向上に必要不可欠👊 今回共有するナレッジによって、Compose Multiplatformを使って Production Readyなアプリの開発に挑戦する開発者を少しでも増やせれば😊

Slide 5

Slide 5 text

前提知識 5 🤔 Compose Multiplatformとは? ➔ Jetpack ComposeのKotlin Multiplatform対応版 ➔ iOS・Desktop・WebアプリのUIをJetpack Composeと(ほぼ)同じAPIで 宣言的に実装することができるフレームワーク 公式サイト: Compose Multiplatform UI フレームワーク | JetBrains Desktop向けは既に安定版に到達! iOS向けも、実はα版までリリースされている!

Slide 6

Slide 6 text

前提知識 6 🤔 Compose Multiplatform、実際どこまでできるの? ➔ 想像以上になんでもできます!👍 現在、本番リリースに向けて開発中の Mastodonクライアントアプリ (左: iOS / 右: Android) 💪実装済の機能 - OAuth認証によるログイン - 複数アカウントの認証情報保持・切り替え - トゥートの閲覧・投稿 - ストリーミングの購読 (→タイムラインの自動更新) - 画像・動画データのプレビュー - ファイルピッカーによる メディアファイルの選択・プレビュー 🔧内部実装 - Dependency Injection導入済み - ドメイン・UI層双方に単体テスト導入済み こんなにできるなら自分もやってみたい!

Slide 7

Slide 7 text

前提知識 7 😇 Android以外のビルドターゲットに関する情報が少ない ➔ 触っている人が少ないため、情報も少なくなりがち ➔ 特にiOSへの対応時、慣れていないiOS・Swift・Obj-Cの知識が要求される 😇 動作検証が大変 ➔ シンプルに動作検証対象が多く、見た目とロジックの確認に労力がかかる ➔ @Preview アノテーションの使用に制限がある ※ commonMain 以下に実装されたComposable関数の @Preview は、JetBrains Fleetでしか動作しない(v1.6.2時点) ただ、現状はまだまだエッジの効いた技術 使いこなす上でのつらみも複数存在🫠

Slide 8

Slide 8 text

前提知識 8 😇 Android以外のビルドターゲットに関する情報が少ない ➔ 触っている人が少ないため、情報も少なくなりがち ➔ 特にiOSへの対応時、慣れていないiOS・Swift・Obj-Cの知識が要求される 😇 動作検証が大変 ➔ シンプルに動作検証対象が多く、見た目とロジックの確認に労力がかかる ➔ @Preview アノテーションの使用に制限がある ※ commonMain 以下に実装されたComposable関数の @Preview は、JetBrains Fleetでしか動作しない(v1.6.2時点) ただ、現状はまだまだエッジの効いた技術 使いこなす上でのつらみも複数存在🫠 アプリ実装の実績をたくさん 積み重ねて、外部に伝える💪 ※後日話す UIテストの実行環境を整え、一度テストコード化した項目は 自動で検証できるようにする💪※今日話す

Slide 9

Slide 9 text

Compose MultiplatformのUIテスト / 概要 9 👀 テストコードの外観 class ExampleTest { @OptIn(ExperimentalTestApi::class) @Test fun myTest() = runComposeUiTest { setContent { var text by remember { mutableStateOf("Hello") } Text( text = text, modifier = Modifier.testTag("text") ) Button( onClick = { text = "Compose" }, modifier = Modifier.testTag("button") ) { Text("Click me") } } onNodeWithTag("text").assertTextEquals("Hello") onNodeWithTag("button").performClick() onNodeWithTag("text").assertTextEquals("Compose") } } 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation

Slide 10

Slide 10 text

Compose MultiplatformのUIテスト / 概要 10 👀 テストコードの外観 class ExampleTest { @OptIn(ExperimentalTestApi::class) @Test fun myTest() = runComposeUiTest { setContent { var text by remember { mutableStateOf("Hello") } Text( text = text, modifier = Modifier.testTag("text") ) Button( onClick = { text = "Compose" }, modifier = Modifier.testTag("button") ) { Text("Click me") } } onNodeWithTag("text").assertTextEquals("Hello") onNodeWithTag("button").performClick() onNodeWithTag("text").assertTextEquals("Compose") } } テスト対象のUI アサーションの実行 ボタンクリックによって テキストが変化するUIの検証 Finder・アサーション アクション・マッチャー 全てJetpack Composeと同じAPIで 利用可能!激アツ!🔥 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation

Slide 11

Slide 11 text

Compose MultiplatformのUIテスト / 概要 11 🏃 テストコードを各ターゲットごとに実行してみる ➔ Android: ./gradlew :connectedAndroidTest a ➔ iOS: ./gradlew :iosSimulatorArm64Test a ➔ ➔ Desktop: ./gradlew :desktopTest a 単一のテストクラスで 3つのターゲットを対象とした 動作検証ができる!激アツ!🔥

Slide 12

Slide 12 text

Compose MultiplatformのUIテスト / 環境設定 12 🐘 UIテストの実行環境を整える(1/3) ➔ テストコードを ${moduleName}/src/commonTest/kotlin 以下に作成 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation

Slide 13

Slide 13 text

Compose MultiplatformのUIテスト / 環境設定 13 🐘 UIテストの実行環境を整える(2/3) ➔ Gradleファイルを編集し、依存関係を追加 kotlin { //... sourceSets { val desktopTest by getting commonTest.dependencies { implementation(kotlin("test")) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.uiTest) } desktopTest.dependencies { implementation(compose.desktop.uiTestJUnit4) implementation(compose.desktop.currentOs) } } } tasks.named("desktopTest") { useJUnitPlatform() } ${moduleName}/build.gradle.kts 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation

Slide 14

Slide 14 text

Compose MultiplatformのUIテスト / 環境設定 14 🐘 UIテストの実行環境を整える(2/3) ➔ Gradleファイルを編集し、依存関係を追加 kotlin { //... sourceSets { val desktopTest by getting commonTest.dependencies { implementation(kotlin("test")) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.uiTest) } desktopTest.dependencies { implementation(compose.desktop.uiTestJUnit4) implementation(compose.desktop.currentOs) } } } tasks.named("desktopTest") { useJUnitPlatform() } ${moduleName}/build.gradle.kts 全ターゲット共通の依存関係 Desktop向けに必要な依存関係・設定 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation

Slide 15

Slide 15 text

Compose MultiplatformのUIテスト / 環境設定 15 🐘 UIテストの実行環境を整える(3/3) ➔ Gradleファイルを編集し、Android向けの設定を追加 kotlin { //... androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) instrumentedTestVariant { sourceSetTree.set(KotlinSourceSetTree.test) dependencies { implementation("androidx.compose.ui:ui-test-junit4-android:1.5.4") debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4") } } } } android { defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } ${moduleName}/build.gradle.kts 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation

Slide 16

Slide 16 text

Compose MultiplatformのUIテスト / 環境設定 16 🐘 UIテストの実行環境を整える(3/3) ➔ Gradleファイルを編集し、Android向けの設定を追加 kotlin { //... androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) instrumentedTestVariant { sourceSetTree.set(KotlinSourceSetTree.test) dependencies { implementation("androidx.compose.ui:ui-test-junit4-android:1.5.4") debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4") } } } } android { defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } ${moduleName}/build.gradle.kts Android向けに必要な依存関係・設定 Instrumented Testの実行に必要な設定 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation

Slide 17

Slide 17 text

Compose MultiplatformのUIテスト / 環境設定 17 🐘 UIテストの実行環境を整える(3/3) ➔ Gradleファイルを編集し、Android向けの設定を追加 kotlin { //... androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) instrumentedTestVariant { sourceSetTree.set(KotlinSourceSetTree.test) dependencies { implementation("androidx.compose.ui:ui-test-junit4-android:1.5.4") debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4") } } } } android { defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } ${moduleName}/build.gradle.kts Android向けに必要な依存関係・設定 Instrumented Testの実行に必要な設定 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation ここまでできたらGradleタスクを実行するだけ!👍 環境設定の仕方は、公式のドキュメントも用意されてるぞ😎 ※この資料では一部の設定を改変しています

Slide 18

Slide 18 text

Compose MultiplatformのUIテスト / 環境設定 18 🐘 UIテストの実行環境を整える(3/3) ➔ Gradleファイルを編集し、Android向けの設定を追加 kotlin { //... androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) instrumentedTestVariant { sourceSetTree.set(KotlinSourceSetTree.test) dependencies { implementation("androidx.compose.ui:ui-test-junit4-android:1.5.4") debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4") } } } } android { defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } ${moduleName}/build.gradle.kts Android向けに必要な依存関係・設定 Instrumented Testの実行に必要な設定 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation Compose MultiplatformのUIテストが実行できるようになった!🎉 これにて一件落着、Have a nice Kotlin!👋 ちょっと待って…!1つ気になるところが…!

Slide 19

Slide 19 text

Compose MultiplatformのUIテスト / 環境設定 19 🐘 UIテストの実行環境を整える(3/3) ➔ Gradleファイルを編集し、Android向けの設定を追加 kotlin { //... androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) instrumentedTestVariant { sourceSetTree.set(KotlinSourceSetTree.test) dependencies { implementation("androidx.compose.ui:ui-test-junit4-android:1.5.4") debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4") } } } } android { defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } ${moduleName}/build.gradle.kts Android向けに必要な依存関係・設定 Instrumented Testの実行に必要な設定 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation Compose MultiplatformのUIテストが実行できるようになった!🎉 これにて一件落着、Have a nice Kotlin!👋 どうしてAndroid向けのテストだけ、実機実行なんですか?

Slide 20

Slide 20 text

Compose MultiplatformのUIテスト / 実行上の制約 20 😭 Android向けのテストは、実機での実行しかできない! ➔ 公式ドキュメント曰く、Android向けのテストはInstrumented Testのみ対応 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation

Slide 21

Slide 21 text

Compose MultiplatformのUIテスト / 実行上の制約 21 😭 Android向けのテストは、実機での実行しかできない! ➔ 試しに ./gradlew :testDebugUnitTest を実行し、 ローカルJVM上でUIテストを実行してみると… 「 "android.os.Build.FINGERPRINT" がnullになってしまう」と怒られる😢 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation

Slide 22

Slide 22 text

Compose MultiplatformのUIテスト / 実行上の制約 22 🤔 テストの実機実行のメリット・デメリット ➔ 🙆実際のアプリの実行環境に、より忠実な環境下でテストが実行される ➔ 🙅1回のテスト実行にかかるコストが重い GitHub Actions等のCI/CD環境では、1回の実行でリソースを大量に消費してしまう すなわち、「commitの度に単体テストを実行する」ことが難しくなる あと、CI/CD環境でエミュレーター立ち上げる設定書くのもめんどくさい😇 何としてでも、Android向けのテストを ローカルJVM上で動かしたい…!

Slide 23

Slide 23 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 23 👊 Android向けテストを(なんとかして)ローカルJVM上で動かす ➔ 方針: Android向けテストを実行する時のみ、 Test RunnerをJUnit4 + Robolectricに差し替えられれば上手くいく(はず) なお、Compose MultiplatformのUIテストは JUnit5上で実行されている模様👀 ※Android向けの @Test アノテーションが JUnit5のアノテーションを参照しているため

Slide 24

Slide 24 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 24 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(1/4) ➔ 必要な依存関係を追加 sourceSets { androidMain.dependencies { implementation(libs.androidx.test.junit) implementation(libs.androidx.activity.compose) } desktopMain.dependencies { implementation(kotlin("test-junit5")) implementation(compose.desktop.currentOs) } androidUnitTest.dependencies { implementation(libs.androidx.compose.ui.test.junit4) runtimeOnly(libs.junit.core) runtimeOnly(libs.junit.vintage) runtimeOnly(libs.robolectric) } } ${moduleName}/build.gradle.kts

Slide 25

Slide 25 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 25 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(1/4) ➔ 必要な依存関係を追加 sourceSets { androidMain.dependencies { implementation(libs.androidx.test.junit) implementation(libs.androidx.activity.compose) } desktopMain.dependencies { implementation(kotlin("test-junit5")) implementation(compose.desktop.currentOs) } androidUnitTest.dependencies { implementation(libs.androidx.compose.ui.test.junit4) runtimeOnly(libs.junit.core) runtimeOnly(libs.junit.vintage) runtimeOnly(libs.robolectric) } } ${moduleName}/build.gradle.kts この後のアノテーション定義に必要

Slide 26

Slide 26 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 26 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(1/4) ➔ 必要な依存関係を追加 sourceSets { androidMain.dependencies { implementation(libs.androidx.test.junit) implementation(libs.androidx.activity.compose) } desktopMain.dependencies { implementation(kotlin("test-junit5")) implementation(compose.desktop.currentOs) } androidUnitTest.dependencies { implementation(libs.androidx.compose.ui.test.junit4) runtimeOnly(libs.junit.core) runtimeOnly(libs.junit.vintage) runtimeOnly(libs.robolectric) } } ${moduleName}/build.gradle.kts Android向けテストの Test Runnerの差し替え + 実行に利用

Slide 27

Slide 27 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 27 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(2/4) ➔ 必要なアノテーションを定義する expect abstract class Runner expect class UiTestRunner : Runner expect annotation class RunWith(val value: KClass) expect annotation class ComposeTest() commonMain/Annotations.kt

Slide 28

Slide 28 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 28 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(2/4) ➔ 必要なアノテーションを定義する expect abstract class Runner expect class UiTestRunner : Runner expect annotation class RunWith(val value: KClass) expect annotation class ComposeTest() commonMain/Annotations.kt Test Runnerの指定に利用するアノテーション (と付随して必要になるクラス) kotlin.testの @Test アノテーションの 代わりに利用するアノテーション

Slide 29

Slide 29 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 29 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(2/4) ➔ 必要なアノテーションを定義する actual typealias Runner = org.junit.runner.Runner actual typealias UiTestRunner = androidx.test.ext.junit.runners.AndroidJUnit4 actual typealias RunWith = org.junit.runner.RunWith actual typealias ComposeTest = org.junit.Test androidMain/Annotations.android.kt

Slide 30

Slide 30 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 30 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(2/4) ➔ 必要なアノテーションを定義する actual typealias Runner = org.junit.runner.Runner actual typealias UiTestRunner = androidx.test.ext.junit.runners.AndroidJUnit4 actual typealias RunWith = org.junit.runner.RunWith actual typealias ComposeTest = org.junit.Test AndroidJUnit4 を参照するように定義 JUnit4の @Test アノテーションを参照するように定義 androidMain/Annotations.android.kt

Slide 31

Slide 31 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 31 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(2/4) ➔ 必要なアノテーションを定義する actual abstract class Runner actual class UiTestRunner : Runner() actual annotation class RunWith(actual val value: KClass) actual typealias ComposeTest = kotlin.test.Test iosMain/Annotations.ios.kt actual abstract class Runner actual class UiTestRunner : Runner() actual annotation class RunWith(actual val value: KClass) actual typealias ComposeTest = org.junit.jupiter.api.Test desktopMain/Annotations.jvm.kt iOS・Desktopは、以前の通りに動けばOK 空のクラス定義 + kotlin.testの定義への参照

Slide 32

Slide 32 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 32 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(3/4) ➔ テストコードを修正 @RunWith(UiTestRunner::class) class ExampleTest { @OptIn(ExperimentalTestApi::class) @ComposeTest fun myTest() = runComposeUiTest { // ... } } commonMain/ExampleTest.kt

Slide 33

Slide 33 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 33 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(3/4) ➔ テストコードを修正 @RunWith(UiTestRunner::class) class ExampleTest { @OptIn(ExperimentalTestApi::class) @ComposeTest fun myTest() = runComposeUiTest { // ... } } commonMain/ExampleTest.kt 追加 @Test → @ComposeTest に修正

Slide 34

Slide 34 text

Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす 34 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(4/4) ➔ Android(Local): ./gradlew :testDebugUnitTest a ➔ iOS: ./gradlew :iosSimulatorArm64Test a ➔ ➔ Desktop: ./gradlew :desktopTest a ローカルJVM上でのテストが 通るように!🎉 他ターゲットのテストも動く!

Slide 35

Slide 35 text

35 🐥 Android向けテストを(なんとかして)ローカルJVM上で動かす(4/4) ➔ Android(Local): ./gradlew :testDebugUnitTest a ➔ Android(Instrumented): ./gradlew :connectedAndroidTest aa 因みに、実機実行のテストも ちゃんと動作します😉 Compose MultiplatformのUIテスト / Android向けテストをローカルJVM上で動かす

Slide 36

Slide 36 text

まとめ 36 👍 Compose Multiplatformでは、単一のテストコードで複数のターゲットに対し UIテストを実行することができる ➔ 動作検証が煩雑になりやすいX-Plat開発にとって、非常に強力といえる 👍 各ターゲット単体でできることは、Compose Multiplatformを使っていても 大体実現できる ➔ expect / actual 修飾子を上手く扱いながら、各ターゲットの実行環境を 再現する方向性で試行錯誤すると◎ 今回話した内容が網羅されたレポジトリはコチラ → subroh0508/compose-uitest-sample Compose Multiplatformを使ったアプリの開発に、みなさんもチャレンジ!😉

Slide 37

Slide 37 text

宣伝 37 Kotlin Fest 2024で登壇します✌ 開催日: 2024/06/22(Sat) 場所: ベルサール渋谷ファースト 05/17までチケットが安い! ¥9,000 → ¥5,000 今日話せなかったことを たくさん話します😎 エンジニア積極採用中! Rails / React / AWS Kotlin / Swift 3月に採用サイトを リニューアル! 見に来てもらえると🙏 Thank you for listening!