测试与质量
JUnit/Espresso/Lint。
测试与质量
这一课你将能做到
- 编写 JUnit 单元测试覆盖 ViewModel 和 Repository 逻辑
- 使用 Espresso 和 Compose Test 编写 UI 测试
- 配置 detekt、ktlint、LeakCanary 保障代码质量与内存安全
为什么要学
「代码能跑」和「代码可靠」之间差着测试。单元测试保证逻辑正确,UI 测试保证交互正确,静态分析保证代码规范,内存检测保证不泄漏。没有测试的代码,每次重构都是赌博。
人话:测试就像「安全网」。走钢丝(改代码)时,有网(测试)你放心大胆走,没网你战战兢兢。Lint 是「交通规则」,LeakCanary 是「汽车安全带」,都是出事时救命的。
面试中,「测试金字塔」、「MockK 用法」、「Espresso 原理」是高频考点。
先建立图像
测试金字塔:
╱╲
╱ ╲
╱ UI ╲ ← 少量,慢,集成测试
╱ Test ╲ Espresso/Compose Test
╱────────╲
╱ 集成测试 ╲ ← 适量,中速
╱ (Room/网络)╲ Robolectric
╱──────────────╲
╱ 单元测试 ╲ ← 大量,快速,隔离
╱ (ViewModel/Repo) ╲ JUnit + MockK
╲──────────────────╱
静态分析:
detekt → 代码坏味道
ktlint → 代码格式
LeakCanary → 内存泄漏
跟着做
步骤 1:JUnit 单元测试(ViewModel)
// 测试 ViewModel
class TrackListViewModelTest {
private lateinit var viewModel: TrackListViewModel
private lateinit var repository: TrackRepository
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Before
fun setup() {
repository = mockk()
viewModel = TrackListViewModel(repository)
}
@Test
fun `初始状态为加载中`() = runTest {
// Given
coEvery { repository.tracks } returns flowOf(emptyList())
coEvery { repository.refreshTracks() } returns Result.success(Unit)
// When
viewModel = TrackListViewModel(repository)
// Then
assertEquals(true, viewModel.uiState.value.isLoading)
}
@Test
fun `加载成功后显示课程列表`() = runTest {
// Given
val tracks = listOf(
TrackEntity("and-intro", "Android 入门", "描述", 12, null)
)
coEvery { repository.tracks } returns flowOf(tracks)
coEvery { repository.refreshTracks() } returns Result.success(Unit)
// When
viewModel = TrackListViewModel(repository)
advanceUntilIdle() // 等待所有协程完成
// Then
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertEquals(1, state.tracks.size)
assertEquals("and-intro", state.tracks[0].slug)
}
@Test
fun `刷新失败时显示错误信息`() = runTest {
// Given
coEvery { repository.tracks } returns flowOf(emptyList())
coEvery { repository.refreshTracks() } returns
Result.failure(Exception("网络错误"))
// When
viewModel = TrackListViewModel(repository)
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertEquals(false, state.isLoading)
assertEquals("网络错误", state.error)
}
@Test
fun `点击收藏后调用 Repository`() = runTest {
// Given
coEvery { repository.tracks } returns flowOf(emptyList())
coEvery { repository.refreshTracks() } returns Result.success(Unit)
coEvery { repository.toggleFavorite(any()) } just Runs
viewModel = TrackListViewModel(repository)
advanceUntilIdle()
// When
viewModel.onFavoriteClick("and-intro")
advanceUntilIdle()
// Then
coVerify { repository.toggleFavorite("and-intro") }
}
}
// 测试规则:替换 Main Dispatcher
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
步骤 2:Repository 测试(Room + Mock 网络)
@RunWith(RobolectricTestRunner::class)
class TrackRepositoryTest {
private lateinit var database: AppDatabase
private lateinit var dao: TrackDao
private lateinit var api: TrackApiService
private lateinit var repository: TrackRepository
@Before
fun setup() {
// 内存数据库
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
.build()
dao = database.trackDao()
api = mockk()
repository = TrackRepository(api, dao, mockk())
}
@After
fun tearDown() {
database.close()
}
@Test
fun `网络数据成功写入数据库`() = runTest {
// Given
val dtos = listOf(
TrackDto("and-intro", "Android 入门", "描述", 12, null, "2024-01-01")
)
coEvery { api.getTracks() } returns ApiResponse(0, "success", dtos)
// When
val result = repository.refreshTracks()
// Then
assertTrue(result.isSuccess)
val entities = dao.getAllTracks().first()
assertEquals(1, entities.size)
assertEquals("and-intro", entities[0].slug)
}
@Test
fun `数据库数据驱动 UI 状态`() = runTest {
// Given
val entity = TrackEntity("and-intro", "Android 入门", "描述", 12, null)
dao.insertTracks(listOf(entity))
// When
val tracks = repository.tracks.first()
// Then
assertEquals(1, tracks.size)
assertEquals("Android 入门", tracks[0].title)
}
}
步骤 3:Compose UI 测试
@RunWith(AndroidJUnit4::class)
class TrackListScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun 加载中显示进度条() {
// Given
composeTestRule.setContent {
TrackListScreen(
uiState = TrackListUiState(isLoading = true),
onRefresh = {},
onFavoriteClick = {}
)
}
// Then
composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed()
composeTestRule.onNodeWithText("加载中").assertIsDisplayed()
}
@Test
fun 显示课程列表() {
// Given
val tracks = listOf(
TrackUiModel("and-intro", "Android 入门", "描述", 12, 85, false),
TrackUiModel("kotlin", "Kotlin 基础", "描述", 10, 60, true)
)
composeTestRule.setContent {
TrackListScreen(
uiState = TrackListUiState(tracks = tracks),
onRefresh = {},
onFavoriteClick = {}
)
}
// Then
composeTestRule.onNodeWithText("Android 入门").assertIsDisplayed()
composeTestRule.onNodeWithText("Kotlin 基础").assertIsDisplayed()
composeTestRule.onNodeWithText("85%").assertIsDisplayed()
}
@Test
fun 点击收藏按钮触发回调() {
// Given
var clickedSlug: String? = null
val tracks = listOf(
TrackUiModel("and-intro", "Android 入门", "描述", 12, 85, false)
)
composeTestRule.setContent {
TrackListScreen(
uiState = TrackListUiState(tracks = tracks),
onRefresh = {},
onFavoriteClick = { clickedSlug = it }
)
}
// When
composeTestRule.onNodeWithContentDescription("收藏").performClick()
// Then
assertEquals("and-intro", clickedSlug)
}
@Test
fun 错误状态显示重试按钮() {
// Given
var retryClicked = false
composeTestRule.setContent {
TrackListScreen(
uiState = TrackListUiState(error = "网络错误"),
onRefresh = { retryClicked = true },
onFavoriteClick = {}
)
}
// Then
composeTestRule.onNodeWithText("网络错误").assertIsDisplayed()
composeTestRule.onNodeWithText("重试").assertIsDisplayed()
// When
composeTestRule.onNodeWithText("重试").performClick()
// Then
assertTrue(retryClicked)
}
}
步骤 4:Espresso 传统 View 测试
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun 点击导航到详情页() {
// Given: 在列表页
onView(withId(R.id.recyclerView))
.check(matches(isDisplayed()))
// When: 点击第一项
onView(withId(R.id.recyclerView))
.perform(
RecyclerViewActions.actionOnItemAtPosition<TrackViewHolder>(
0, click()
)
)
// Then: 显示详情页
onView(withId(R.id.tvDetailTitle))
.check(matches(isDisplayed()))
}
@Test
fun 输入文本并提交() {
onView(withId(R.id.etSearch))
.perform(typeText("Android"), closeSoftKeyboard())
onView(withId(R.id.btnSearch))
.perform(click())
onView(withId(R.id.tvResult))
.check(matches(withText(containsString("Android"))))
}
}
步骤 5:静态分析与内存检测
// build.gradle.kts 配置 detekt
plugins {
id("io.gitlab.arturbosch.detekt") version "1.23.6"
}
detekt {
config.setFrom("$rootDir/config/detekt/detekt.yml")
baseline = file("$rootDir/config/detekt/baseline.xml")
}
// ktlint 配置
plugins {
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
}
// LeakCanary 自动检测(debugImplementation)
dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13")
}
LeakCanary 会自动检测 Activity/Fragment 内存泄漏,在通知栏提示。
讲清楚要点
-
测试金字塔:单元测试(70%)→ 集成测试(20%)→ UI 测试(10%)。单元测试快、隔离、反馈即时;UI 测试慢、脆弱、维护成本高。比例不能反。
-
MockK 是 Kotlin 首选 Mock 框架:支持 coEvery 处理 suspend 函数,coVerify 验证调用,just Runs 表示 Unit 返回。比 Mockito 更符合 Kotlin 习惯。
-
Compose Test 用语义树:onNodeWithText、onNodeWithContentDescription、onNodeWithTag 查找节点。assertIsDisplayed 验证可见,performClick 模拟点击。
-
detekt 和 ktlint 互补:detekt 查「坏味道」(过长函数、复杂表达式),ktlint 查「格式」(缩进、换行)。CI 中都应该配置,阻断不合格代码合并。
-
LeakCanary 原理:通过弱引用监听对象销毁,若未按时销毁则触发堆 dump,分析引用链找出泄漏点。Debug 包自动启用,Release 必须移除。
你可能会懵的地方
-
测试覆盖率多少合适?
没有标准答案。核心逻辑(Repository、UseCase)追求 80%+,UI 层 50%+ 即可。不要为了数字写无意义的测试。
-
单元测试能跑在 JVM 上吗?
纯逻辑(ViewModel、UseCase)可以,用 runTest。涉及 Android 框架(Context、Room)的需要 Robolectric 或真机/模拟器。
-
Compose Test 和 Espresso 能混用吗?
可以。createComposeRule 用于纯 Compose;createAndroidComposeRule 可以在 Activity 中混合测试 Compose 和传统 View。
-
为什么要用 UnconfinedTestDispatcher?
它让协程立即执行,测试代码更线性。但会跳过调度逻辑,复杂时序测试用 StandardTestDispatcher 更真实。
-
LeakCanary 误报怎么办?
某些系统类或第三方库会误报。在 Application 中配置 LeakCanary.config = LeakCanary.config.copy(computeRetainedHeapSize = false) 或添加排除规则。
错法对照
| 错法 | 问题 | 正解 |
|---|
用 GlobalScope 写测试 | 无法等待协程完成 | runTest + advanceUntilIdle |
| 在单元测试里用真 Room 数据库 | 慢、不稳定 | 内存数据库 + Robolectric |
| UI 测试断言文本硬编码 | 多语言失败 | 用 string resource 或 tag |
| detekt 规则全部开启 | 噪音太多,团队抵触 | 逐步启用,先严重后轻微 |
| LeakCanary 带到 Release 包 | 性能损耗,用户可见 | 只在 debugImplementation 添加 |
卡点急救
-
MockK 报错「ClassCastException: class java.lang.Object cannot be cast」:检查是否 mockk() 时指定了具体类型,或泛型擦除导致。用 mockk<TrackRepository>() 显式声明。
-
Compose Test 找不到节点:检查是否 setContent 成功;用 onRoot().printToLog("TAG") 打印语义树;确保测试设备/模拟器动画关闭(开发者选项 → 动画缩放全部设为 0)。
-
Room 内存数据库测试失败:检查是否 allowMainThreadQueries()(测试环境可以),或改用 runTest 在协程中执行。
面试常问
-
测试金字塔为什么单元测试最多?
单元测试执行快(毫秒级)、反馈即时、定位精确、维护成本低。UI 测试慢(秒级)、环境依赖多、易碎。金字塔越往上,越接近真实用户,但成本越高。
-
MockK 的 every 和 coEvery 区别?
every 用于普通函数,coEvery 用于 suspend 函数。coVerify 验证 suspend 函数调用。用错会导致编译错误或测试失败。
-
Espresso 如何保证测试稳定性?
IdlingResource 等待异步完成(网络、动画);禁用系统动画(开发者选项);避免固定 Thread.sleep;用 Barista 或 Kakao 封装简化 API。
自测三问
- 解释
runTest 中 advanceUntilIdle 的作用。
- 设计一个测试场景:验证「网络请求失败时,UI 显示错误信息并可重试」。
- 为什么 LeakCanary 只在 Debug 包引入?如果 Release 引入会有什么后果?
小结一句话
测试是代码的「保险单」:JUnit 保逻辑,Espresso 保交互,detekt 保规范,LeakCanary 保内存;没有测试的重构是赌博,有测试的重构是升级。
延伸