Android 測試金字塔與開發者哲學
在軟體工程中,測試不只是為了「找出 Bug」,更是一種設計工具。透過撰寫測試,開發者能強迫自己寫出低耦合、高内聚的代碼,並在未來的重構中擁有充足的信心。
測試金字塔 (Testing Pyramid)
Android 官方建議遵循 70/20/10 法則來分配測試工作量:
底層:Small Tests (Unit Tests) - 70%
中層: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 的穩定性。