首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >[Android 从零到一] 单元测试与 UI 自动化测试:让 Android 质量更可控

[Android 从零到一] 单元测试与 UI 自动化测试:让 Android 质量更可控

原创
作者头像
hunter android
发布2026-07-09 10:16:45
发布2026-07-09 10:16:45
250
举报

单元测试与 UI 自动化测试:让 Android 质量更可控

为什么 Android 项目需要自动化测试

很多 Android 项目早期只靠手工点页面验证:功能少、团队小、发布节奏慢时还能勉强撑住。随着业务变复杂,手工测试会很快遇到瓶颈:回归范围越来越大,边界条件容易漏,老功能被新改动影响后很难及时发现。

自动化测试的价值不是替代所有人工测试,而是把可重复、可判断、容易回归的部分交给机器。开发每次改完代码,都能快速知道核心逻辑是否还对;CI 每次合并前,都能挡住明显破坏;测试同学也能把精力放在探索性测试、体验问题和复杂场景上。

对 Android 来说,自动化测试通常分成两类:运行在 JVM 上的本地单元测试,以及运行在设备或模拟器上的仪器测试。前者速度快,适合验证业务逻辑;后者更接近真实环境,适合验证 UI、系统组件和页面交互。

先理解测试金字塔

一个健康的测试结构通常像金字塔:底层是大量快速稳定的单元测试,中间是少量集成测试,顶层是更少但更贴近真实用户路径的 UI 自动化测试。

不要一上来就把所有场景都写成 UI 自动化。UI 测试启动慢、依赖设备状态、对动画和异步更敏感,维护成本也更高。更稳的策略是:能用纯 Kotlin 或 JVM 测的逻辑,就不要搬到模拟器上;只有真正需要验证界面、导航、输入、系统交互时,再写 UI 测试。

常见分层可以这样安排:

  • 工具函数、格式化、校验规则:本地单元测试。
  • Repository、UseCase、ViewModel:本地单元测试为主,必要时加少量集成测试。
  • 数据库、网络 Mock、序列化:本地测试或仪器测试按依赖选择。
  • 页面显示、点击、输入、列表滚动、导航:UI 自动化测试。
  • 登录、支付、下单等核心链路:少量端到端冒烟测试。

测试越靠底层越应该多,越靠 UI 越要克制。

配置测试依赖

Kotlin 项目里,常见测试依赖大致如下:

