👀

🧪 Android Glance の単体テストの備忘録

に公開

Glance とは

Android のホーム画面に表示する Widget を Jetpack compose で作成できるライブラリ。
https://developer.android.com/develop/ui/compose/glance/create-app-widget?hl=ja

Glance 1.1.0 で安定版リリースされて、Google I/O 2025 でも触れられていたので、今後も開発が進められそう。
↓Google I/O 2025 の Widget の部分。
https://youtu.be/IaNpcrCSDiI?t=2233

Glance 単体テスト

以前は UiState の単体テストを記述することでなんとかデグレチェックを担保していたが、どのような表示になっているかまでは難しかった。
Glance のテストツールを使うことで Jetpack compose の単体テストのように UI Automator を使用せずに記述できる。

https://developer.android.com/develop/ui/compose/glance/testing?hl=ja

環境

  • Android Studio Android Studio Meerkat | 2024.3.1 Patch 2
  • Kotlin 2.1.21
  • Glance 1.1.1
  • Robolectric 4.14.1

設定

公式サイトでは下記のような記述で設定できると書かれている。
今回は LocalContext が必要で Robolectric も使用するので、差分を追加。

 android {
     ...
+    testOptions {
+        unitTests {
+            // For Robolectric
+            isIncludeAndroidResources = true
+        }
+    }
 }

 dependencies {
     // Other Glance and Compose runtime dependencies.
     ...
     testImplementation("androidx.glance:glance-testing:1.1.1")
     testImplementation("androidx.glance:glance-appwidget-testing:1.1.1")
     testImplementation("org.robolectric:robolectric:4.14.1")
     ...
     // You may include additional dependencies, such as Robolectric, if your test
     // needs to set a LocalContext.

+    testImplementation("androidx.test.ext:junit-ktx:1.2.1")
  }

テストコード

import androidx.test.core.app.ApplicationProvider
...

@RunWith(RobolectricTestRunner::class)
class WidgetTest {
    private val context: Context = ApplicationProvider.getApplicationContext() // ①

    @Test
    fun itemEmpty_shouldShowLoadingIndicator() = runGlanceAppWidgetUnitTest {
        provideComposable {
            CompositionLocalProvider(
                // NOTE: Scaffold の内部で LocalContext が使用されているので必要 ②
                LocalContext provides context,
            ) {
                WidgetComposable(
                    items = emptyList(),
                    onItemClick = {},
                )
            }
        }

        onNode(hasTestTag("loading_indicator")) // ③
            .assertExists()
    }
}

context 取得

androidx.test.ext:junit-ktxandroidx.test.core.app.ApplicationProvider をインポートしてそこから context を取得。
context を使用するので @RunWith(RobolectricTestRunner::class)@RunWith(AndroidJUnit4::class) などを忘れずに設定。

LocalContext を上書き

CompositionLocalProvider を使って①で取得した context に上書き。
これがないと LocalContext を使用した Scaffold などのコンポーネントなどがあると下記のようなエラーになる。

java.lang.IllegalStateException: No default context

余談:LocalContext を使用していない Scaffold はいつリリースされるのか

main ブランチの ScaffoldLocalContext を使用していないけど、Glance 1.1.1 の Scaffold はまだ LocalContext を使用している。使用していない版はいつリリースされるのだろうか。

3163676: [glance] Fix resource check for corner radius | https://android-review.googlesource.com/c/platform/frameworks/support/+/3163676
Submitted Jul 10, 2024

③ Glance のテストタグ

通常の Compose のテストタグと少し違う。

1. テストタグの設定方法

GlanceModifier には通常の Compose にある Modifier#testTag がないので sementics で設定する。

// 通常の Compose
Modifier.testTag(tag = "loading_indicator"),

// Glance
GlanceModifier.semantics { testTag = "loading_indicator" }

2. ルートのコンポーネントで testTagsAsResourceId が不要

当然と言えば当然なんだが、UI Automator を使用しているわけではないので、不要。
むしろ、設定するプロパティがないので、UI Automator を使用してテストすることができない?

Modifier.semantics { testTagsAsResourceId = true }

Jetpack Compose × UiAutomator の相互運用で詰まった時の備忘録はこっち。

https://zenn.dev/u_chan/articles/f1a35391ec3a2f

文字列の取得

これは Glance に限らず、context から取得することもできる。

@Test
fun isErrorTrue_shouldShowErrorMessage() = runGlanceAppWidgetUnitTest {
    val errorMessage = context.getString(R.string.widget_error)

    provideComposable {
        CompositionLocalProvider(
            // NOTE: Scaffold の内部で LocalContext が使用されているので必要
            LocalContext provides context,
        ) {
            WidgetComposable(
                items = emptyList(),
                isError = true,
                onItemClick = {},
            )
        }
    }

    onNode(hasText(errorMessage))
            .assertExists()
}

余談

文字列取得は通常の Compose のように下記のような util があると便利

@Composable
@ReadOnlyComposable
fun stringResource(@StringRes id: Int): String {
    val resources = LocalContext.current.resources
    return resources.getString(id)
}

@Composable
@ReadOnlyComposable
fun stringResource(@StringRes id: Int, vararg formatArgs: Any): String {
    val resources = LocalContext.current.resources
    return resources.getString(id, *formatArgs)
}

参考
StringResources.android.kt
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/res/StringResources.android.kt;l=25-50;drc=1db67f157c12be1d4ca7863bd6933caed342d7bb;bpv=0;bpt=0

Discussion