
1. 项目概述做Android开发这些年我见过太多项目在后期被测试拖垮。功能越堆越多回归测试动辄几个小时开发不敢改代码生怕动一处而崩全局。直到团队开始重视并系统化地实施单元测试与UI测试整个开发节奏才从“救火”模式转向了“预防”模式。今天我就结合自己踩过的坑和积累的经验为你拆解一份Android测试的实战全指南。这不仅仅是关于JUnit和Espresso的API调用更是关于如何构建一个高效、可靠且可持续的测试体系让你的应用质量从“能用”提升到“可靠”。无论你是刚接触测试的新手还是想优化现有测试套件的老手这篇文章都将从最基础的单元测试搭建讲到复杂的UI自动化测试框架选型与实战并分享那些官方文档里不会写的调试技巧和架构设计心得。我们的目标很明确写出来的测试不仅要能跑通更要能真正守护代码质量成为开发过程中值得信赖的安全网。2. 测试策略与框架选型构建你的测试金字塔在动手写第一行测试代码之前我们必须先想清楚测试什么、怎么测、以及用什么工具来测。盲目地堆砌测试用例只会带来维护噩梦。一个清晰的测试策略通常可以用经典的“测试金字塔”来指导。2.1 理解Android测试的层次结构测试金字塔的核心思想是越底层的测试应该写得越多、运行得越快、成本越低越顶层的测试则写得越少、运行越慢、但更贴近用户真实场景。对于Android应用我们可以将其分为三层单元测试底层数量最多针对最小的代码单元通常是单个类或方法进行测试。它运行在本地JVM上速度极快毫秒级。目标是验证业务逻辑的正确性。例如测试一个计算器类的add方法是否返回正确结果或者测试一个数据转换函数是否按预期格式化字符串。集成测试中层测试多个单元模块之间的协作是否正常。在Android中这常常指涉及Android框架组件如Activity、Fragment、ViewModel的测试。它们可能需要运行在模拟器或真机上速度中等。例如测试ViewModel是否正确地将用户输入传递给Repository并更新了LiveData。UI测试顶层数量最少模拟用户操作从界面层面验证整个应用流程。它完全运行在Android设备上速度最慢。例如测试用户点击登录按钮后是否能成功跳转到主页。很多团队犯的错误是“金字塔倒置”即写了大量沉重、脆弱的UI测试却忽略了快速、稳定的单元测试。结果就是测试套件运行缓慢且界面稍有改动就导致大量测试失败最终测试代码被废弃。我们的策略应该是用单元测试覆盖所有核心业务逻辑和算法用少量集成测试验证关键组件间的交互用更少的UI测试来保障核心用户流程的畅通。2.2 核心框架详解与选型建议明确了层次我们来看看每个层次的主力框架。单元测试层JUnit 4 Mockito/MockKJUnit 4这是Java/Kotlin世界单元测试的事实标准。它提供了Test注解来标记测试方法以及Assert类来进行断言如assertEquals,assertTrue。虽然JUnit 5功能更强大但目前Android Gradle插件对JUnit 5的支持仍需额外配置JUnit 4因其开箱即用的稳定性仍是首选。Mockito / MockK单元测试要求“隔离”即只测试当前类它的依赖如网络层、数据库、系统服务需要被“模拟”或“打桩”。Mockito是Java领域最流行的模拟框架而MockK则是为Kotlin量身定做的提供了更符合Kotlin习惯的DSL领域特定语言。如果你的项目是纯Kotlin我强烈推荐MockK它在处理协程、扩展函数等方面比Mockito更自然。Robolectric这是一个特殊的角色。当你的代码直接依赖Android SDK中的类如Context,Resources,TextView时纯JVM单元测试就无法运行了。Robolectric通过实现一套“影子”Shadow对象在本地JVM上模拟了Android运行环境让你能在单元测试中调用这些Android API。它比启动模拟器快得多但比纯逻辑单元测试慢。我的经验是除非必要否则通过良好的架构如依赖注入避免在业务逻辑中直接依赖Android框架从而减少对Robolectric的依赖。UI测试层Espresso UI AutomatorEspressoGoogle官方推荐的UI测试框架用于测试单个应用内的界面交互。它的核心特点是“同步”会自动等待UI线程空闲和异步任务完成后再执行下一个操作避免了在测试代码中写Thread.sleep。Espresso的API非常简洁例如onView(withId(R.id.button)).perform(click())。UI Automator当你的测试需要跨应用操作例如测试应用跳转到系统设置或与通知栏交互或者需要在不了解应用内部视图结构的情况下进行测试时UI Automator是更好的选择。它通过Android的辅助功能服务来定位和操作屏幕上的元素。测试运行与扩展AndroidJUnitRunner AndroidX TestAndroidJUnitRunner这是运行插桩测试需要在Android设备上运行的测试包括集成测试和UI测试的测试运行器。它处理了测试包的安装、启动、执行和结果报告。AndroidX Test这是一个统一的测试API库它提供了一套不依赖于运行环境的API。也就是说同一套测试代码既可以配置为使用Robolectric在本地JVM上运行作为单元测试也可以配置为使用AndroidJUnitRunner在设备上运行作为集成测试。这极大地提高了测试代码的复用性。选型心法 对于新项目我的基础推荐组合是JUnit 4 MockK (用于纯逻辑单元测试) Espresso (用于UI测试)。随着项目复杂再按需引入Robolectric用于包含Android框架的单元测试或UI Automator用于跨应用测试。记住工具是为人服务的选择最适合你当前团队和项目阶段的而不是最“先进”的。3. 单元测试实战从零搭建可测试的代码结构理论说再多不如动手写一遍。让我们从一个具体的例子开始看看如何为一个简单的功能编写单元测试更重要的是如何设计易于测试的代码。3.1 示例一个用户输入验证器的测试假设我们有一个用于注册功能的EmailValidator类它有一个静态方法isValidEmail。// 生产代码EmailValidator.kt object EmailValidator { fun isValidEmail(email: String): Boolean { // 简单的正则验证实际项目请使用更严谨的规则 val emailRegex ^[A-Za-z0-9_.-][A-Za-z0-9.-]\$ return email.matches(emailRegex.toRegex()) } }为它编写单元测试// 测试代码EmailValidatorTest.kt // 位置app/src/test/java/com/yourpackage/EmailValidatorTest.kt import org.junit.Test import org.junit.Assert.* // 使用静态导入使断言更简洁 class EmailValidatorTest { Test fun valid email with simple format should return true() { // Given - 准备测试数据 val validEmail testexample.com // When - 执行被测方法 val result EmailValidator.isValidEmail(validEmail) // Then - 验证结果 assertTrue(有效的邮箱地址应返回true, result) } Test fun email without at symbol should return false() { // Given val invalidEmail testexample.com // When val result EmailValidator.isValidEmail(invalidEmail) // Then assertFalse(缺少符号的邮箱地址应返回false, result) } Test fun empty email should return false() { // Given val emptyEmail // When val result EmailValidator.isValidEmail(emptyEmail) // Then assertFalse(空字符串应返回false, result) } Test fun email with spaces should return false() { // Given val emailWithSpaces test example.com // When val result EmailValidator.isValidEmail(emailWithSpaces) // Then assertFalse(包含空格的邮箱地址应返回false, result) } }实操要点测试命名我使用了Kotlin的反引号支持让测试函数名读起来像一个句子valid email with simple format should return true这比emailValidator_CorrectEmailSimple_ReturnsTrue这样的命名更清晰测试报告也一目了然。Given-When-Then模式这是一种结构化的测试编写模式有助于理清测试逻辑。虽然不是强制但强烈推荐。断言信息在assertTrue或assertFalse中传入第二个参数错误信息当测试失败时能快速定位问题。测试位置记住纯单元测试不依赖Android放在src/test目录下Gradle会使用本地JVM来运行它们速度飞快。3.2 使用MockK模拟依赖测试ViewModel现实中的代码很少像EmailValidator这样独立。更多时候我们需要测试像ViewModel这样依赖Repository或UseCase的类。这时模拟框架就派上用场了。假设我们有一个简单的登录场景// 生产代码 interface AuthRepository { suspend fun login(username: String, password: String): ResultBoolean } class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() { private val _loginState MutableStateFlowLoginState(LoginState.Idle) val loginState: StateFlowLoginState _loginState fun onLoginClicked(username: String, password: String) { viewModelScope.launch { _loginState.value LoginState.Loading _loginState.value when (val result authRepository.login(username, password)) { is Result.Success - LoginState.Success is Result.Error - LoginState.Error(result.message) } } } } sealed class LoginState { object Idle : LoginState() object Loading : LoginState() object Success : LoginState() data class Error(val message: String) : LoginState() }现在我们为LoginViewModel编写单元测试并模拟AuthRepository// 测试代码LoginViewModelTest.kt // 位置app/src/test/java/com/yourpackage/LoginViewModelTest.kt import io.mockk.coEvery import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test OptIn(ExperimentalCoroutinesApi::class) class LoginViewModelTest { // 使用一个测试用的CoroutineDispatcher以控制协程的执行 private val testDispatcher StandardTestDispatcher() // 创建模拟对象 private val mockAuthRepository: AuthRepository mockk() // 在测试中创建ViewModel注入模拟的依赖 private lateinit var viewModel: LoginViewModel Test fun login with valid credentials should update state to Success() runTest(testDispatcher) { // Given - 准备模拟行为 val testUsername user val testPassword pass coEvery { mockAuthRepository.login(testUsername, testPassword) } returns Result.Success(true) viewModel LoginViewModel(mockAuthRepository) // 初始状态应为Idle assertEquals(LoginState.Idle, viewModel.loginState.value) // When - 触发登录操作 viewModel.onLoginClicked(testUsername, testPassword) // 推进协程确保所有挂起函数执行完毕 advanceUntilIdle() // Then - 验证状态变化和Repository调用 assertEquals(LoginState.Success, viewModel.loginState.value) verify { mockAuthRepository.login(testUsername, testPassword) } } Test fun login with invalid credentials should update state to Error() runTest(testDispatcher) { // Given val testUsername user val testPassword wrong val errorMessage Invalid credentials coEvery { mockAuthRepository.login(testUsername, testPassword) } returns Result.Error(errorMessage) viewModel LoginViewModel(mockAuthRepository) // When viewModel.onLoginClicked(testUsername, testPassword) advanceUntilIdle() // Then val currentState viewModel.loginState.value assert(currentState is LoginState.Error) assertEquals(errorMessage, (currentState as LoginState.Error).message) verify { mockAuthRepository.login(testUsername, testPassword) } } Test fun login should set state to Loading before making the call() runTest(testDispatcher) { // Given - 模拟一个长时间挂起的Repository调用以便我们观察中间状态 val testUsername user val testPassword pass coEvery { mockAuthRepository.login(testUsername, testPassword) } coAnswers { // 这里不立即返回让我们有机会检查Loading状态 kotlinx.coroutines.delay(100) // 模拟网络延迟 Result.Success(true) } viewModel LoginViewModel(mockAuthRepository) val states mutableListOfLoginState() // 收集状态变化 val job viewModel.loginState.onEach { states.add(it) }.launchIn(this) // When viewModel.onLoginClicked(testUsername, testPassword) // 不调用 advanceUntilIdle而是手动推进一点时间让协程启动但未完成 testDispatcher.scheduler.advanceTimeBy(50) // Then - 此时状态应为Loading assertEquals(LoginState.Loading, viewModel.loginState.value) assert(states.contains(LoginState.Loading)) // 清理 job.cancel() } }核心技巧与避坑指南协程测试ViewModel中大量使用协程测试时必须使用kotlinx-coroutines-test库。runTest是新的推荐方式它替换了旧的runBlockingTest。StandardTestDispatcher和advanceUntilIdle()让我们能精确控制协程的执行时机这是测试异步逻辑的关键。MockK DSLcoEvery用于模拟挂起函数suspend function的行为。verify块用于验证模拟对象的方法是否被以预期的参数调用。MockK的语法非常直观。状态验证对于StateFlow或LiveData我们通过收集其值的变化来验证业务逻辑。注意在测试结束后取消收集作业避免内存泄漏或影响其他测试。依赖注入请注意LoginViewModel的构造函数接收一个AuthRepository参数。这称为“构造函数注入”是使代码可测试的最重要设计模式之一。它允许我们在测试中轻松地传入一个模拟对象在生产中传入真实的实现如网络请求。如果你在使用Hilt或Koin等依赖注入框架这一过程会更加自动化。注意模拟Mock虽好但不要滥用。过度模拟会导致测试与实现细节耦合过紧一旦内部逻辑改变即使外部行为不变测试也会失败。一个原则是只模拟外部依赖如网络、数据库、系统服务不要模拟你正在测试的模块内部的协作类如果它们属于同一个逻辑单元。4. UI自动化测试实战用Espresso模拟真实用户操作单元测试保证了业务逻辑的坚固UI测试则确保了用户界面的流畅。Espresso是Android UI测试的利器但写出稳定、可维护的UI测试是一门艺术。4.1 Espresso核心三要素ViewMatchers, ViewActions, ViewAssertionsEspresso的API围绕三个核心概念构建它们像乐高积木一样可以组合ViewMatchers用于在屏幕上找到你想要操作的视图。最常用的是withId(R.id.view_id)还有withText(),withClassName()等。ViewActions用于对找到的视图执行操作。如click(),typeText(),scrollTo(),pressBack()。ViewAssertions用于对视图的当前状态做出断言。如matches(isDisplayed()),matches(withText(“expected text”))。一个典型的Espresso测试语句如下onView(withId(R.id.button_login)) // ViewMatcher: 找到登录按钮 .perform(click()) // ViewAction: 执行点击操作 onView(withId(R.id.text_greeting)) // ViewMatcher: 找到欢迎文本 .check(matches(withText(“Hello, User!”))) // ViewAssertion: 检查文本内容4.2 编写一个完整的登录流程UI测试让我们为一个简单的登录界面编写UI测试。假设界面有两个EditText用户名和密码和一个登录按钮登录成功后会跳转到主页并显示用户名。首先需要在app/build.gradle.kts中添加依赖androidTestImplementation(androidx.test.espresso:espresso-core:3.5.1) androidTestImplementation(androidx.test:runner:1.5.2) androidTestImplementation(androidx.test:rules:1.5.0) // 如果需要测试RecyclerView还需要espresso-contrib然后编写测试类// 测试代码LoginActivityTest.kt // 位置app/src/androidTest/java/com/yourpackage/LoginActivityTest.kt import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.* import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith RunWith(AndroidJUnit4::class) class LoginActivityTest { // 这条规则会在每个测试方法前启动指定的Activity并在测试后关闭它 get:Rule val activityRule ActivityScenarioRule(LoginActivity::class.java) Test fun login with correct credentials should navigate to home() { // 1. 输入用户名和密码 onView(withId(R.id.edit_text_username)) .perform(typeText(testuser), closeSoftKeyboard()) // closeSoftKeyboard很重要 onView(withId(R.id.edit_text_password)) .perform(typeText(password123), closeSoftKeyboard()) // 2. 点击登录按钮 onView(withId(R.id.button_login)) .perform(click()) // 3. 验证是否成功跳转到主页并且主页显示了用户名 // 假设主页的TextView的id是text_welcome且其文本包含用户名 onView(withId(R.id.text_welcome)) .check(matches(isDisplayed())) // 断言视图已显示 .check(matches(withText(containsString(testuser)))) // 断言文本包含用户名 } Test fun login with empty credentials should show error() { // 直接点击登录按钮 onView(withId(R.id.button_login)) .perform(click()) // 验证错误提示是否显示假设有一个Snackbar或TextView显示错误 // 错误提示的文本可能在一个Snackbar中我们可以用withText来匹配 onView(withText(R.string.error_empty_credentials)) // 匹配字符串资源 .check(matches(isDisplayed())) } Test fun pressing back on login screen should finish activity() { // 启动Activity val scenario ActivityScenario.launch(LoginActivity::class.java) // 按下返回键 onView(isRoot()).perform(pressBack()) // isRoot()匹配根视图 // 验证Activity是否已结束 scenario.onActivity { activity - assert(activity.isFinishing) } } }4.3 处理异步操作与Idling Resource现代App充满了网络请求、数据库查询等异步操作。Espresso默认会等待UI线程空闲但如果你使用了自定义的线程池或回调Espresso可能无法感知这些后台任务导致在任务完成前就执行断言而失败。解决方案1使用Espresso Idling Resource这是官方推荐的方式。你需要实现一个IdlingResource在异步操作开始时将其注册为“忙碌”操作完成后标记为“空闲”。Espresso会等待所有注册的IdlingResource都空闲后才继续执行。// 一个简单的CountingIdlingResource示例 object EspressoIdlingResource { private const val RESOURCE GLOBAL val countingIdlingResource CountingIdlingResource(RESOURCE) fun increment() { countingIdlingResource.increment() } fun decrement() { if (!countingIdlingResource.isIdleNow) { countingIdlingResource.decrement() } } } // 在你的网络请求库或Repository中使用 class MyRepository { fun fetchData(callback: (Data) - Unit) { EspressoIdlingResource.increment() // 开始忙碌 someAsyncOperation { callback(it) EspressoIdlingResource.decrement() // 结束忙碌 } } } // 在测试中注册这个资源 Before fun registerIdlingResource() { IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource) } After fun unregisterIdlingResource() { IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource) }解决方案2更优雅的方式 - 使用状态驱动的UI实际上更根本的解决方案是采用像MVVM这样的架构配合LiveData或StateFlow。UI只观察状态的变化。在测试中你可以使用IdlingRegistry但更推荐的做法是在测试环境中用同步的实现替换掉异步的依赖。例如在UI测试的构建变体中提供一个使用内存数据库和模拟网络响应的Repository实现。这样测试就完全变成了同步的无需处理复杂的等待逻辑。这涉及到更高级的测试架构设计如依赖注入的不同配置。4.4 UI测试的稳定性与维护性技巧使用唯一的资源ID确保每个需要操作的视图都有唯一且稳定的android:id。避免使用android:tag或通过文本定位除非必要因为文本可能变化或国际化。创建自定义的ViewMatcher和ViewAction对于复杂的查找逻辑例如在RecyclerView的特定位置找到某项将其封装成自定义的ViewMatcher提高代码复用性和可读性。页面对象Page Object模式将每个屏幕或Fragment封装成一个“页面对象”其中包含定位元素和执行操作的方法。这样测试用例读起来就像在描述用户故事且界面元素定位逻辑只在一处维护。class LoginPage { fun enterUsername(name: String) { onView(withId(R.id.edit_text_username)).perform(typeText(name)) } fun enterPassword(pass: String) { ... } fun clickLogin() { ... } fun checkWelcomeMessage(name: String) { ... } } // 在测试中使用 Test fun loginTest() { LoginPage().apply { enterUsername(user) enterPassword(pass) clickLogin() checkWelcomeMessage(user) } }测试数据隔离UI测试可能会创建或修改数据。确保每个测试都是独立的使用Before来准备测试数据如清空数据库、注册测试用户使用After来清理现场。可以考虑使用专门的测试后端或Mock Server。5. 测试配置、执行与持续集成写好测试只是第一步如何高效地运行它们并将其集成到开发流程中才是发挥测试价值的關鍵。5.1 Gradle配置与测试变体在模块的build.gradle.kts文件中正确配置测试依赖至关重要android { // ... defaultConfig { // 为插桩测试指定一个测试运行器 testInstrumentationRunner androidx.test.runner.AndroidJUnitRunner } // 配置测试选项 testOptions { // 对于单元测试可以配置模拟Android库的行为谨慎使用 unitTests.isReturnDefaultValues true // 当调用未模拟的Android方法时返回null/0而不是抛异常 unitTests.all { // 配置所有单元测试使用JUnit 4 it.useJUnit() // 可以设置堆内存大小等 it.jvmArgs(-Xmx2g) } } } dependencies { // 本地单元测试依赖 (src/test) testImplementation(junit:junit:4.13.2) testImplementation(org.mockito.kotlin:mockito-kotlin:5.1.0) // 如果用Mockito testImplementation(io.mockk:mockk:1.13.8) // 如果用MockK testImplementation(org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3) // 协程测试 testImplementation(androidx.arch.core:core-testing:2.2.0) // 测试Architecture Components // 使用Robolectric测试Android依赖的单元测试 testImplementation(org.robolectric:robolectric:4.11.1) // 插桩测试依赖 (src/androidTest) androidTestImplementation(androidx.test.ext:junit:1.1.5) androidTestImplementation(androidx.test.espresso:espresso-core:3.5.1) androidTestImplementation(androidx.test:runner:1.5.2) androidTestImplementation(androidx.test:rules:1.5.0) // 如果需要测试Fragment androidTestImplementation(androidx.fragment:fragment-testing:1.6.2) }构建变体Build Variants为了在UI测试中注入模拟依赖你可以创建专门的androidTest源码集并为其提供不同的依赖实现。或者更常见的做法是使用依赖注入框架如Hilt的测试模块在测试时替换掉网络模块等。5.2 在Android Studio中运行与调试测试运行单个测试在测试类或测试方法旁边点击绿色的运行图标。运行所有测试右键点击src/test或src/androidTest目录选择“Run Tests”。调试测试和调试普通应用一样点击调试图标。你可以在测试代码中设置断点这对于排查复杂的测试失败原因非常有用。查看测试报告运行后在底部的“Run”工具窗口可以看到通过/失败的测试列表。点击失败的测试可以查看详细的堆栈跟踪和错误信息。5.3 通过命令行执行测试在CI/CD流水线中我们通常通过命令行执行测试。运行所有单元测试./gradlew test这会运行src/test下的所有测试。报告会生成在build/reports/tests/目录下。运行特定变体的单元测试./gradlew testDebugUnitTest运行所有插桩测试需要连接设备或启动模拟器./gradlew connectedDebugAndroidTest运行特定包或类的测试可以使用--tests参数。./gradlew testDebugUnitTest --tests *.LoginViewModelTest ./gradlew connectedDebugAndroidTest --tests *.LoginActivityTest5.4 集成到持续集成CI流水线一个基本的Android CI流水线例如使用GitHub Actions, GitLab CI, Jenkins通常包含以下步骤拉取代码。设置环境安装JDK、Android SDK、接受许可协议。构建项目./gradlew assembleDebug assembleDebugAndroidTest。运行单元测试./gradlew testDebugUnitTest。如果失败流水线应标记为失败。启动模拟器在CI服务器上启动一个Android模拟器实例。可以使用android-emulator-runnerGitHub Actions或Docker镜像。运行插桩测试./gradlew connectedDebugAndroidTest。收集测试报告将build/reports/目录下的测试报告通常是HTML格式保存为流水线产物便于查看。生成测试覆盖率报告可选添加Jacoco插件运行./gradlew jacocoTestReport并收集覆盖率报告。CI中的稳定性挑战UI测试在CI上可能因为模拟器性能、动画、网络波动而不稳定。对策包括禁用动画在测试前执行adb shell settings put global window_animation_scale 0等命令、增加重试机制、将大型测试套件分片sharding到多个设备并行运行。6. 高级主题与最佳实践当你的测试套件逐渐庞大你会遇到新的挑战。下面是一些进阶话题和让测试保持健康的最佳实践。6.1 测试覆盖率与报告测试覆盖率是一个衡量测试完整性的指标但请注意高覆盖率不等于高质量测试。使用JaCoCo插件可以生成报告。在项目根build.gradle.ktsplugins { id(jacoco) }在模块build.gradle.ktsandroid { buildTypes { getByName(debug) { isTestCoverageEnabled true } } } tasks.registerJacocoReport(jacocoTestReport) { dependsOn(testDebugUnitTest, createDebugCoverageReport) reports { xml.required.set(true) html.required.set(true) } val fileFilter listOf(... // 可以排除自动生成的类、Android框架类等) val javaClasses ... // 配置类文件目录 val kotlinClasses ... // 配置Kotlin类文件目录 val sourceDirectories ... // 配置源码目录 classDirectories.setFrom(files(javaClasses, kotlinClasses).map { ... }) sourceDirectories.setFrom(files(sourceDirectories)) executionData.setFrom(fileTree(project.rootDir.absolutePath).include(...)) }运行./gradlew jacocoTestReport后可以在build/reports/jacoco/下查看HTML报告直观地看到哪些行、分支被测试覆盖了。6.2 测试命名与组织规范良好的命名和组织能让测试套件易于理解和维护。类名被测类名 Test如LoginViewModelTest。方法名应该描述测试场景和预期结果。推荐使用should或when句式。例如login_withValidCredentials_shouldNavigateToHome(传统)shouldNavigateToHomeWhenLoginWithValidCredentials(BDD风格)login with valid credentials should navigate to home(Kotlin反引号我最推荐)包结构按功能模块组织测试类与生产代码的包结构保持一致。使用Before和After将通用的准备和清理代码放在用Before如setUp和After如tearDown注解的方法中。但要注意每个测试方法都会运行它们不要在里面做太耗时的操作。6.3 测试替身Test Doubles的深入理解除了Mock还有其他类型的测试替身Dummy仅用于填充参数从不被使用。Stub提供预设的固定回答。Spy记录其被调用的信息可用于验证交互同时保留部分真实行为。Fake一个轻量级的、可工作的实现用于测试。例如一个基于内存的FakeUserRepository替代真实的基于网络的UserRepository。何时用什么当你需要验证对象间的交互如方法A是否以参数B调用了方法C时用Mock。当你需要为被测对象提供数据时用Stub。当你需要一个简单、快速、可控的依赖实现来让测试运行起来时用Fake。Fake通常比Mock更稳定因为它不依赖于具体的调用顺序和参数。6.4 避免测试的常见反模式测试实现细节而非行为不要测试私有方法也不要断言一个方法内部调用了另一个具体的方法。测试应该关注“输入什么输出什么”而不是“怎么实现的”。否则一旦重构内部实现即使外部行为不变测试也会失败。过度指定Over-specification在Mock时不要对非关键的交互进行验证。例如一个方法内部可能记录了日志测试不需要验证日志方法是否被调用除非记录日志本身就是核心需求。脆弱测试Brittle TestsUI测试尤其容易因界面微调如ID改变、布局变化而失败。通过使用稳定的ID、页面对象模式和避免依赖绝对坐标来缓解。慢速测试单元测试应该极快。如果测试套件需要几分钟才能跑完开发人员就不会频繁运行它。确保单元测试不启动Android组件不进行I/O操作。将慢速测试如涉及数据库或网络的集成测试与快速单元测试分开。6.5 测试驱动开发TDD的简要实践TDD是一种“测试先行”的开发方法先写一个失败的测试然后写最简单的代码使其通过最后重构代码。对于Android开发在ViewModel、UseCase或Repository层实践TDD非常有效。一个简单的TDD循环红为一个尚不存在的功能如“用户输入无效邮箱时显示错误”编写一个测试。运行测试它应该失败红色。绿编写刚好能让这个测试通过的最简单的生产代码。不要考虑设计优化。运行测试它应该通过绿色。重构在测试通过的保护下改进生产代码的设计消除重复、提高可读性等。运行测试确保它们始终保持绿色。这个过程能驱使你设计出高内聚、低耦合、易于测试的代码因为你是先从一个调用者的角度测试来思考接口的。7. 疑难排查与实战心得最后分享一些我在实践中积累的“血泪教训”和实用技巧。7.1 常见错误与解决方案速查表错误现象可能原因解决方案java.lang.RuntimeException: Method ... not mocked在本地单元测试中调用了Android SDK的方法且未使用Robolectric或未配置unitTests.returnDefaultValues true。1. 重构代码将Android相关逻辑抽离到可测试的类中。2. 引入Robolectric。3. 临时在build.gradle中设置unitTests.returnDefaultValues true需谨慎。No tests found for given includes测试类或方法没有被正确识别。1. 确保测试类为publicJava或openKotlin或使用all-open插件。2. 确保测试方法有Test注解。3. 检查Gradle的测试配置确保testInstrumentationRunner正确。androidx.test.espresso.NoMatchingViewExceptionEspresso在当前界面找不到匹配的视图。1. 检查视图ID是否正确、唯一且在当前界面已显示。2. 视图可能被遮挡或不在当前视图层级。使用onView(isRoot()).perform(ViewActions.dump())打印视图树调试。3. 如果是异步加载使用Idling Resource或等待条件。PerformException: Error performing single click视图不可点击如被禁用、被覆盖。1. 检查视图的clickable和enabled属性。2. 确保视图在屏幕上可能需要先scrollTo()。3. 检查是否有动画或过渡效果干扰考虑禁用动画。测试在CI上通过在本地失败或反之环境不一致如API级别、屏幕尺寸、系统语言、时区。1. 在CI和本地使用相同版本的模拟器系统镜像。2. 在测试开始前通过ADB命令统一设置语言、时区、禁用动画。3. 避免依赖不稳定的外部网络或服务使用Mock Server。java.lang.IllegalStateException: Cannot invoke ... on a background thread在非UI线程更新了LiveData或直接操作了View。确保在测试中对LiveData的赋值或对View的操作发生在主线程。使用InstantTaskExecutorRule测试Architecture Components或在runOnUiThread块中执行。7.2 性能优化技巧测试分片Test Sharding对于庞大的UI测试套件可以将其分成多个“分片”在多个设备或模拟器上并行运行大幅缩短总执行时间。Gradle和Firebase Test Lab都支持分片。使用测试装置Test Fixtures将通用的测试工具类、Fake实现等放在一个独立的testFixtures源码集中可以被多个模块的测试代码共享避免重复。避免BeforeClass/AfterClass中的重量级操作这些类级别的初始化/清理方法只运行一次但如果操作很重如启动数据库可能会影响所有测试的启动时间。考虑懒加载或使用更轻量的替代品。7.3 个人心得测试是一种设计工具写了这么多年测试我最大的体会是编写测试的难易程度直接反映了代码设计质量的好坏。如果你发现一个类很难测试需要模拟一大堆东西或者测试代码又长又复杂这通常是一个强烈的信号——你的生产代码耦合度太高、职责太多。这时不要强行去写复杂的测试而是回过头来重构你的生产代码。应用依赖注入、单一职责原则、接口隔离原则。当你把代码重构得易于测试时它通常也变得更清晰、更灵活、更易于维护。测试不仅仅是质量的守护者更是优秀设计的催化剂。从今天开始尝试为你新增或修改的每一个功能点至少写一个单元测试。一开始可能会觉得慢但当你习惯了这种“测试驱动”的思维并享受到它带来的重构信心和代码质量提升时你就会发现这一切的投入都是值得的。你的代码库将从一个脆弱的“泥球”逐渐演变成一个坚固的、可随时被验证的“乐高城堡”。