很多 Android 项目早期只靠手工点页面验证:功能少、团队小、发布节奏慢时还能勉强撑住。随着业务变复杂,手工测试会很快遇到瓶颈:回归范围越来越大,边界条件容易漏,老功能被新改动影响后很难及时发现。
自动化测试的价值不是替代所有人工测试,而是把可重复、可判断、容易回归的部分交给机器。开发每次改完代码,都能快速知道核心逻辑是否还对;CI 每次合并前,都能挡住明显破坏;测试同学也能把精力放在探索性测试、体验问题和复杂场景上。
对 Android 来说,自动化测试通常分成两类:运行在 JVM 上的本地单元测试,以及运行在设备或模拟器上的仪器测试。前者速度快,适合验证业务逻辑;后者更接近真实环境,适合验证 UI、系统组件和页面交互。
一个健康的测试结构通常像金字塔:底层是大量快速稳定的单元测试,中间是少量集成测试,顶层是更少但更贴近真实用户路径的 UI 自动化测试。
不要一上来就把所有场景都写成 UI 自动化。UI 测试启动慢、依赖设备状态、对动画和异步更敏感,维护成本也更高。更稳的策略是:能用纯 Kotlin 或 JVM 测的逻辑,就不要搬到模拟器上;只有真正需要验证界面、导航、输入、系统交互时,再写 UI 测试。
常见分层可以这样安排:
测试越靠底层越应该多,越靠 UI 越要克制。
Kotlin 项目里,常见测试依赖大致如下:
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 依赖的业务规则。例如手机号校验、金额格式化、优惠规则、状态转换,都适合写成本地单元测试。
class PriceFormatterTest {
@Test
fun format_shouldKeepTwoDecimals() {
val result = PriceFormatter.format(12.3)
assertEquals("12.30", result)
}
}这类测试速度很快,失败原因也清楚。它们能帮团队养成一个重要习惯:把业务逻辑从 Activity、Fragment、Composable 中抽出来,放到可测试的类里。
如果一个逻辑很难测试,通常说明它耦合了太多东西:Context、网络、数据库、时间、线程、全局单例。把这些依赖抽象成接口,测试就会变简单。
ViewModel 是 Android 项目里非常适合测试的一层。它承接 UI 事件,调用业务逻辑,再输出页面状态。只要把 Repository、Dispatcher、时间来源这些依赖注入进去,就可以在 JVM 上稳定验证。
@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 时,可以用 Turbine 让断言更清晰。
@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,测试时要明确取消:
viewModel.uiState.test {
assertEquals(HomeState.Empty, awaitItem())
cancelAndIgnoreRemainingEvents()
}异步测试最怕“等一下应该好了”。测试代码里尽量使用 advanceUntilIdle()、runCurrent()、虚拟时间和明确事件,不要用 Thread.sleep()。
Repository 通常依赖网络和数据库。网络部分不要访问真实服务,可以用 MockWebServer 构造可控响应。
@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 测试关注的是用户视角:文本是否出现,按钮是否可点击,输入后页面是否变化。传统 View 系统常用 Espresso。
@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 提供了更现代的测试 API,可以按文本、语义标签和节点属性查找组件。
@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:
Button(
modifier = Modifier.testTag("increaseButton"),
onClick = onIncrease
) {
Text("增加")
}不要所有节点都加标签。优先给测试需要定位、且文本可能变化的关键组件加标签,比如按钮、输入框、列表、错误提示区域。
UI 自动化最容易坏在三个地方:数据不可控、定位方式脆弱、等待逻辑不稳定。
建议遵守这些原则:
例如可以封装页面对象:
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 流程可以分成几层:
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest本地单元测试速度快,建议每次提交或合并请求都跑。UI 测试耗时更长,可以按核心用例拆成冒烟集合,在合并前运行;完整 UI 回归可以放到夜间任务或发布前任务。
CI 中还要保存测试报告,便于定位失败:
app/build/reports/tests/testDebugUnitTest/index.html
app/build/reports/androidTests/connected/index.html如果 UI 测试失败,最好保留截图、日志和设备信息。没有现场信息的失败,很容易变成“本地复现不了”。
等功能做完再补测试,往往会发现代码已经耦合严重。更好的方式是在写 ViewModel、UseCase、Repository 时顺手补测试,把测试当成设计反馈。
真实线上问题经常来自失败路径:网络超时、空列表、接口字段缺失、权限拒绝、重复点击、页面恢复。测试至少要覆盖关键失败分支。
如果每一层都 Mock,测试只是在验证 Mock 返回了 Mock。要根据目标选择边界:测 ViewModel 可以 Fake Repository;测 Repository 就尽量用 MockWebServer 验证真实解析和请求。
UI 测试不是越多越好。把大量细碎逻辑放到 UI 层测,会让测试慢且脆。UI 测试应该覆盖关键路径和真实交互,细节逻辑下沉到单元测试。
可以按这个顺序推进 Android 自动化测试:
Android 自动化测试的核心,是把质量保障从发布前挪到开发过程中。单元测试负责快速验证业务逻辑,Repository 和 ViewModel 测试负责守住状态转换和数据边界,UI 自动化测试负责确认用户关键路径真的可用。
不要追求一次性写满所有测试。先从最容易出错、最值得回归、最容易自动判断的地方开始。只要测试能稳定运行、失败能提供明确反馈,它就会慢慢成为团队交付质量的一部分。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。