Android 单元测试,从零到一的实战避坑指南 📅 2026/7/15 2:36:08 1. 为什么Android开发者必须掌握单元测试第一次接触单元测试时我和大多数Android开发者一样充满疑惑明明手动点一点就能验证功能为什么要花时间写测试代码直到在一次版本迭代中我修改了某个工具类的日期格式化逻辑导致线上十几处功能异常才真正明白单元测试的价值。单元测试就像代码的安全气囊它能帮你提前发现80%的基础逻辑错误比如我遇到过日期计算时没考虑闰年单元测试立刻暴露了这个问题让重构更有底气去年重构一个2000行的历史Activity时200多个单元测试用例给了我十足的信心提升代码设计质量可测试的代码往往耦合度更低。就像你无法给一个包含网络请求、数据库操作和界面更新的巨型方法写单元测试美团技术团队的数据显示完善的单元测试能减少40%以上的线上崩溃。我在实际项目中也发现坚持写单元测试的模块线上问题数量只有其他模块的1/3。2. 从0搭建Android单元测试环境2.1 基础框架配置在app模块的build.gradle中添加这些依赖2023年最新版本dependencies { // 基础测试框架 testImplementation junit:junit:4.13.2 // Android环境模拟 testImplementation org.robolectric:robolectric:4.9 // Mock框架二选一 testImplementation org.mockito:mockito-core:4.5.1 // Java首选 testImplementation io.mockk:mockk:1.12.4 // Kotlin首选 // 测试Android架构组件 testImplementation androidx.arch.core:core-testing:2.1.0 }踩坑提醒使用Mockito时遇到final类报错添加mockito-inline依赖Robolectric测试慢在gradle.properties中加入org.gradle.paralleltrue org.gradle.daemontrue2.2 测试目录结构推荐按功能模块组织测试代码src/test/ └── java/ └── com.yourpackage/ ├── util/ # 工具类测试 ├── repository/ # 数据层测试 └── feature/ ├── FeatureATest.kt └── FeatureBTest.kt3. 破解不可测代码的三大秘籍3.1 依赖注入改造典型问题场景class UserRepository { private val api RetrofitClient.create(UserApi::class.java) // 直接依赖具体实现 fun getUser(): User { return api.getUser().blockingGet() // 同步网络请求 } }改造步骤通过接口抽象依赖interface UserDataSource { fun getUser(): User } class RemoteUserDataSource( private val api: UserApi // 通过构造函数注入 ) : UserDataSource { override fun getUser() api.getUser().blockingGet() }使用依赖注入框架可选class UserRepository Inject constructor( private val userDataSource: UserDataSource ) { fun getUser() userDataSource.getUser() }3.2 异步代码测试方案方案对比表方案优点缺点适用场景RobolectricLooper真实模拟Android线程速度稍慢涉及Handler/LooperRxJava TestScheduler精确控制时间需用RxJavaRxJava项目Coroutine TestDispatcher原生协程支持需用协程Kotlin协程项目协程测试示例class UserViewModelTest { get:Rule val rule InstantTaskExecutorRule() private val testDispatcher StandardTestDispatcher() Before fun setup() { Dispatchers.setMain(testDispatcher) } Test fun loadUser should update UI state() runTest { val viewModel UserViewModel(FakeUserRepository()) viewModel.loadUser() advanceUntilIdle() // 等待协程完成 assertEquals(UIState.Success, viewModel.uiState.value) } After fun tearDown() { Dispatchers.resetMain() } }3.3 静态方法处理技巧遇到System.currentTimeMillis()等静态方法调用时包装成接口interface Clock { fun currentTime(): Long } class SystemClock : Clock { override fun currentTime() System.currentTimeMillis() }在测试中替换class OrderProcessorTest { private val mockClock mockkClock { every { currentTime() } returns 1625097600000L // 固定时间 } private val processor OrderProcessor(mockClock) Test fun processOrder_shouldSetCreateTime() { val order processor.process(Order()) assertEquals(1625097600000L, order.createTime) } }4. Mock框架选型实战指南4.1 Mockito vs MockK深度对比功能对比表功能MockitoMockK推荐选择Kotlin支持需要额外库原生支持MockK静态方法Mock需PowerMock原生支持MockK链式调用thenReturn().thenReturn()andThen链式语法MockK参数匹配any()any()平手验证语法verify(repo).getUser()verify { repo.getUser() }MockK更直观MockK完整示例Test fun should load user when init() { // 准备Mock val mockRepo mockkUserRepository { every { getUser() } returns User(mockUser) } // 注入被测对象 val viewModel UserViewModel(mockRepo) // 验证行为 verify(exactly 1) { mockRepo.getUser() } assertEquals(mockUser, viewModel.userName.value) }4.2 测试Android组件使用Robolectric测试Activity的经典模式RunWith(RobolectricTestRunner::class) Config(sdk [Build.VERSION_CODES.O]) class LoginActivityTest { private lateinit var activity: LoginActivity Before fun setup() { activity Robolectric.buildActivity(LoginActivity::class.java) .create() .resume() .get() } Test fun login button click should start MainActivity() { activity.findViewByIdButton(R.id.btn_login).performClick() val expectedIntent Intent(activity, MainActivity::class.java) val actualIntent ShadowApplication.getInstance().nextStartedActivity assertEquals(expectedIntent.component, actualIntent.component) } }5. 复杂业务逻辑测试策略5.1 状态验证 vs 行为验证电商下单场景示例Test fun placeOrder should save order and clear cart() { // 准备测试数据 val testCart Cart().apply { addItem(Item(1, Phone, 1, 3999.0)) } val mockOrderRepo mockkOrderRepository(relaxed true) // 执行测试 val orderService OrderService(mockOrderRepo) orderService.placeOrder(testCart) // 状态验证 assertTrue(testCart.items.isEmpty()) // 行为验证 verify { mockOrderRepo.saveOrder( match { it.totalAmount 3999.0 } ) } }5.2 参数化测试实践使用JUnit5的ParameterizedTestclass PriceCalculatorTest { companion object { JvmStatic fun discountCases() listOf( Arguments.of(100.0, 0.1, 90.0), // 原价,折扣,期望结果 Arguments.of(200.0, 0.2, 160.0), Arguments.of(300.0, 0.0, 300.0) ) } ParameterizedTest MethodSource(discountCases) fun calculateFinalPrice with different discounts( original: Double, discount: Double, expected: Double ) { val calculator PriceCalculator() assertEquals(expected, calculator.calculateFinalPrice(original, discount)) } }5.3 测试覆盖率提升技巧边界条件测试模板空输入测试最大值/最小值测试非法输入测试并发操作测试JaCoCo配置示例android { buildTypes { debug { testCoverageEnabled true } } } // 在终端运行./gradlew createDebugCoverageReport我在实际项目中的经验是核心业务逻辑如支付、订单追求80%覆盖率而简单的工具类保持60%即可不要为了覆盖率而写无意义的测试。6. 常见坑点及解决方案坑点1测试通过但实际运行崩溃原因Robolectric的行为与真机有差异解决方案关键路径补充真机插桩测试坑点2测试随机失败原因未清理静态变量或单例状态修复方案After fun tearDown() { // 清理单例状态 SingletonManager.reset() // 清除Mock状态 unmockkAll() }坑点3测试运行超慢优化方案使用Config(qualifiers w320dp-h480dp)限定资源尺寸避免在Before中初始化不必要的对象使用Mock替代真实数据库操作7. 真实项目测试演进案例在开发一个电商App的购物车模块时我经历了这样的测试演进初级阶段覆盖率30%Test fun addItem_shouldIncreaseCount() { val cart ShoppingCart() cart.addItem(Item(1, Phone, 1, 3999.0)) assertEquals(1, cart.itemCount) }中级阶段覆盖率65%Test fun addDuplicateItem_shouldMergeQuantity() { val cart ShoppingCart().apply { addItem(Item(1, Phone, 1, 3999.0)) } cart.addItem(Item(1, Phone, 2, 3999.0)) assertEquals(1, cart.items.size) assertEquals(3, cart.items[0].quantity) assertEquals(11997.0, cart.totalAmount) }高级阶段覆盖率85%Test fun concurrentAddItem_shouldThreadSafe() runBlockingTest { val cart ShoppingCart() val testItem Item(1, Phone, 1, 3999.0) // 模拟100个并发请求 val jobs List(100) { launch(Dispatchers.Default) { cart.addItem(testItem.copy()) } } jobs.joinAll() assertEquals(100, cart.itemCount) }这个过程中最大的体会是单元测试不是一蹴而就的应该随着业务逻辑的复杂化逐步完善。刚开始可能只是验证基本功能后续逐渐加入边界条件、异常场景和性能要求的测试。