はじめに
Androidアプリ開発の最終段階では、テストが不可欠です。バグを発見し、品質を保証するために、さまざまな種類のテストが実施されます。今日は、アプリのロジックとUIの両方を網羅的にテストするための、主要なテスト手法とツールについて学びます。
1. なぜテストが重要なのか?
テストは、コードの信頼性と保守性を高めます。手動で毎回アプリを操作して確認する代わりに、自動テストを導入することで、以下のメリットが得られます:
- バグの早期発見: 開発サイクルの早い段階で問題を特定できます
- リファクタリングの安全性: コードを改善する際に、既存の機能が壊れていないことを確認できます
- 回帰テストの効率化: 新しい機能を追加した際に、以前の機能に影響がないかを簡単にチェックできます
- 継続的インテグレーション(CI): 自動化により、チーム開発での品質保証が向上します
2. テストの種類とピラミッド構造
Androidアプリのテストは、テストピラミッドの考え方に従って構成されます:
/\
/UI\ ← 少数(遅い、高コスト)
/____\
/ \
/ Integration\ ← 中程度
/__________\
/ \
/ Unit Tests \ ← 多数(高速、低コスト)
/________________\
- Unit Tests (70%): ビジネスロジック、ユーティリティ関数の検証
- Integration Tests (20%): 複数コンポーネント間の連携テスト
- UI Tests (10%): エンドユーザーの操作フローの検証
3. ユニットテスト (Unit Test)
ユニットテストは、アプリの最小単位(関数、クラス、メソッドなど)が期待通りに動作するかを検証するテストです。Androidでは、通常、JVM上で実行され、UIやAndroidフレームワークに依存しないロジックを対象とします。
必要な依存関係
build.gradle.kts (Module: app)
に以下を追加:
dependencies {
// Unit Test
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.1.1")
testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("app.cash.turbine:turbine:1.0.0")
// Android Test
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.test:rules:1.5.0")
}
実装例: ViewModelのテスト
前回作成したChatViewModel
をテストしてみましょう。ここでは、Repositoryをモック化し、Coroutinesのテストも適切に行います:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.Assert.*
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.*
import app.cash.turbine.test
@ExperimentalCoroutinesApi
class ChatViewModelTest {
@Mock
private lateinit var mockRepository: LlmRepository
private lateinit var viewModel: ChatViewModel
private val testDispatcher = UnconfinedTestDispatcher()
@Before
fun setup() {
// Mockitoの初期化
MockitoAnnotations.openMocks(this)
// テスト用のDispatcherを設定
Dispatchers.setMain(testDispatcher)
// テスト対象のViewModelをインスタンス化
viewModel = ChatViewModel(mockRepository)
}
@After
fun tearDown() {
// MainDispatcherをリセット
Dispatchers.resetMain()
}
@Test
fun `sendMessage_givenValidInput_updatesChatStateWithUserAndAiMessage`() = runTest {
// Given: モックの振る舞いを定義
val expectedStreamResponse = listOf("Hello", " ", "World", "!")
whenever(mockRepository.streamChatCompletion(any()))
.thenReturn(flowOf(*expectedStreamResponse.toTypedArray()))
// When: テスト対象のメソッドを実行
viewModel.sendMessage("Hello, AI")
// Then: StateFlowの変化を検証
viewModel.messages.test {
val messages = awaitItem()
// ユーザーメッセージが追加されたことを確認
assertEquals(2, messages.size)
assertEquals("user", messages[0].role)
assertEquals("Hello, AI", messages[0].content)
// AIメッセージが追加され、ストリーミングが完了していることを確認
assertEquals("assistant", messages[1].role)
assertEquals("Hello World!", messages[1].content)
assertFalse(messages[1].isStreaming)
}
}
@Test
fun `sendMessage_whenRepositoryThrowsException_updatesErrorState`() = runTest {
// Given: リポジトリがエラーを投げるように設定
val expectedException = RuntimeException("Network error")
whenever(mockRepository.streamChatCompletion(any()))
.thenThrow(expectedException)
// When: テスト対象のメソッドを実行
viewModel.sendMessage("Hello, AI")
// Then: エラー状態が更新されることを確認
viewModel.error.test {
val errorMessage = awaitItem()
assertNotNull(errorMessage)
assertTrue(errorMessage!!.contains("Network error"))
}
}
@Test
fun `initialState_hasEmptyMessages_andNotLoading`() {
// Then: 初期状態を確認
assertTrue(viewModel.messages.value.isEmpty())
assertFalse(viewModel.isLoading.value)
assertNull(viewModel.error.value)
}
}
重要なポイント:
-
@ExperimentalCoroutinesApi
: Coroutinesテスト機能を使用するため -
UnconfinedTestDispatcher
: テスト用の同期ディスパッチャ -
runTest
: Coroutinesを含むテストを実行するためのテストビルダー -
Turbine
: StateFlowのテストを簡単に行うライブラリ -
any()
: Mockitoのマッチャー、任意の引数にマッチ
4. インストゥルメンテーションテスト (Instrumented Test)
インストゥルメンテーションテストは、デバイスまたはエミュレーター上で実行されるテストです。Androidフレームワークのコンポーネント(Activity、Fragmentなど)やUIの動作を検証するのに適しています。
実装例: チャット画面のUIテスト
メッセージの送信と表示が正しく行われるかをテストする例です:
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.*
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.recyclerview.widget.RecyclerView
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.hamcrest.Matchers.*
@RunWith(AndroidJUnit4::class)
class ChatActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(ChatActivity::class.java)
@Test
fun sendMessage_displaysUserMessageInRecyclerView() {
val testMessage = "Hello, this is a test message"
// Given: チャット画面が表示されている
onView(withId(R.id.recycler_view_chat))
.check(matches(isDisplayed()))
// When: メッセージを入力して送信ボタンをクリック
onView(withId(R.id.edit_text_message))
.perform(typeText(testMessage), closeSoftKeyboard())
onView(withId(R.id.button_send))
.perform(click())
// Then: RecyclerViewにメッセージが表示されることを確認
onView(withId(R.id.recycler_view_chat))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(0))
onView(withText(testMessage))
.check(matches(isDisplayed()))
}
@Test
fun sendButton_isDisabled_whenInputIsEmpty() {
// Given: 入力フィールドが空の状態
onView(withId(R.id.edit_text_message))
.perform(clearText())
// Then: 送信ボタンが無効になっていることを確認
onView(withId(R.id.button_send))
.check(matches(not(isEnabled())))
}
@Test
fun sendButton_isEnabled_whenInputHasText() {
// When: テキストを入力
onView(withId(R.id.edit_text_message))
.perform(typeText("Test message"))
// Then: 送信ボタンが有効になることを確認
onView(withId(R.id.button_send))
.check(matches(isEnabled()))
}
@Test
fun loadingIndicator_showsWhileProcessing() {
// Given: メッセージを送信
onView(withId(R.id.edit_text_message))
.perform(typeText("Test message"), closeSoftKeyboard())
onView(withId(R.id.button_send))
.perform(click())
// Then: ローディングインジケーターが表示されることを確認
onView(withId(R.id.progress_bar))
.check(matches(isDisplayed()))
}
}
高度なUIテスト: IdlingResourceの使用
非同期処理を含むUIテストでは、IdlingResource
を使用してテストの同期を取ります:
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.idling.CountingIdlingResource
class ChatActivityAdvancedTest {
private lateinit var idlingResource: CountingIdlingResource
@Before
fun setup() {
idlingResource = CountingIdlingResource("ChatLoading")
IdlingRegistry.getInstance().register(idlingResource)
}
@After
fun tearDown() {
IdlingRegistry.getInstance().unregister(idlingResource)
}
@Test
fun sendMessage_waitsForResponse_displaysAIMessage() {
// IdlingResourceを使用して非同期処理を待機
onView(withId(R.id.edit_text_message))
.perform(typeText("Hello AI"))
onView(withId(R.id.button_send))
.perform(click())
// AIからの応答が表示されるまで自動的に待機
onView(withText(containsString("AI:")))
.check(matches(isDisplayed()))
}
}
5. テストのベストプラクティス
命名規則
テストメソッドには、以下の形式を使用します:
fun `methodName_givenCondition_expectedResult`()
例:
fun `sendMessage_givenEmptyInput_doesNotSendMessage`()
fun `calculateTotal_givenValidItems_returnsCorrectSum`()
AAA パターン
テストは、Arrange-Act-Assertパターンで構成します:
@Test
fun `example_test`() {
// Arrange: テストの準備
val input = "test input"
whenever(mockRepository.getData()).thenReturn(input)
// Act: テスト対象の実行
val result = viewModel.processData()
// Assert: 結果の検証
assertEquals("processed: $input", result)
}
テストデータの管理
テストデータは、TestData
オブジェクトで管理します:
object ChatTestData {
val sampleUserMessage = Message(
id = "user-1",
role = "user",
content = "Hello AI",
isStreaming = false
)
val sampleAIMessage = Message(
id = "ai-1",
role = "assistant",
content = "Hello! How can I help you?",
isStreaming = false
)
val sampleChatRequest = ChatRequest(
model = "gpt-3.5-turbo",
messages = listOf(sampleUserMessage),
stream = true
)
}
6. Continuous Integration (CI) との統合
GitHubActionsを使用したテストの自動実行例:
name: Android CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Run instrumentation tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
script: ./gradlew connectedDebugAndroidTest
7. まとめ
テストの種類 | 目的 | ツール | 特徴 |
---|---|---|---|
ユニットテスト | ロジックの検証 | JUnit, Mockito, Coroutines Test | 高速、UIに依存しない、開発環境で実行 |
インストゥルメンテーションテスト | UIとフレームワークの検証 | Espresso, UI Automator | 実機/エミュレーターで実行、遅い、より現実的なテスト |
重要なポイント:
- テストピラミッド: ユニットテスト(多)→ 統合テスト(中)→ UIテスト(少)の比率で実装
-
Coroutinesテスト:
runTest
、UnconfinedTestDispatcher
を使用 - StateFlow/Flowテスト: Turbineライブラリで簡潔に記述
- UIテスト: Espressoで実際のユーザー操作をシミュレート
- CI統合: 自動テスト実行で品質を継続的に保証
これらのテストを適切に実装することで、アプリの品質を包括的に保証し、安心してリリースできる状態になります。次回は、いよいよアプリをリリースに向けて準備する最終ステップに入ります。