Android 測試金字塔與開發者哲學

在軟體工程中,測試不只是為了「找出 Bug」,更是一種設計工具。透過撰寫測試,開發者能強迫自己寫出低耦合、高内聚的代碼,並在未來的重構中擁有充足的信心。

測試金字塔 (Testing Pyramid)

Android 官方建議遵循 70/20/10 法則來分配測試工作量:

底層:Small Tests (Unit Tests) - 70%

  • 範疇:測試單一函式、類別或 ViewModel
  • 特性:執行速度極快,跑在開發機的 JVM 上,不需要模擬器。
  • 工具:JUnit, Mockk, Truth。

中層:Medium Tests (Integration Tests) - 20%

  • 範疇:測試多個元件間的互動,如 Repository + DAO。
  • 特性:速度中等,部分可能需要 Robolectric 或小型模擬器環境。

頂層:Large Tests (UI Tests) - 10%

  • 範疇:模擬真實使用者操作整個 App 流程。
  • 特性:速度最慢且最容易因環境不穩而失效 (Flaky),需在實機執行。
  • 工具:Espresso, Compose Test Rule。

單元測試實戰 (Unit Testing)

我們以測試一個簡單的 UserViewModel 為例。

被測代碼

class UserViewModel(private val repository: UserRepository) : ViewModel() {
    private val _userName = mutableStateOf("")
    val userName: State<String> = _userName

    fun loadUser(id: String) {
        val user = repository.getUserById(id)
        _userName.value = user.name
    }
}

測試代碼 (JUnit 4 + Mockk)

class UserViewModelTest {
    // 建立一個 Mock 物件,模擬 Repository 行為
    private val repository = mockk<UserRepository>()
    private lateinit var viewModel: UserViewModel

    @Before
    fun setup() {
        viewModel = UserViewModel(repository)
    }

    @Test
    fun `當載入使用者時,應更新其名稱`() {
        // 定義 mock 行為:當呼叫 getUserById 時,回傳指定的 User 物件
        every { repository.getUserById("123") } returns User("Mike")

        viewModel.loadUser("123")

        // 使用 Truth 斷言結果
        assertThat(viewModel.userName.value).isEqualTo("Mike")
    }
}

協程測試 (Testing Coroutines)

背景任務通常使用 Coroutines。為了測試非同步邏輯,我們需要自定義 TestDispatcher

@Test
fun `測試背景同步任務`() = runTest {
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val viewModel = MyViewModel(testDispatcher)
    
    viewModel.startSync()
    
    // 模擬時間流逝或強制執行排隊中的協程
    advanceUntilIdle()
    
    assertThat(viewModel.isSyncComplete).isTrue()
}

Compose UI 測試

在 Compose 中,我們不測試「像素」,而是測試「語意 (Semantics)」。

class MyComposeTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun testButton_ShouldDisplayOriginalText() {
        composeTestRule.setContent {
            MyTheme { MyButtonScreen() }
        }

        // 尋找帶有 "Submit" 文字的組件並驗證其存在
        composeTestRule.onNodeWithText("Submit").assertExists()
        
        // 模擬點擊行為
        composeTestRule.onNodeWithText("Submit").performClick()
    }
}

測試策略:Mocking vs Faking

  • Mocking (模擬):使用框架(如 Mockk)動態產生行為。適合用於驗證「函式有沒有被呼叫」以及簡單的回傳。
  • Faking (虛擬):手動寫一個測試版的類別(如 FakeRepository)。當你的邏輯涉及複雜的狀態(如資料庫緩存)時,寫 Fake 通常比寫一長串 every { ... } 更好維護。

為什麼有的程式碼很難測?

如果你發現你的測試需要準備龐大的初始化設定,通常代表你的架構出問題了

  • 依賴隱藏:在建構子內部直接 new 物件,而不是透過 Hilt / DI 注入。
  • 職責過重:一個類別做了太多不相關的事。
  • 靜態相依:大量使用全域靜態方法(Singleton 或 Static Utility)。

遵循測試金字塔,從底層最穩定的單元測試築起,才能在快速迭代的同時確保 App 的穩定性。