资讯动态

Android单元测试实战:环境搭建、依赖选型与团队落地

发布时间:2026/9/20 5:16:12 来源:尧图企业网站定制
1. 先搞清楚Android单元测试到底在测什么做Android开发的同学多多少少都经历过这种场景项目跑了好几年业务代码堆得像山一样高重构的时候心里发怵改一个工具类要全局搜索引用改完还得手动跑一遍App把相关页面点个遍。这时候你会意识到缺的东西不是技术不是架构是测试。但“测试”这个词在Android圈子里其实被用得很泛。日常说的测试至少包含三层东西单元测试跑在JVM上的不需要真机不需要模拟器只测试某一个类、某一个方法、某一个函数的行为。速度极快毫秒级跑完。集成测试涉及多个模块协同或者需要数据库、网络、文件系统等真实环境但依然可以跑在JVM或者Robolectric容器里。UI测试通常指仪器测试跑在模拟器或真机上用Espresso、Compose Testing之类的框架去模拟用户点击、输入、滑动。本文聊的是第一层Android单元测试为主。我之前带过一个项目代码量不算大但每次发版前都要手动回归一遍核心流程耗时小半天。后来花了两个周末把核心业务逻辑的单元测试补上从那以后改完代码跑一遍./gradlew testDebugUnitTest一分钟就知道哪里崩了、哪里逻辑错了、哪里边界处理漏了。这个收益谁用谁知道。很多人不写单元测试的理由非常统一不会写、不知道怎么测、项目里全是Activity和Fragment、感觉无从下手。这篇文章就是来解决这些问题的。不讲虚的直接讲Android单元测试的环境搭建、依赖选型、实战写法、常见坑和团队落地经验保证你看完能直接在自己项目里跑起来。2. 单元测试的正确打开方式先看懂测试目录2.1 test目录和androidTest目录别混了在Android Studio里新建一个项目默认会有两套测试目录app/src/test/java/... // JVM单元测试本地运行无需设备 app/src/androidTest/java/... // 仪器测试需要模拟器或真机test目录跑在本地JVM上依赖的Android框架代码是mock出来的速度快适合测纯逻辑、ViewModel、工具类、数据层封装等。androidTest目录跑在设备上可以调用真实的系统API适合测UI、数据库迁移、文件读写、蓝牙等硬件交互等。很多人一开始就把这两个目录搞混导致写好的测试跑不起来或者跑起来特别慢。原则上能写进test目录的就不要写进androidTest目录。真机测试越少越好资源消耗和稳定性都是问题。2.2 单元测试的运行逻辑和方式单元测试在Android项目里的执行链路是这样的Gradle编译Java/Kotlin代码后生成对应的字节码和测试字节码然后由Gradle的testDebugUnitTest任务执行。测试框架默认是JUnit通过Gradle的testOptions配置来约束运行行为。在Android Studio里跑测试的方式也有好几种右键单击具体测试类中的某个方法选择Run单跑一个用例右键测试类Run整个类的所有用例在项目根目录执行./gradlew testDebugUnitTest跑整个模块的所有单元测试单跑一个用例和跑整个类的区别只在于Gradle任务参数不同实际跑起来都是JUnit加载测试类执行带有Test注解的方法。这里有个细节跑单个测试的时候Android Studio会自动生成对应的Gradle任务和Run Configuration不用手动配置。2.3 为什么说JVM测试才是性价比之王我在团队里经常强调一句话凡是能在JVM上测的绝不往设备上搬。原因很朴素速度快。JVM测试跑完全部用例通常几秒到几十秒真机测试一次构建加安装就要好几分钟。稳定。CI服务器上没有真机也能跑不受设备调度、USB连接、模拟器性能影响。定位精准。失败的堆栈信息直接指到代码行号不用看设备端Logcat里乱七八糟的日志。成本低。不需要维护设备池不需要处理多机型兼容问题。所以Android单元测试的核心阵地永远是src/test目录加JVM。3. 搭建一把能打的测试环境3.1 依赖怎么配JUnit 4还是JUnit 5在Android Studio里新建项目时build.gradle里默认会带上JUnit 4的依赖testImplementation junit:junit:4.13.2这就是最基础的单元测试依赖配上就能跑Test。JUnit 4和JUnit 5的区别一句话总结JUnit 5更模块化由Jupiter新编程模型、Vintage兼容老JUnit、Platform测试引擎三部分组成功能更强但Android项目的支持相对JUnit 4来说麻烦一丢丢。实际项目里我的建议是没有特殊需求就先用JUnit 4等团队里有人踩熟了JUnit 5的坑再升级。很多人在这一步会有个疑问“热词里提到的vue router pinia eslint prettier vitest单元测试是前端的东西跟Android有什么关系”没关系这是前后端两条线。前端有Vitest、JestAndroid生态的主动权在JUnit Mockito Robolectric这一套上。3.2 这几样依赖建议项目里直接备齐如果把Android单元测试当成吃饭的话JUnit只是最基础的碗筷真正让测试能写下去的是下面这几个库// 核心测试框架 testImplementation junit:junit:4.13.2 // Android测试支持库 testImplementation androidx.test:core:1.5.0 // Mockito框架用来mock依赖对象 testImplementation org.mockito:mockito-core:5.6.0 testImplementation org.mockito:mockito-inline:5.2.0 // 如果用的是Kotlin用MockK会更舒服 testImplementation io.mockk:mockk:1.13.5 // Robolectric在JVM上模拟Android框架环境 testImplementation org.robolectric:robolectric:4.11.1 // 断言库比JUnit自带的断言更直观 testImplementation com.google.truth:truth:1.1.4 // 协程测试库 testImplementation org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3这里逐个说下各自的角色Mockito / MockK单元测试的精髓是隔离依赖。被测对象依赖的Network API、Database、Context等都要用mock替身顶替掉。Mockito是老牌Java mock框架MockK是Kotlin的亲儿子支持suspend函数、顶层函数mockKotlin项目下用MockK基本是顺理成章的事。Robolectric如果被测代码中出现了Context、SharedPreferences、TextView等Android框架类纯JVM是跑不起来的Robolectric可以在JVM中模拟出一个Android运行环境让这些类也“活”起来。TruthJUnit自带的assertEquals参数顺序反人类Truth写出来更像读英文句子assertThat(actual).isEqualTo(expected)报错时给出的信息也比JUnit的详细。kotlinx-coroutines-test测协程代码必备。提供runTest、StandardTestDispatcher、虚拟时间控制等工具让异步变成同步毫秒级跑完。3.3 build.gradle里的关键配置项配完依赖后还需要在defaultConfig下面的testOptions加一段配置testOptions { unitTests { // 返回默认的Android框架方法行为而不是直接抛异常 returnDefaultValues true // 配置Robolectric运行时参数 includeAndroidResources true } }returnDefaultValues这个选项很关键。默认情况下在JVM测试里调用Android框架未mock的方法比如TextUtils.isEmpty()会抛出RuntimeException: Stub!。把这个值设为true这类方法会返回默认值0、null、false测试能继续下去。但要注意这只是一种兜底不能依赖它。真正要测的逻辑涉及的Android依赖还是要用mock或者Robolectric解决。4. 手把手写一个像样的单元测试4.1 选一个真正值得测的类光有环境没有实战等于白搭。我们拿一个业务里非常常见的场景来练手登录ViewModel。假设有一个LoginViewModel职责是获取用户输入的用户名和密码调AuthApi登录登录成功后把用户信息保存到本地仓库更新UI状态class LoginViewModel( private val authApi: AuthApi, private val userRepository: UserRepository ) : ViewModel() { private val _uiState MutableStateFlowLoginUiState(LoginUiState.Idle) val uiState: StateFlowLoginUiState _uiState fun login(username: String, password: String) { viewModelScope.launch { _uiState.value LoginUiState.Loading try { val user authApi.login(username, password) userRepository.saveUser(user) _uiState.value LoginUiState.Success(user) } catch (e: Exception) { _uiState.value LoginUiState.Error(e.message ?: 未知错误) } } } }这个ViewModel里有几个值得测的点登录请求成功时状态从Idle变成Loading再变成Success且保存了用户登录请求失败时状态变成Error并且Error信息是后端返回的用户名密码为空时的参数校验写单元测试之前我们先把依赖项AuthApi和UserRepository定义好interface AuthApi { suspend fun login(username: String, password: String): User } interface UserRepository { suspend fun saveUser(user: User): Boolean } data class User(val id: String, val name: String)4.2 用MockK写第一个测试用例这里我选择MockK。说实话Kotlin项目里用Mockito写协程mock比较别扭mockito-kotlin各种扩展方法要考虑的细节太多。MockK直接支持kotlin类型和suspend函数写起来干净。对照上面的LoginViewModel第一个测试类长这样class LoginViewModelTest { private val authApi mockkAuthApi() private val userRepository mockkUserRepository() private lateinit var viewModel: LoginViewModel private val dispatcher StandardTestDispatcher() Before fun setup() { // 把协程切换到测试Dispatcher使异步变同步 Dispatchers.setMain(dispatcher) viewModel LoginViewModel(authApi, userRepository) } After fun teardown() { Dispatchers.resetMain() } Test fun 登录成功后更新状态并保存用户() runTest { val user User(id 1, name 小白) // 准备mock依赖的返回结果 coEvery { authApi.login(admin, 123456) } returns user coEvery { userRepository.saveUser(user) } returns true // 执行 viewModel.login(admin, 123456) // 切换状态之前需要跑完dispatcher队列中的任务 advanceUntilIdle() // 断言最终状态 assertEquals(user, viewModel.uiState.value) // 验证saveUser被调用了一次参数是user coVerify(exactly 1) { userRepository.saveUser(user) } } Test fun 登录失败后状态变成Error() runTest { coEvery { authApi.login(admin, wrong) } throws RuntimeException(密码错误) viewModel.login(admin, wrong) advanceUntilIdle() assertTrue(viewModel.uiState.value is LoginUiState.Error) assertEquals(密码错误, (viewModel.uiState.value as LoginUiState.Error).message) } }这段代码覆盖了登录成功的正常路径和登录失败的异常路径。方法论上对应测试理论里典型的Given-When-ThenGiven准备mock好依赖定义好入参和预期返回When执行调用被测方法Then断言验证状态变化和依赖方法调用4.3 异步测试的关键runTest和StandardTestDispatcher上面的测试代码里最容易被新手忽略的就是这两个东西private val dispatcher StandardTestDispatcher() Dispatchers.setMain(dispatcher) advanceUntilIdle()viewModelScope.launch默认使用Dispatchers.Main在单元测试环境里没有Main Looper如果不替换一跑就崩。所以我们在Before里用Dispatchers.setMain(dispatcher)把主线程调度器替换成测试专用的StandardTestDispatcher。StandardTestDispatcher的厉害之处在于它会把你提交的任务排队只有显式调用advanceUntilIdle()或runCurrent()时才会执行。这样一来异步代码就变成了同步代码我们可以精确控制执行到哪一步再停下来断言。这就是Kotlin协程测试的核心心法让时间可控让异步变同步。4.4 边界条件测试才是单元测试的含金量上面测了成功和失败但如果要吹毛求疵还应该测这些边界情况用户名为空时网络请求不应该被发出密码为空时应该直接提示错误并发点击多次登录按钮时会不会重复发起请求写出来就是Test fun 用户名为空时直接返回错误不调用网络() runTest { viewModel.login(, 123456) advanceUntilIdle() assertTrue(viewModel.uiState.value is LoginUiState.Error) coVerify(exactly 0) { authApi.login(any(), any()) } }这种测试的价值在于防止回归。开发时把边界条件想清楚测试写明白一个月后有人改了登录逻辑忘了加用户名非空校验测试立刻变红比用户发现bug再提工单高效一百倍。4.5 ViewModel里的unittest能直接测吗这里回答一个高频问题ViewModel测试是不是必须引入ViewModelScope之类的特殊处理实际上官方推荐的测试方式就是上面这种。viewModelScope在测试中会被Dispatchers.setMain接管只要主线程调度器替换成功ViewModel就可以当普通类来测。如果你用的是Android架构组件里的其他成员比如SavedStateHandle需要在创建ViewModel时传入可以用SavedStateHandle(mapOf(...))来构造。不用mock直接传真实对象即可。5. 绕不开的Android依赖Robolectric实战5.1 为什么需要RobolectricJVM测试里最大的拦路虎是“Android框架类在本地不可用”。举个例子你写了一个工具类class DeviceInfoUtil { fun getAppVersion(context: Context): String { return context.packageManager.getPackageInfo(context.packageName, 0).versionName } }在JVM环境里直接new DeviceInfoUtil().getAppVersion(context)context从哪来就算mock了Contextcontext.packageManager也会返回null接着调用getPackageInfo就会空指针。Robolectric就是解决这个问题的它在JVM进程内启动了一个“影子Android环境”让Context、PackageManager、SharedPreferences、Resources这些Android框架类都变成可用的、有真实行为的对象。5.2 加注解就能用Robolectric用起来成本极低只需要在测试类上加两个注解RunWith(RobolectricTestRunner::class) Config(sdk [33]) class DeviceInfoUtilTest { private lateinit var context: Context Before fun setup() { context ApplicationProvider.getApplicationContext() } Test fun 获取应用版本号不为空() { val version DeviceInfoUtil().getAppVersion(context) assertNotNull(version) } }ApplicationProvider.getApplicationContext()是androidx.test库提供的方法在Robolectric环境下会返回一个真实的Application对象不是mock的。Config(sdk [33])指定运行在SDK 33的环境下。不同的SDK版本对应的Android框架行为略有差异比如权限行为、文件路径规则等。项目里有多个SDK版本兼容需求时可以让测试分别跑在多个SDK上Config(sdk [30, 33])这样同一个测试会在SDK 30和33环境下分别执行适合做系统兼容性验证。5.3 Robolectric测SharedPreferences简直不要太方便业务代码里最常见的Android依赖之一就是SharedPreferences。以前的写法要么写进instrumentation测试里跑真机要么mock整个SharedPreferencesmock出来的行为还不一定和真实的一致。用Robolectric就非常简单RunWith(RobolectricTestRunner::class) Config(sdk [33]) class DataStoreManagerTest { Test fun 写入和读取配置正确() { val context ApplicationProvider.getApplicationContextContext() val manager DataStoreManager(context) manager.saveToken(abc123) assertEquals(abc123, manager.getToken()) } }Robolectric会为每次测试自动创建独立的Application实例测试之间数据互不干扰不需要手动清理。5.4 使用Robolectric的注意事项Robolectric虽好但也有代价启动慢。每个类第一次跑Robolectric测试时要初始化整个shadow环境耗时可能从几秒到十几秒不等。所以不要让所有测试类都用Robolectric能纯JVM测的保持纯JVM。版本兼容问题。Robolectric每个版本支持的SDK和AndroidX版本不完全一样升级AGP或AndroidX库时Robolectric可能会报“不支持该SDK版本”之类的错误。需要保持Robolectric版本和Android SDK版本对齐。不测真实硬件。Robolectric本质上是模拟对于蓝牙、传感器、摄像头等真实硬件行为它是无能为力的。6. 常见问题与排查技巧实录6.1 “Method xx in android.util.Log not mocked” 报错这是JVM测试里最经典的一个坑报错内容类似Method parseInt in android.util.Log not mocked.原因test目录下的Android框架方法是空的默认直接抛异常。虽然前面配置了returnDefaultValues true但这个配置只对部分方法有效有些方法仍然会抛Stub错误。解决方案有几种用mockStatic明确mock这个方法适合被测代码里用的地方不多的情况。用Robolectric环境跑这个测试类。引入专门的工具类库比如Timber替代Log测试时mock Timber。我个人建议是业务代码里尽量避免直接依赖Log等Android静态方法统一走自己封装一层测试时不仅能mock还能顺便统计日志输出。6.2 协程测试里时间一直不走任务一直不执行写协程测试时用了runTest和StandardTestDispatcher后发现测试卡住或者断言一直不通过。原因多半是标准代码里用了真实的延迟而不是虚拟时间。举个反面例子// 被测代码 fun refreshData() { viewModelScope.launch { delay(1000) _uiState.value ... } }在测试里delay在虚拟时间下是可控的可以用advanceTimeBy(1000)来跳时间。如果用错了Dispatcher比如协程跑在实际的IO线程上advanceUntilIdle()是控制不了那个线程的测试就会变成真正的异步等待。排查思路确认被测代码的CoroutineDispatcher是否来自Dispatchers.Main确认是否调用了Dispatchers.setMain把runTest和StandardTestDispatcher当成默认标配6.3 Mockito的when对suspend函数不生效在Kotlin项目里用Mockito写协程代码测试经常遇到when对suspend函数不生效的问题。写出来的测试看起来很合理跑起来却发现mock的函数压根没被调用。原因suspend函数在编译后会变成一个带有Continuation参数的方法Mockito默认对它支持不好。解决换MockK用coEvery直接支持suspend函数这是最推荐的。或者使用mockito-kotlin提供的特殊处理但体验一般。6.4 Android Studio里点Run跑测试显示的却是“All tests passed”但实际没跑任何用例这个太经典了。原因是你把测试写在了src/androidTest目录下但Run Configuration选择了Unit Test。Android Studio根据项目结构推断测试类型时偶尔会选错。解决检查测试文件路径是src/test/java还是src/androidTest/java如果测试类确实在test目录但还是没跑看看Run Configuration里的Module设置是否正确有时候并行跑多个模块会串。6.5 代码覆盖率总是上不去先看测试策略很多人搭好测试后开始纠结覆盖率。覆盖率工具的配置很简单testOptions { unitTests.all { jacoco { includeNoLocationClasses true } } }但覆盖率上不去通常不是工具的问题而是策略的问题。测试金字塔在这里同样适用UI 测试少 集成测试中 单元测试多且在底层单元测试应该集中力量覆盖业务逻辑密集、复用度高、对稳定性要求高的模块比如数据层的解析和持久化逻辑领域层的计算和规则校验ViewModel和UseCase的状态流转工具类的纯函数逻辑而Activity、Fragment这类UI页面如果能把状态抽到ViewModel里ViewModel测好了UI层测试的负担就大大减轻。我见过很多团队盯着Activity覆盖率打转把测试写成跑真机的脚本最后维护成本爆炸。正确做法是把逻辑下沉到可测试的普通类里然后让普通类充分测试。7. 团队落地的几个实操建议7.1 从哪些代码开始测最有性价比不是所有代码都值得写单元测试。根据我的实际经验以下类型优先级最高包含分支和边界条件的工具类比如金额格式化、日期换算、JSON解析、加解密。状态流转复杂的类ViewModel、状态机、支付流程。数据仓库/接口封装层负责组装请求参数、解析返回数据、处理缓存逻辑的类。定时任务、消息调度逻辑测它的调度条件正确与否。反过来一个只把A字段赋值到B字段的setter/getter类以及UI控件的简单填充代码测试价值很低不必死磕覆盖率。7.2 测试代码也要维护别让它在CI里变成摆设我见过不少项目刚开始时所有人都很热情地写测试CI上也配了testDebugUnitTest。但过了一两个月测试用例开始红没人去修渐渐大家达成了默契CI跑挂了先跳过等版本发布会前再统一修。到后来那条红色检查就成了个摆设。这里给三个建议测试用例不允许出现网络依赖。凡是需要真实网络请求的测试要么mock要么用本地测试数据。一个测试如果在CI上因为网络波动fail一次团队的信任感就断一分。Pull Request必须过单元测试。在Git平台上的PR流水线里加上unit test这一环跑挂了不允许合入。这条规则比什么都好使。新逻辑一定要配测试否则代码审查不通过。把测试当成交付物的一部分和功能代码同等重要。7.3 AGP版本升级时测试库跟着升Android技术栈升级频率很快每半年一次AGP大版本AndroidX库也在不断发版。很多团队升级的时候只关注编译问题忽略了测试库的适配。常见的问题有这些Robolectric和AGP版本不兼容测试启动报Unsupported class file major versionAndroidX Test的版本和targetSdk不匹配MockK版本和Kotlin版本不匹配经验法则升级AGP时把JUnit、MockK、Robolectric、Truth这几个测试库也一起升级到与当前AGP官方集成版本兼容的组合。Android官方在Release Notes里会标明测试库的兼容版本逐个对照别拖到最后才处理。8. 写在最后的一点个人体会说句心里话Android单元测试的学习曲线确实有点陡要掌握的东西比写业务代码多Gradle配置、JUnit生命周期、Mock原理、协程调度、Robolectric兼容性。但一旦跨过这道坎你会明显感到写代码的心态都变了——改一个公共方法的实现不再需要鼓起“全项目点一遍”的勇气跑一把测试就能安心提交。我自己的习惯是每次写完一个核心类顺手就把测试写了10分钟的事。等到集成联调的时候少踩的坑远不止10分钟。哪怕以后换人接手项目看到这些测试也能很快理解代码的行为边界。这种隐性收益没法用数字衡量但长期累积下来就是代码质量的分水岭。最后给一个实打实的小技巧不要一上来就追求100%覆盖率先把核心逻辑那几条关键路径测住跑通流程再逐步把边界条件补上。一口吃不成胖子但一年下来你会发现项目里的测试已经是相当厚实的护城河了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价