Jetpack Compose + Robolectric でテストを書いていたところ、ある日突然 AppNotIdleException が発生するようになりました。
最初は Compose のレイアウト構成が原因だと思っていたのですが、検証を進めるうちに原因がまったく別のところにあることが分かってきました。
この記事では、原因を切り分けていった過程と、現時点で分かっていることをまとめます。
発生した例外
テストは単体では成功するものの、他のテストと一緒に実行すると次の例外が発生しました。
androidx.test.espresso.AppNotIdleException:
Compose did not get idle after 60 SECONDS.
例えば、
./gradlew testDebugUnitTest --tests "*.PlainComposeTest"
では成功しますが、
./gradlew testDebugUnitTest
では失敗します。
つまり、
- 単体実行 → PASS
- 全体実行 → FAIL
という状況でした。
最初に疑ったこと
最初は Compose のレイアウト構造が原因だと思いました。
例えば次のようなコードです。
@Composable
fun Screen(visible: Boolean) {
if (!visible) {
return
}
Text("Hello")
}
また、
@Composable
fun Screen() {
Text("Hello")
}
では失敗するのに、
@Composable
fun Screen() {
Scaffold { padding ->
Box(
modifier = Modifier.padding(padding),
) {
Text("Hello")
}
}
}
では成功するケースもありました。
このため、
Scaffold-
ifによる早期return
あたりを疑っていました。
最小再現プロジェクトを作成
原因を切り分けるため、検証専用のリポジトリを作成しました。
目的は
- 余計なライブラリを排除する
- 一つずつ条件を追加する
- 再現条件を最小化する
ことです。
切り分け
Room の書き込み
Room の INSERT だけを実行するテスト。
結果
✅ 再現しない
Room の読み込み
Room の SELECT のみ実行。
結果
✅ 再現しない
ViewModel
ViewModel を生成するだけ。
結果
✅ 再現しない
ViewModel + Room
Room のデータを ViewModel から取得。
結果
✅ 再現しない
mutableStateOf
ViewModel 内で
var uiState by mutableStateOf(...)
を更新する。
結果
❌ AppNotIdleException
StateFlow
同じコードを
private val _uiState = MutableStateFlow(...)
val uiState = _uiState.asStateFlow()
へ変更。
結果
✅ 再現しない
ViewModel を使わない場合
さらに ViewModel すら使わず、
class MutableStateHolder {
var state by mutableStateOf(0)
fun update() {
state = 1
}
}
だけを実行。
その後 Compose UI Test を実行すると、
❌ AppNotIdleException
一方、
class StateFlowHolder {
private val _state = MutableStateFlow(0)
val state = _state.asStateFlow()
fun update() {
_state.value = 1
}
}
では
✅ PASS
となりました。
現時点で分かっていること
再現するケース
| ケース | 結果 |
|---|---|
ViewModel + mutableStateOf
|
❌ |
mutableStateOf を持つ通常クラス |
❌ |
再現しないケース
| ケース | 結果 |
|---|---|
| Room の読み書き | ✅ |
| ViewModel のみ | ✅ |
StateFlow を使用 |
✅ |
Scaffold について
後続の Compose UI Test を
Scaffold {
...
}
でラップすると失敗を回避できるケースがありました。
ただし、
- Scaffold 単体では再現しない
- Scaffold の有無だけでは説明できない
ため、
Scaffold は根本原因ではなく、Compose UI Test の Idling 判定に影響しているだけではないかと考えています。
現時点の仮説
今回の調査結果では、
Robolectric Test
↓
mutableStateOf を更新
↓
後続の Compose UI Test
↓
AppNotIdleException
という条件で再現しています。
一方、
StateFlow を更新
↓
後続の Compose UI Test
では再現していません。
そのため、現時点では Compose Runtime の Snapshot State (mutableStateOf) と Robolectric / Compose UI Test の Idling 判定の組み合わせに何らかの問題があるのではないかと考えています。
今後
まだ根本原因までは特定できていません。
今後はさらに検証を進め、
- Compose Runtime
- Compose UI Test
- Robolectric
- Espresso Idling Resource
のどこに起因するのかを調査していく予定です。
もし同じ現象に遭遇した方がいれば、ぜひ情報をいただけると嬉しいです。
また、検証用リポジトリも公開しているので、再現確認などに利用していただければと思います。