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上测的,绝不往设备上搬 。

原因很朴素:

  1. 速度快 。JVM测试跑完全部用例通常几秒到几十秒,真机测试一次构建加安装就要好几分钟。
  2. 稳定 。CI服务器上没有真机也能跑,不受设备调度、USB连接、模拟器性能影响。
  3. 定位精准 。失败的堆栈信息直接指到代码行号,不用看设备端Logcat里乱七八糟的日志。
  4. 成本低 。不需要维护设备池,不需要处理多机型兼容问题。

所以,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虽好,但也有代价:

  1. 启动慢 。每个类第一次跑Robolectric测试时,要初始化整个shadow环境,耗时可能从几秒到十几秒不等。所以不要让所有测试类都用Robolectric,能纯JVM测的保持纯JVM。
  2. 版本兼容问题 。Robolectric每个版本支持的SDK和AndroidX版本不完全一样,升级AGP或AndroidX库时,Robolectric可能会报“不支持该SDK版本”之类的错误。需要保持Robolectric版本和Android SDK版本对齐。
  3. 不测真实硬件 。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跑挂了先跳过,等版本发布会前再统一修。到后来,那条红色检查就成了个摆设。

这里给三个建议:

  1. 测试用例不允许出现网络依赖 。凡是需要真实网络请求的测试,要么mock,要么用本地测试数据。一个测试如果在CI上因为网络波动fail一次,团队的信任感就断一分。
  2. Pull Request必须过单元测试 。在Git平台上的PR流水线里加上unit test这一环,跑挂了不允许合入。这条规则比什么都好使。
  3. 新逻辑一定要配测试,否则代码审查不通过 。把测试当成交付物的一部分,和功能代码同等重要。

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%覆盖率,先把核心逻辑那几条关键路径测住,跑通流程,再逐步把边界条件补上。一口吃不成胖子,但一年下来,你会发现项目里的测试已经是相当厚实的护城河了。

更多推荐