CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Android 开发入门/课时/测试与质量
RawBlame
课时目录12 节

Android 开发入门

  1. 01Android 开发概览
  2. 02四大组件
  3. 03传统 View UI
  4. 04Jetpack Compose
  5. 05架构模式
  6. 06依赖注入
  7. 07本地存储
  8. 08网络层
  9. 09异步与响应式
  10. 10Firebase 生态
  11. 11测试与质量
  12. 12综合练习

测试与质量

JUnit/Espresso/Lint。

30 分钟

测试与质量

这一课你将能做到

  1. 编写 JUnit 单元测试覆盖 ViewModel 和 Repository 逻辑
  2. 使用 Espresso 和 Compose Test 编写 UI 测试
  3. 配置 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 内存泄漏,在通知栏提示。

讲清楚要点

  1. 测试金字塔:单元测试(70%)→ 集成测试(20%)→ UI 测试(10%)。单元测试快、隔离、反馈即时;UI 测试慢、脆弱、维护成本高。比例不能反。

  2. MockK 是 Kotlin 首选 Mock 框架:支持 coEvery 处理 suspend 函数,coVerify 验证调用,just Runs 表示 Unit 返回。比 Mockito 更符合 Kotlin 习惯。

  3. Compose Test 用语义树:onNodeWithText、onNodeWithContentDescription、onNodeWithTag 查找节点。assertIsDisplayed 验证可见,performClick 模拟点击。

  4. detekt 和 ktlint 互补:detekt 查「坏味道」(过长函数、复杂表达式),ktlint 查「格式」(缩进、换行)。CI 中都应该配置,阻断不合格代码合并。

  5. 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 添加

卡点急救

  1. MockK 报错「ClassCastException: class java.lang.Object cannot be cast」:检查是否 mockk() 时指定了具体类型,或泛型擦除导致。用 mockk<TrackRepository>() 显式声明。

  2. Compose Test 找不到节点:检查是否 setContent 成功;用 onRoot().printToLog("TAG") 打印语义树;确保测试设备/模拟器动画关闭(开发者选项 → 动画缩放全部设为 0)。

  3. Room 内存数据库测试失败:检查是否 allowMainThreadQueries()(测试环境可以),或改用 runTest 在协程中执行。

面试常问

  • 测试金字塔为什么单元测试最多? 单元测试执行快(毫秒级)、反馈即时、定位精确、维护成本低。UI 测试慢(秒级)、环境依赖多、易碎。金字塔越往上,越接近真实用户,但成本越高。

  • MockK 的 every 和 coEvery 区别? every 用于普通函数,coEvery 用于 suspend 函数。coVerify 验证 suspend 函数调用。用错会导致编译错误或测试失败。

  • Espresso 如何保证测试稳定性? IdlingResource 等待异步完成(网络、动画);禁用系统动画(开发者选项);避免固定 Thread.sleep;用 Barista 或 Kakao 封装简化 API。

自测三问

  1. 解释 runTest 中 advanceUntilIdle 的作用。
  2. 设计一个测试场景:验证「网络请求失败时,UI 显示错误信息并可重试」。
  3. 为什么 LeakCanary 只在 Debug 包引入?如果 Release 引入会有什么后果?

小结一句话

测试是代码的「保险单」:JUnit 保逻辑,Espresso 保交互,detekt 保规范,LeakCanary 保内存;没有测试的重构是赌博,有测试的重构是升级。

延伸

  • Android 测试官方指南
  • MockK 官方文档
  • Compose Test 指南
  • 下一课:综合练习——从零构建一个完整的 Compose 学习应用。
加载本节练习…
← 上一课Firebase 生态下一课 →综合练习