はじめに
この記事では、Android開発におけるテストの概要について述べ、その中でもユニットテストとUIテストという2種類のテストについて具体的な流れを解説します。間違いなどありましたら指摘していただけますと幸いです。
なお、本記事ではテストを行うアプリとして簡易的なTodoアプリを使用しています。
GitHubリポジトリは以下の通りです。
https://github.com/suzukibelltree/SampleToDoApp
Android開発におけるテスト
Android開発におけるテストは以下の4種類に分けられます。
- ユニットテスト(単体テスト):関数やクラスなどの小さな単位でそのロジックが正しく動作することを確認するテスト
- インストルメンテーションテスト:Androidデバイスやエミュレータ上で実行されるテスト
- 統合テスト:複数コンポーネントを組み合わせて動作を確認
- E2Eテスト:アプリを起動してから終了まで、ユーザー操作を完全に再現しながら全体の動作を確認
本記事では、このうちユニットテストとインストルメンテーションテストの1種であるUIテストについて簡単に解説します。
ユニットテスト(単体テスト)
概要
目的
ユニットテストは小さな単位(関数、ViewModelなど)でのロジックが正しく動作するかを検証するために行われます。
実行環境
ユニットテストは、JVM(Java Virtual Machine)上で実行されます。そのため、Android OSを必要とせず、高速で実行できるというメリットがあります。
主な対象
- ビジネスロジック
- データ変換
- ViewModelのState管理など
使用するフレームワーク
- JUnit4
- MockK
JUnit4はテストの実行環境を提供し、テストケースの定義や検証を行うフレームワークです。
一方で、MockKはテストの中で外部に依存する部分(API呼び出し、DBアクセスなど)の部分をモックに置き換えるのに必要なライブラリです。
コード例
class HomeViewModelTest {
val repository = mockk<TasksRepository>()
private lateinit var viewModelTest: HomeViewModel
@Test
fun `When repository returns task, UI flows Loading to Success with correct classification`() {
runTest {
val FinishedTasks = listOf(
Task(
id = 1,
title = "Test Task 1",
deadline = "2025/01/01",
importance = TaskPriority.HIGH.level,
color = 0xFF0000,
progress = 0,
isDone = true
),
)
val unfinishedTasks = listOf(
Task(
id = 2,
title = "Test Task 2",
deadline = "2025/01/02",
importance = TaskPriority.LOW.level,
color = 0x0000FF,
progress = 0,
isDone = false
)
)
// タスク取得処理のモック化
every {
repository.getFinishedTasks()
} returns flowOf(FinishedTasks)
every {
repository.getUnfinishedTasks()
} returns flowOf(unfinishedTasks)
viewModelTest = HomeViewModel(repository)
viewModelTest.uiState.test {
// 初期状態の確認(UI状態がLoadingであるか)
assert(awaitItem() is HomeUiState.Loading)
// データ取得後の状態を確認
val state = awaitItem()
// UI状態がSuccessであることを確認
assert(state is HomeUiState.Success)
val successState = state as HomeUiState.Success
// タスクが正しく取得されているか確認
assert(successState.finishedTasks.size == FinishedTasks.size)
assert(successState.unfinishedTasks.size == unfinishedTasks.size)
assert(!successState.isEmpty)
}
}
}
}
まず、ユニットテストでは以下のようにテストのルールを設定しておく必要があります。
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
テストコードにおける上記の部分はコルーチンを使ったコードをテストで正しく動かすために必要になります。
普通のアプリ実行時はDispatchers.Mainによってメインスレッド(UIスレッド)上で処理が行われていますが、テスト時はDispatchers.Mainが使えないので、テスト用のディスパッチャーを代わりに割り当てています。
(個人的にはテストを行うときにスレッド処理をいい感じにしてくれるおまじないという理解です)
このテストは、HomeViewModel
の uiState
がリポジトリからのデータ取得に応じて正しく変化するかを検証しています。
- モックの準備
val repository = mockk<TasksRepository>()
TasksRepository
を mockk
でモック化しています。
every {repository.getFinishedTasks()} returns flowOf(FinishedTasks)
every {repository.getUnfinishedTasks()} returns flowOf(unfinishedTasks)
getFinishedTasks()
と getUnfinishedTasks()
がそれぞれ、事前に用意したタスクのリスト(FinishedTasks
と unfinishedTasks
)を flowOf で返すよう設定しています。
これにより、実際のデータベースやAPIに依存せず、決まったデータで動作をテスト可能です。
- ViewModelの初期化
viewModelTest = HomeViewModel(repository)
モック化したリポジトリを渡して HomeViewModel
を生成します。
ViewModelは初期化時にリポジトリのデータ取得を開始し、uiState を流します。
- UI状態の検証(turbineライブラリ使用)
viewModelTest.uiState.test {
// 初期状態の確認(UI状態がLoadingであるか)
assert(awaitItem() is HomeUiState.Loading)
// データ取得後の状態を確認
val state = awaitItem()
// UI状態がSuccessであることを確認
assert(state is HomeUiState.Success)
val successState = state as HomeUiState.Success
・・・
}
uiState
は StateFlow
で、test ブロック内で値の変化を監視します。
最初に流れてくる状態は Loading であることを assert で確認しています。
次に流れてくる状態が Success であることを確認し、成功時のUI状態を変数に代入しています。
- 成功時のデータ内容検証
viewModelTest.uiState.test {
・・・
// タスクが正しく取得されているか確認
assert(successState.finishedTasks.size == FinishedTasks.size)
assert(successState.unfinishedTasks.size == unfinishedTasks.size)
assert(!successState.isEmpty)
}
Success 状態の中の finishedTasks
と unfinishedTasks
の件数が、モックで用意したリストと一致することを検証しています。
また、isEmpty フラグが false であることも確認し、実際にタスクが存在していることを検証しています。
実行例
前述したとおり、ユニットテストはJVM上で実行されます。テストが成功した場合、上の画像の通りTest passedと表示されます。
UIテスト
概要
目的
UIテストは、Androidアプリが実際に端末(エミュレータ)上で動作することを確認するテストです。
実行環境
UIテストは実機またはエミュレータ上で実行されます。そのため、ユニットテストと比較すると少し時間がかかります。
主な利用ケース
- ボタンをタップしたときに正しい画面へ遷移するか
- テキスト入力やスクロールが正しく動作するか
- データベースにデータが正しく保存されているか
コード例
@RunWith(AndroidJUnit4::class)
class AddTaskUITest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun addTaskTest() {
val fakeViewModel = FakeAddTaskViewModel()
composeTestRule.setContent {
AddTaskScreen(viewModel = fakeViewModel, onNavigateToHome = {})
}
composeTestRule.onNodeWithTag("fab_add_task")
// タスクのタイトルを入力
composeTestRule.waitUntil(timeoutMillis = 5_000) {
composeTestRule.onAllNodesWithTag("input_task_title")
.fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithTag("input_task_title")
.assertIsDisplayed()
.performClick()
.performTextInput("SampleTask")
// タスクの重要度を選択
composeTestRule.onNodeWithTag("priority_1").performClick()
// タスクを保存
composeTestRule.onNodeWithTag("button_save_task").performClick()
}
}
テストの流れ
- ルールの設定
UIテストでも、ユニットテストと同様に初めにテストのルールを設定しておきます。
@get:Rule
val composeTestRule = createComposeRule()
createComposeRule()
は、Jetpack Compose の UIテストを実行するために必要な テストルール(Test Rule) を生成する関数です。
- ノードの取得
UIテストでは、まずonNodeWithText()
,onNodeWithTag()
などを用いてノードを取得します。これにより、画面中の特定のコンポーネントを見つけてテストの対象としています。
//例:「保存」のテキストボタンを探す
composeTestRule.onNodeWithText("保存")
//例:saveButton、というタグを付けられたコンポーネントを探す
composeTestRule.onNodeWithTag("saveButton")
- アサーション
ノードを取得したら、アサーション(期待通りに動作しているかのチェック)を行います。以下のコードでは、assertIsDisplayed()
,assertDoesNotExist()
,assertIsEnabled()
の3種類のメソッドによってアサーションを行っています。
// ノードを取得
val saveButton = composeTestRule.onNodeWithText("保存")
// 例1:ボタンが画面上に表示されていることの確認
saveButton.assertIsDisplayed()
// 例2:エラーメッセージが画面上に表示されていないことの確認
composeTestRule.onNodeWithText("エラー").assertDoesNotExist()
// 例3:ボタンが有効(クリック可能)であることの確認
saveButton.assertIsEnabled()
- ユーザー操作
UIテストでは、ノードを取得したUI要素に対して、特定の操作を行うことが出来ます。以下のコードでは、performClick()
によるボタンのクリック、performTextInput()
によるテキストフィールドへの文字列入力、performScrollTo()
による対象コンポーネントへのスクロールを行っています。
// 例1:保存ボタンに対してクリックイベントを送る
composeTestRule.onNodeWithText("保存").performClick()
// 例2:inputFieldタグが付けられたテキストフィールドに「こんにちは」と入力
composeTestRule.onNodeWithTag("inputField").performTextInput("こんにちは")
// 例3:スクロール可能なUI要素の中でタグが付けられたコンポーネントまでスクロール
composeTestRule.onNodeWithText("最後の項目").performScrollTo()
<補足:非同期処理の扱い>
UIテストでは画面遷移や非同期処理を適切に待機する必要があります。
この記事で例として使用しているアプリでは、UiStateをViewModelのメンバとして持たせ、StateFlowでコンポーザブル関数に渡しているため、Flowの最新状態がUIに反映されるまで一度待つ必要があります。
// 例: 最大5秒間、特定の条件が満たされるまで待機
composeTestRule.waitUntil(5000) {
composeTestRule.onAllNodesWithText("input_task_title").fetchSemanticsNodes().isNotEmpty()
}
実行例
まとめ
この記事では、Android開発におけるテストの全体像に触れ、特に以下の2種類のテストについて解説しました。
-
ユニットテスト
-
小さな単位のロジック検証
-
実行が速く、外部依存をモック化してテスト可能
-
-
UIテスト
-
実際の画面操作を自動で検証
-
実行時間はかかるが、ユーザー目線の動作確認ができる
-
両者は目的や実行環境が異なりますが、組み合わせることでアプリの品質を高められます。
まずはユニットテストでロジックの土台を固め、その上で重要な画面遷移やユーザー操作をUIテストで検証すると、開発の効率と信頼性が大きく向上します。