代码语言:javascript
复制
dependencies {
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
    testImplementation("app.cash.turbine:turbine:1.1.0")
    testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
    testImplementation("io.mockk:mockk:1.13.13")

    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

依赖版本以项目当前 Android Gradle Plugin、Kotlin 和 Compose BOM 为准。测试依赖也要集中管理,避免不同模块使用不同版本导致行为不一致。

单元测试:先测纯逻辑

最容易开始的是纯函数和无 Android 依赖的业务规则。例如手机号校验、金额格式化、优惠规则、状态转换,都适合写成本地单元测试。

代码语言:javascript
复制
class PriceFormatterTest {
    @Test
    fun format_shouldKeepTwoDecimals() {
        val result = PriceFormatter.format(12.3)

        assertEquals("12.30", result)
    }
}

这类测试速度很快,失败原因也清楚。它们能帮团队养成一个重要习惯:把业务逻辑从 Activity、Fragment、Composable 中抽出来,放到可测试的类里。

如果一个逻辑很难测试,通常说明它耦合了太多东西:Context、网络、数据库、时间、线程、全局单例。把这些依赖抽象成接口,测试就会变简单。

测试 ViewModel

ViewModel 是 Android 项目里非常适合测试的一层。它承接 UI 事件,调用业务逻辑,再输出页面状态。只要把 Repository、Dispatcher、时间来源这些依赖注入进去,就可以在 JVM 上稳定验证。

代码语言:javascript
复制
@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {
    private val dispatcher = StandardTestDispatcher()

    @Before
    fun setUp() {
        Dispatchers.setMain(dispatcher)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun loginSuccess_shouldShowUserName() = runTest {
        val repo = FakeLoginRepository(result = Result.success(User("Lulu")))
        val viewModel = LoginViewModel(repo)

        viewModel.login("account", "password")
        advanceUntilIdle()

        assertEquals("Lulu", viewModel.uiState.value.userName)
        assertFalse(viewModel.uiState.value.loading)
    }
}

这里的重点是控制协程调度器。不要让测试依赖真实线程和真实时间,否则测试会变慢且不稳定。

测试 Flow

Flow 常用于状态流、分页加载、搜索联想和事件流。测试 Flow 时,可以用 Turbine 让断言更清晰。

代码语言:javascript
复制
@Test
fun search_shouldEmitLoadingAndResult() = runTest {
    val repo = FakeSearchRepository(listOf("Android", "Kotlin"))
    val useCase = SearchUseCase(repo)

    useCase.search("an").test {
        assertEquals(SearchState.Loading, awaitItem())
        assertEquals(SearchState.Success(listOf("Android", "Kotlin")), awaitItem())
        awaitComplete()
    }
}

如果 Flow 不会结束,比如 StateFlow,测试时要明确取消:

代码语言:javascript
复制
viewModel.uiState.test {
    assertEquals(HomeState.Empty, awaitItem())
    cancelAndIgnoreRemainingEvents()
}

异步测试最怕“等一下应该好了”。测试代码里尽量使用 advanceUntilIdle()runCurrent()、虚拟时间和明确事件,不要用 Thread.sleep()

测试 Repository 与网络

Repository 通常依赖网络和数据库。网络部分不要访问真实服务,可以用 MockWebServer 构造可控响应。

代码语言:javascript
复制
@Test
fun getProfile_shouldParseResponse() = runTest {
    val server = MockWebServer()
    server.enqueue(MockResponse().setBody("""
        {"id":"u1","name":"Lulu"}
    """.trimIndent()))
    server.start()

    val api = Retrofit.Builder()
        .baseUrl(server.url("/"))
        .addConverterFactory(MoshiConverterFactory.create())
        .build()
        .create(UserApi::class.java)

    val repo = UserRepository(api)
    val profile = repo.getProfile("u1")

    assertEquals("Lulu", profile.name)
    assertEquals("/users/u1", server.takeRequest().path)

    server.shutdown()
}

这类测试能验证请求路径、参数、响应解析和错误处理,比单纯 Mock API 更接近真实网络层,同时又不依赖外部环境。

UI 自动化测试:验证用户能看到和操作什么

UI 测试关注的是用户视角:文本是否出现,按钮是否可点击,输入后页面是否变化。传统 View 系统常用 Espresso。

代码语言:javascript
复制
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
    @get:Rule
    val rule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun loginButton_shouldOpenHome() {
        onView(withId(R.id.accountInput)).perform(typeText("lulu"), closeSoftKeyboard())
        onView(withId(R.id.passwordInput)).perform(typeText("123456"), closeSoftKeyboard())
        onView(withId(R.id.loginButton)).perform(click())

        onView(withText("首页")).check(matches(isDisplayed()))
    }
}

Espresso 会等待主线程空闲,但对网络、动画、自定义异步任务不一定完全感知。复杂场景要用 IdlingResource、Mock 数据或关闭非必要动画来提升稳定性。

Compose UI Test

Compose 提供了更现代的测试 API,可以按文本、语义标签和节点属性查找组件。

代码语言:javascript
复制
@get:Rule
val composeRule = createComposeRule()

@Test
fun counter_shouldIncreaseAfterClick() {
    composeRule.setContent {
        CounterScreen()
    }

    composeRule.onNodeWithText("0").assertExists()
    composeRule.onNodeWithTag("increaseButton").performClick()
    composeRule.onNodeWithText("1").assertExists()
}

为了让测试稳定,建议给关键节点加 testTag

代码语言:javascript
复制
Button(
    modifier = Modifier.testTag("increaseButton"),
    onClick = onIncrease
) {
    Text("增加")
}

不要所有节点都加标签。优先给测试需要定位、且文本可能变化的关键组件加标签,比如按钮、输入框、列表、错误提示区域。

让 UI 测试可维护

UI 自动化最容易坏在三个地方:数据不可控、定位方式脆弱、等待逻辑不稳定。

建议遵守这些原则:

  • 测试环境使用固定 Mock 数据,不依赖真实线上接口。
  • 避免按层级路径查找节点,优先使用文本、ID、语义或 testTag。
  • 关闭系统动画,减少因为动画造成的时序问题。
  • 每个测试只验证一个核心行为,不要把长流程全部堆在一个方法里。
  • 把登录、跳转、输入等重复动作封装成测试辅助方法。

例如可以封装页面对象:

代码语言:javascript
复制
class LoginRobot {
    fun inputAccount(value: String) = apply {
        onView(withId(R.id.accountInput)).perform(replaceText(value))
    }

    fun inputPassword(value: String) = apply {
        onView(withId(R.id.passwordInput)).perform(replaceText(value))
    }

    fun clickLogin() = apply {
        onView(withId(R.id.loginButton)).perform(click())
    }
}

这样 UI 结构调整时,只需要改少量定位代码,测试用例本身仍然表达业务行为。

接入 CI

自动化测试只有在持续运行时才有价值。常见 CI 流程可以分成几层:

代码语言:javascript
复制
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest

本地单元测试速度快,建议每次提交或合并请求都跑。UI 测试耗时更长,可以按核心用例拆成冒烟集合,在合并前运行;完整 UI 回归可以放到夜间任务或发布前任务。

CI 中还要保存测试报告,便于定位失败:

代码语言:javascript
复制
app/build/reports/tests/testDebugUnitTest/index.html
app/build/reports/androidTests/connected/index.html

如果 UI 测试失败,最好保留截图、日志和设备信息。没有现场信息的失败,很容易变成“本地复现不了”。

常见问题

测试写得太晚

等功能做完再补测试,往往会发现代码已经耦合严重。更好的方式是在写 ViewModel、UseCase、Repository 时顺手补测试,把测试当成设计反馈。

只测成功路径

真实线上问题经常来自失败路径:网络超时、空列表、接口字段缺失、权限拒绝、重复点击、页面恢复。测试至少要覆盖关键失败分支。

Mock 过度导致测试没有意义

如果每一层都 Mock,测试只是在验证 Mock 返回了 Mock。要根据目标选择边界:测 ViewModel 可以 Fake Repository;测 Repository 就尽量用 MockWebServer 验证真实解析和请求。

UI 测试数量失控

UI 测试不是越多越好。把大量细碎逻辑放到 UI 层测,会让测试慢且脆。UI 测试应该覆盖关键路径和真实交互,细节逻辑下沉到单元测试。

落地建议

可以按这个顺序推进 Android 自动化测试:

  1. 从纯业务逻辑和工具类开始写本地单元测试。
  2. 给 ViewModel、UseCase、Repository 建立测试样板。
  3. 用协程测试工具控制 Dispatcher 和虚拟时间。
  4. 用 MockWebServer 覆盖核心接口解析和错误处理。
  5. 给关键页面补少量 UI 冒烟测试。
  6. 把本地单元测试接入合并流程。
  7. 把核心 UI 测试接入发布前或夜间任务。
  8. 持续清理不稳定测试,失败必须有人处理。

总结

Android 自动化测试的核心,是把质量保障从发布前挪到开发过程中。单元测试负责快速验证业务逻辑,Repository 和 ViewModel 测试负责守住状态转换和数据边界,UI 自动化测试负责确认用户关键路径真的可用。

不要追求一次性写满所有测试。先从最容易出错、最值得回归、最容易自动判断的地方开始。只要测试能稳定运行、失败能提供明确反馈,它就会慢慢成为团队交付质量的一部分。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 单元测试与 UI 自动化测试:让 Android 质量更可控
    • 为什么 Android 项目需要自动化测试
    • 先理解测试金字塔
    • 配置测试依赖
    • 单元测试:先测纯逻辑
    • 测试 ViewModel
    • 测试 Flow
    • 测试 Repository 与网络
    • UI 自动化测试:验证用户能看到和操作什么
    • Compose UI Test
    • 让 UI 测试可维护
    • 接入 CI
    • 常见问题
      • 测试写得太晚
      • 只测成功路径
      • Mock 过度导致测试没有意义
      • UI 测试数量失控
    • 落地建议
    • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档