1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Android開発30日間マスターシリーズ - Day27: Unit TestとInstrument Test - JUnit、Espresso、Mockitoを使った包括的テスト

Posted at

はじめに

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テスト: runTestUnconfinedTestDispatcherを使用
  • StateFlow/Flowテスト: Turbineライブラリで簡潔に記述
  • UIテスト: Espressoで実際のユーザー操作をシミュレート
  • CI統合: 自動テスト実行で品質を継続的に保証

これらのテストを適切に実装することで、アプリの品質を包括的に保証し、安心してリリースできる状態になります。次回は、いよいよアプリをリリースに向けて準備する最終ステップに入ります。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?