【解決】GitHub ActionsのAndroidテストが数時間も終わらない?根本原因と対策を徹底解説
【解決】GitHub ActionsのAndroidテストが数時間も終わらない?根本原因と対策を徹底解説
公開日: 2025年6月9日
タグ: Android
, Kotlin
, CI/CD
, GitHub Actions
, Coroutine
, テスト
, TDD
ローカル環境では一瞬で終わるテストが、なぜかGitHub Actions上では何時間も終わらずにタイムアウトしてしまう…。多くのAndroid開発者が一度は頭を悩ませるこの問題。
この記事では、その現象の根本原因である「ディスパッチャーの競合」を解き明かし、具体的なコード例を交えながら、恒久的な解決策をステップバイステップで解説します。
この記事で解決できる悩み
- CI/CD上でのみ、テストがハングアップして終わらない。
kotlinx-coroutines-test
を使っているが、非同期処理のテストが不安定。- テストの品質を上げたいが、何から手をつけるべきか分からない。
なぜハングするのか?犯人は「ディスパッチャーの競合」
結論から言うと、テストがハングする最大の原因は、テストコードが使う「仮想時間」と、ViewModelやRepository内部で動く「現実時間」が衝突し、デッドロック(永久に待ち合う状態)を引き起こしていることです。
kotlinx-coroutines-test
ライブラリのTestDispatcher
は、テストを安定させるために「仮想のクロック」上でコルーチンを実行します。そして、テストコード内のadvanceUntilIdle()
は、「仮想クロック上のタスクが全て完了するまで待つ」という命令です。
しかし、あなたのViewModelやRepositoryの中に、以下のようなコードはありませんか?
// ViewModelやRepository内部の、問題を引き起こすコード例
fun loadDataFromServer() {
viewModelScope.launch(Dispatchers.IO) { // ← 問題の箇所!
// データベースアクセスやAPI通信など
val data = heavyNetworkRequest()
_uiState.value = UiState.Success(data)
}
}
このコードの問題点は、Dispatchers.IO
という、テストの管理外にある「現実時間」で動くディスパッチャーを直接呼び出していることです。
これにより、以下のようなデッドロックが発生します。
-
テスト側: 「
TestDispatcher
(仮想時間)のタスクが終わるまでadvanceUntilIdle()
で待機しよう」 -
ViewModel側: 「
Dispatchers.IO
(現実時間)のタスクが終わるまで結果を返せない…」
お互いが相手の終了を待つため、テストは永遠に完了せず、ハングアップします。これが、CI上でテストが数時間経っても終わらない現象の正体です。
解決へのロードマップ:3つの具体的なアクションプラン
この問題を根本的に解決し、安定したテストを構築するための3つの具体的なアクションプランを提示します。特にプラン1が最も重要です。
プラン1:【最重要】ディスパッチャーを外部から注入(DI)する
ViewModelやRepositoryが、内部でDispatchers.IO
などを直接生成するのではなく、コンストラクタを通して外部から受け取るように設計を変更します。これを**依存性の注入(Dependency Injection, DI)**と呼びます。
Step 1: ViewModelのコンストラクタを修正
CoroutineDispatcher
をコンストラクタの引数に追加します。
修正前
class MyViewModel(
private val repository: MyRepository
) : ViewModel() {
fun loadData() {
viewModelScope.launch(Dispatchers.IO) { /* ... */ }
}
}
修正後
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
class MyViewModel(
private val repository: MyRepository,
private val ioDispatcher: CoroutineDispatcher // ← DIポイント
) : ViewModel() {
fun loadData() {
viewModelScope.launch(ioDispatcher) { // ← 注入されたディスパッチャーを使用
/* ... */
}
}
}
Step 2: 本番コードとテストコードで渡すものを分ける
本番のコードでは、Dispatchers.IO
を渡します。
// 実際のアプリでViewModelを生成する箇所
val myViewModel = MyViewModel(myRepository, Dispatchers.IO)
テストコードでは、TestDispatcher
を渡します。
// テストコードの@BeforeEachやsetup関数内
private lateinit var testDispatcher: TestDispatcher
private lateinit var viewModel: MyViewModel
@BeforeEach
fun setup() {
testDispatcher = StandardTestDispatcher()
viewModel = MyViewModel(fakeRepository, testDispatcher) // ← テスト用を注入!
}
この設計により、ViewModelは本番環境とテスト環境で振る舞いを一切変えることなく、ディスパッチャーだけを差し替えることができ、テストの信頼性が劇的に向上します。
forkEvery
でハングを隔離する
プラン2:【影響の限定】この設定は根本解決ではありませんが、ハングしたテストが他のテストに影響を与えるのを防ぎ、原因特定を容易にするための強力な防護壁です。
app/build.gradle
(またはbuild.gradle.kts
)に以下の設定を追加します。
// app/build.gradle
android {
// ...
testOptions {
unitTests.all {
// テストクラス1つごとに新しいJVMプロセスを起動する
// メモリリークや静的状態の汚染、ハングの影響を限定する
forkEvery = 1
}
}
}
これにより、テストがハングしても、そのテストクラスの実行が終わればプロセスごと強制終了されるため、CI全体が停止する事態を防げます。
プラン3:【安全装置】GitHub Actionsにタイムアウトを設定する
最後の砦として、CIのジョブ自体にタイムアウトを設定し、無限にリソースを消費するのを防ぎます。
.github/workflows/ci.yml
にtimeout-minutes
を追加します。
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30 # ← 例えば30分でジョブを強制終了
steps:
- uses: actions/checkout@v4
# ... 以降のステップ
まとめ
CIでのテストが長時間ハングする問題は、テストコードの細かなロジックミスではなく、非同期処理の扱い方というアーキテクチャ上の問題であることがほとんどです。
解決の鍵
- 根本解決: ディスパッチャーのDIを徹底し、テストと本番で利用するディスパッチャーを分離する。
- リスク管理:
forkEvery
やtimeout-minutes
で、万が一のハングアップに備える。
このアプローチは、テストが不安定であるという目の前の問題を解決するだけでなく、あなたのAndroidアプリをより堅牢で、メンテナンスしやすく、テスト可能な設計へと進化させます。これは、全てのAndroid開発者が身につけるべき非常に重要なプラクティスです。
ぜひ、ご自身のプロジェクトにこの改善を加えて、安定したCI環境を手に入れてください。
Discussion