Android单元测试实战:环境搭建、依赖选型与团队落地
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、Jest,Android生态的主动权在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函数、顶层函数mock,Kotlin项目下用MockK基本是顺理成章的事。 -
Robolectric
:如果被测代码中出现了
Context、SharedPreferences、TextView等Android框架类,纯JVM是跑不起来的,Robolectric可以在JVM中模拟出一个Android运行环境,让这些类也“活”起来。 -
Truth
:JUnit自带的
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 = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
val uiState: StateFlow<LoginUiState> = _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 = mockk<AuthApi>()
private val userRepository = mockk<UserRepository>()
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-Then :
- Given(准备) :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 为什么需要Robolectric
JVM测试里最大的拦路虎是“Android框架类在本地不可用”。
举个例子,你写了一个工具类:
class DeviceInfoUtil {
fun getAppVersion(context: Context): String {
return context.packageManager.getPackageInfo(context.packageName, 0).versionName
}
}
在JVM环境里直接
new DeviceInfoUtil().getAppVersion(context)
,
context
从哪来?就算mock了Context,
context.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整个SharedPreferences,mock出来的行为还不一定和真实的一致。
用Robolectric就非常简单:
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class DataStoreManagerTest {
@Test
fun `写入和读取配置正确`() {
val context = ApplicationProvider.getApplicationContext<Context>()
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 version - AndroidX Test的版本和targetSdk不匹配
- MockK版本和Kotlin版本不匹配
经验法则:升级AGP时,把JUnit、MockK、Robolectric、Truth这几个测试库也一起升级到与当前AGP官方集成版本兼容的组合。Android官方在Release Notes里会标明测试库的兼容版本,逐个对照,别拖到最后才处理。
8. 写在最后的一点个人体会
说句心里话,Android单元测试的学习曲线确实有点陡,要掌握的东西比写业务代码多:Gradle配置、JUnit生命周期、Mock原理、协程调度、Robolectric兼容性。但一旦跨过这道坎,你会明显感到写代码的心态都变了——改一个公共方法的实现,不再需要鼓起“全项目点一遍”的勇气,跑一把测试就能安心提交。
我自己的习惯是每次写完一个核心类,顺手就把测试写了,10分钟的事。等到集成联调的时候,少踩的坑远不止10分钟。哪怕以后换人接手项目,看到这些测试,也能很快理解代码的行为边界。这种隐性收益,没法用数字衡量,但长期累积下来,就是代码质量的分水岭。
最后给一个实打实的小技巧:不要一上来就追求100%覆盖率,先把核心逻辑那几条关键路径测住,跑通流程,再逐步把边界条件补上。一口吃不成胖子,但一年下来,你会发现项目里的测试已经是相当厚实的护城河了。
更多推荐



所有评论(0)