说实话,刚接手团队那会儿,一听到“保证测试覆盖率”这种要求我就头疼。以前写功能代码有多爽,补单元测试就有多痛苦。但真正静下心来,把Android单元测试这套东西从环境搭建到架构设计彻底捋了一遍之后,我反倒觉得,单测不是用来应付KPI的额外负担,而是一面能逼着你把代码写得更加清晰的照妖镜。
这篇文章我想从一个亲历者的角度,把Android单元测试这条路从头到尾踩过的坑、总结出来的套路,都给正在观望或者刚起步的朋友捋一遍。不整虚的,就是从环境配置、第一行测试代码、Mock依赖,到怎么让你的代码天生好测,再到那些能把人逼疯的疑难杂症,一次说透。
1. 搭建测试环境,先把地基夯实
这么多年看下来,很多团队单元测试搞不起来,第一个坎其实不是不会写测试,而是环境根本跑不起来。所以在聊怎么写测试之前,先把Android Studio项目里的测试环境讲清楚,这是所有工作的前提。
1.1 分清两种测试目录:test和androidTest
刚入行那阵子,我经常看到同事把测试代码一股脑丢进src/androidTest目录,然后在本地JVM上直接跑,结果跑一次编译一次,慢得让人怀疑人生。其实Android工程默认就有两个测试目录,职责完全不同:
src/test/java/:本地单元测试(Local Unit Tests),跑在开发机自己的JVM上。速度快、不依赖模拟器或者真机,适合跑绝大部分纯逻辑测试。日常开发中绝大多数单测都写在这里。src/androidTest/java/:仪器化测试(Instrumented Tests),跑在Android设备或者模拟器上。需要调用系统API或者真实设备环境的场景,比如测试SharedPreferences的真实读写、UI组件的渲染等,才需要在这里写。
我的建议很明确:能用本地单测解决的,坚决不写仪器化测试。一次本地测试跑下来,几百个用例也就几秒钟,而仪器化测试光拉起模拟器就要好几分钟,这种成本差异直接决定了你会不会真的去跑测试。
1.2 Gradle依赖配置的弯弯绕绕
在app/build.gradle文件里,测试相关的依赖需要加对作用域。很多新手分不清testImplementation和androidTestImplementation,其实看名字就能猜个大概:
dependencies { // 本地单元测试专用依赖 testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:4.11.0' testImplementation 'org.robolectric:robolectric:4.9.2' testImplementation 'androidx.test:core:1.5.0' testImplementation 'androidx.test.ext:junit:1.1.5' // 仪器化测试专用依赖 androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:rules:1.5.0' }这里有个容易踩的坑:如果你在src/test目录下想用android.util.Log这类Android SDK的类,直接跑会报“Method xxx in android.util.Log not mocked”的错误。这是因为官方提供的mockable android.jar只是把所有方法都做成了抛异常的空实现。解决办法有两个:
- 在
app/build.gradle里加一段配置,让本地测试能拿到真实实现:
android { testOptions { unitTests.returnDefaultValues = true } }个人不太推荐这种一键配置,因为它会让很多错误静默吞掉,排查问题的时候反而更费劲。
- 引入Robolectric框架(后面会专门讲),在本地JVM上模拟Android环境。这个方法更优雅,也是目前业界的主流做法。
1.3 千万别碰的Android API陷阱
回到上面那个“not mocked”的问题,我再说细一点。即使你配了returnDefaultValues = true,Android API的返回值也全都是默认值:对象是null,整数是0,布尔是false。如果你的被测代码里面对这些结果做过非空判断或者逻辑分支,那么测试结果和真实运行结果会有天壤之别。
举个例子,你测一个工具类:
public static boolean isNetworkAvailable(Context context) { ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo info = cm.getActiveNetworkInfo(); return info != null && info.isConnected(); }这段代码在本地测试里,getSystemService会返回null,接着就抛NullPointerException。很多新手会在这里卡住,觉得“怎么换个环境就跑不了”,其实解决思路很简单:要么用Mockito把Contextmock掉,要么用Robolectric跑一个真实的Android环境。选哪个方案,取决于你的被测代码到底跟Android系统API耦合得有多深。
2. 从JUnit4开始,把测试用例写出仪式感
环境搭好之后,就该动手写第一条测试了。JUnit4作为Android单测的基础框架,看起来简单,但很多人的测试代码写得乱七八糟,断言满天飞、命名随意、逻辑串成一坨。这一节我把JUnit4里最实用的部分拆开讲清楚。
2.1 生命周期注解怎么用才对
JUnit4的几个核心注解,用错了虽然不报错,但会让测试之间互相污染数据。我见过太多人把对象初始化一股脑丢在@Before里,然后每个测试方法内部又偷偷塞自己的逻辑。
正确做法是:
@BeforeClass:整个测试类执行一次,适合初始化耗时资源(比如数据库连接、大对象),方法必须是public static void。@Before:每个测试方法执行前都会调用一次,适合初始化通用的测试数据。这是用得最多的注解。@After:每个测试方法执行后调用,适合清理临时文件、释放资源。@AfterClass:整个测试类跑完后执行一次,适合关闭连接池等。
一个标准模板长这样:
public class CalculatorTest { private Calculator mCalculator; @Before public void setUp() { mCalculator = new Calculator(); } @After public void tearDown() { // 释放资源 } @Test public void testAdd_twoPositiveNumbers_returnsSum() { int result = mCalculator.add(2, 3); assertEquals(5, result); } }2.2 断言方法别只会assertEquals
很多人在写测试断言时,翻来覆去就一个assertEquals,遇到集合、数组、异常场景就犯难。我整理几个高频场景:
assertTrue(condition)/assertFalse(condition):验证布尔条件。assertNull(obj)/assertNotNull(obj):验证对象是否为空。assertSame(expected, actual):验证引用是否指向同一个对象,而assertEquals验证的是值相等。两者不能混用。assertArrayEquals(expectedArray, actualArray):数组内容逐项比对。assertThrows(ExceptionClass.class, () -> doSomething()):验证特定异常被抛出,这是JUnit4.13推荐的新写法,比@Test(expected = ...)更好用,因为它还能同时校验异常信息。
2.3 一个反直觉的经验:断言要具体
我见过一些“过度稳健”的测试,断言永远只写一句assertNotNull(result)。这种用例跑一万遍都是绿的,但什么都没验证到。好的断言应该像把摄像头对准了代码的每一个输出的细节——不仅要知道结果不为空,还要知道结果里每一项的具体值、顺序、边界情况。
有一次我们修一个排序模块的bug,就是因为测试断言写得不够细,排序算法从稳定变成不稳定了,测试照样全绿。后来把断言改成同时校验元素内容和相对位置,问题立刻暴露。所以,断言越具体,测试的保护力越强。
3. Mockito和Robolectric,单元测试的两把瑞士军刀
真实项目里几乎没有完全不依赖外部对象的代码。网络请求、数据库访问、系统服务——这些依赖如果不处理,单测根本写不了。Mockito帮你把依赖“架空”,Robolectric帮你在JVM上“伪造”一个Android系统,这俩组合起来,覆盖日常开发中95%以上的场景没问题。
3.1 Mockito:让所有依赖“听话”
Mockito的核心思想很简单:用一个假的替身对象代替真实依赖,然后定义替身的行为,最后验证替身有没有被正确调用。
先看一个最典型的用法,我们需要测试一个LoginViewModel:
public class LoginViewModel { private final LoginRepository mRepository; public LoginViewModel(LoginRepository repository) { mRepository = repository; } public boolean login(String username, String password) { if (username.isEmpty() || password.isEmpty()) { return false; } return mRepository.loginRequest(username, password).isSuccess(); } }对应的测试用Mockito可以写成:
@RunWith(MockitoJUnitRunner.class) public class LoginViewModelTest { @Mock LoginRepository mRepository; @InjectMocks LoginViewModel mViewModel; @Test public void login_emptyUsername_returnsFalse() { boolean result = mViewModel.login("", "123456"); assertFalse(result); verify(mRepository, never()).loginRequest(anyString(), anyString()); } @Test public void login_validInput_returnsTrue() { ApiResponse response = new ApiResponse(true); when(mRepository.loginRequest("admin", "123456")).thenReturn(response); boolean result = mViewModel.login("admin", "123456"); assertTrue(result); verify(mRepository).loginRequest("admin", "123456"); } }注意几个细节:
@RunWith(MockitoJUnitRunner.class)让注解@Mock自动生效,省去手动Mockito.mock()的样板代码。@InjectMocks会自动把已声明的@Mock对象按照构造函数参数注入进去,注意只能注入构造器或者setter。verify(mRepository, never()).loginRequest(...)用来验证某个方法从未被调用过,这种负向验证在检查防御性逻辑时尤其有用。when(...).thenReturn(...)只能用于非void方法,void方法的mock需要使用doNothing().when(mock).method(...)。
3.2 ArgumentCaptor:当参数是动态生成的时候
有一种场景挺折磨人的:你不在意当次调用的参数是什么,但你想验证传给依赖的参数必须是符合预期的。比如被测代码内部会生成一个时间戳,然后传给Repository。这时候ArgumentCaptor就派上用场了:
@Test public void login_validInput_capturesCorrectTimestamp() { when(mRepository.saveLoginRecord(anyString())).thenReturn(true); mViewModel.recordLogin("admin"); ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class); verify(mRepository).saveLoginRecord(captor.capture()); String capturedValue = captor.getValue(); assertNotNull(capturedValue); // 可以用正则表达式或者SimpleDateFormat验证时间格式 }3.3 Robolectric:没有模拟器,也能调用Android API
前面提到过,本地测试直接调用Android API会报错。Robolectric项目的目标,就是提供一个“在JVM上运行Android代码”的模拟环境。它的内部实现相当聪明,把Android源码里的关键类(比如Application、Activity、Looper、Resource)都重写了一遍,让它们能脱离真实设备工作。
使用Robolectric很简单,给测试类加个注解:
@RunWith(RobolectricTestRunner.class) public class UserDaoTest { private UserDao mUserDao; @Before public void setUp() { Application app = ApplicationProvider.getApplicationContext(); mUserDao = new UserDao(app); } @Test public void insertUser_getById_returnsUser() { User user = new User(1L, "Jack"); mUserDao.insert(user); User result = mUserDao.getById(1L); assertNotNull(result); assertEquals("Jack", result.getName()); } }Robolectric最大的好处是,它连SharedPreferences、SQLite、Resources都能模拟,而且跑一个测试用例的时间通常在几百毫秒到一两秒之间,比仪器化测试快太多了。当然它也不是万能的,涉及到真实的多进程、真机传感器、系统级UI弹窗这些场景,还是要回到模拟器或真机上验证。
3.4 需要谨慎:Robolectric也不是银弹
我吃过一次亏:用Robolectric测试一个依赖Handler.postDelayed的定时任务,测试里等了很久都没等到回调执行。因为Robolectric对Looper的处理方式跟真实设备不一样,需要主动去驱动主线程的Looper。
简单粗暴的解决办法是:
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();这会立刻执行所有UI线程上排队的所有任务,包括延时任务。这种细节如果不看文档踩过一次,真的会怀疑人生。
4. 协程、LiveData和ViewModel的测试实战
现在的新项目基本都在用Kotlin,协程和LiveData几乎是标配。但很多人写的业务代码很溜,一到测协程就不知道从哪下手。这一章我们重点讲清楚协程和LiveData的测试套路。
4.1 主线程调度器怎么替换
协程最麻烦的一点是,测试环境不像Android主线程那样天然存在一个MainDispatcher。你一旦在测试里触发了viewModelScope.launch,很可能直接抛出IllegalStateException: Module with the Main dispatcher had failed to initialize。
解决方案是自定义一个MainDispatcherRule,在测试里强制替换主线程调度器:
class MainDispatcherRule( private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() ) : TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(testDispatcher) } override fun finished(description: Description) { Dispatchers.resetMain() } }然后在测试类里这么用:
@get:Rule val mainDispatcherRule = MainDispatcherRule() @Test fun login_validInput_launchesCoroutineAndSetsLoading() = runTest { val viewModel = LoginViewModel(mockRepository) viewModel.login("admin", "123456") assertEquals(true, viewModel.isLoading.value) }这里有两个关键点:
Dispatchers.setMain必须在测试运行前替换,测试结束后用Dispatchers.resetMain恢复,避免影响其他测试类。runTest是kotlinx-coroutines-test提供的入口,它会在测试内部创建一个虚拟时间环境,让协程立即执行完毕,不需要你真的等网络请求完成。
4.2 LiveData怎么测:InstantTaskExecutorRule
LiveData的更新是异步的,而且是跑到主线程的。测试环境下主线程又不存在,所以同样需要一个规则来兜底:
class InstantTaskExecutorRule : TestWatcher() { override fun starting(description: Description) { ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() { override fun executeOnDiskIO(runnable: Runnable) = runnable.run() override fun postToMainThread(runnable: Runnable) = runnable.run() override fun isMainThread(): Boolean = true }) } override fun finished(description: Description) { ArchTaskExecutor.getInstance().setDelegate(null) } }加上这个规则之后,LiveData的postValue和setValue都会同步执行,你就能在测试里直接读取到最新的值。
还有一个日常高频操作:使用LiveDataTestUtil这类辅助函数同步获取LiveData的当前值,避免每次都要写observeForever那一堆样板代码。
4.3 一个可落地的ViewModel测试模板
把上面这些串起来,一个典型的ViewModel测试长这样:
@RunWith(MockitoJUnitRunner::class) class LoginViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() @Mock private lateinit var repository: LoginRepository private lateinit var viewModel: LoginViewModel @Before fun setUp() { viewModel = LoginViewModel(repository) } @Test fun `login with empty username should fail`() = runTest { viewModel.login("", "123456") assertFalse(viewModel.loginState.value?.isSuccess ?: false) verify(repository, never()).loginRequest(any(), any()) } @Test fun `login with valid input should show loading then success`() = runTest { val response = ApiResponse(true, "token123") `when`(repository.loginRequest("admin", "123456")).thenReturn(response) viewModel.login("admin", "123456") assertTrue(viewModel.loginState.value?.isSuccess == true) verify(repository).loginRequest("admin", "123456") } }这里用了一个特性:Kotlin方法名可以用反引号包起来,写成一句完整的、能读懂的描述。比如`login with empty username should fail`,跑测试的时候报告会非常清晰,一眼就能看出哪个场景出了问题。这个习惯我强烈建议推广到团队里。
5. 想让代码好测,先学会让代码“可测”
写了几年测试之后,我最大的感悟是:如果一个模块很难测,往往不是测试方法不对,而是这个模块的代码设计本身就有问题。反过来,那些容易写测试的代码,通常也是职责清晰、耦合度低的好代码。
5.1 依赖注入是单测的前提
一个类里如果到处是new Xxx()或者直接调用静态方法,那它在测试环境下几乎没法替换依赖。比如下面这种:
public class OrderManager { public boolean submitOrder(Order order) { NetworkApi api = new NetworkApi(); ApiResponse resp = api.submit(order); return resp.isSuccess(); } }如果NetworkApi的构造函数需要初始化一堆网络库,或者它的submit方法真的会发请求,那单测就变成集成测试了。
改成依赖注入后,一切都清爽了:
public class OrderManager { private final NetworkApi mApi; public OrderManager(NetworkApi api) { mApi = api; } public boolean submitOrder(Order order) { return mApi.submit(order).isSuccess(); } }然后在测试里直接new OrderManager(mock(NetworkApi.class))。不需要引入任何DI框架,最简单的构造函数注入就够了。这也是我在团队里一直强调的原则:先把代码改成可以手动注入依赖的形态,复杂场景再考虑引入Hilt等框架。
5.2 把逻辑和Android框架分开
很多代码难测的根源,是把业务逻辑和Android框架代码焊死在了一起。比如在Activity里直接处理金额计算、状态判断等纯逻辑。正确的做法是,把这类逻辑抽到Presenter、ViewModel或者纯Kotlin/Java类里去,让它们不依赖Android SDK。
拿一个最简单的例子:判断用户是否成年。
// 差的设计:逻辑写在Activity里 public void checkAdultButtonClick(String birthDate) { int age = calculateAge(birthDate); if (age >= 18) { mAdultButton.setVisibility(View.VISIBLE); } } // 好的设计:逻辑放到Pure Java类,Activity只负责UI public class UserValidator { public boolean isAdult(String birthDate) { return calculateAge(birthDate) >= 18; } }这样UserValidator在测试里几乎零成本,不需要mock任何Android对象。
5.3 Repository模式的隐藏价值
很多架构文章都在提Repository模式,但很少有人从“可测试性”的角度去理解它。Repository最大的好处是:你的ViewModel只依赖Repository接口,测试时可以无缝替换成FakeRepository。比如:
class FakeUserRepository : UserRepository { private val users = mutableListOf<User>() override suspend fun getUser(id: Long): User { return users.first { it.id == id } } }用FakeRepository而不是Mockito的mock,在业务逻辑复杂的场景下反而更好写、更好维护。因为Fake是真实实现,它会完整走一遍类似生产代码的路径,不像mock那样需要你一个个去when().thenReturn()。
6. 常见问题排查实录:那些年我们踩过的坑
最后分享几个我实际遇到的、并且花了很大力气才查到原因的问题。这些问题在官方文档里不一定会写,但在团队协作时几乎必然出现。
6.1 “Method putString in android.os.Bundle not mocked”
这个错误几乎每个刚开始写本地单测的人都会碰到。原因前面讲过,本地测试环境下的android.jar是有名无实的空壳。解决方案优先选Robolectric,而不是盲目开returnDefaultValues = true。
注意:开
returnDefaultValues是“饮鸩止渴”,它会掩盖内存泄漏、空指针等一系列问题。我建议只在临时验证时用,长期保留会显著降低测试的有效性。
6.2 Mockito的when不生效:comparing different types
有时候你写了when(mock.method(anyString())).thenReturn(xxx),但运行时发现返回的是默认值(null或false)。大概率是调用mock.method时传入的参数类型不匹配,比如实际传了null,而anyString()不匹配null。
调试思路:
- 确认mock对象是同一个,
@InjectMocks注入失败时很容易出现这个问题。可以在测试里打印一下被测对象持有的依赖是否等于mock对象。 - 确认方法没有被
final修饰。新版Mockito虽然支持mock final方法,但默认配置下某些版本依然受限。如果方法被private修饰,when会直接报错。 - 确认是不是调用顺序问题,
when写在被测方法执行之后了。
6.3 协程测试永远卡住,跑完超时
这个问题我吃过不少亏。明明用了runTest,但协程逻辑里如果有delay(1000)或者真实网络请求,虚拟时间只会推进由测试调度器管理的协程,不会等待真实线程池的任务。
解决办法:
- 确保被测代码里的所有调度器都来自
Dispatchers.Main,并且已经被MainDispatcherRule替换。 - 不要直接测试调用真实网络请求的Repository方法,而应该mock掉Repository。
runTest只适用于kotlinx-coroutines-test支持的虚拟时间环境,如果被测代码用的是GlobalScope.launch,虚拟时间不会自动控制它,这时候需要改用Dispatchers.setMain配合runBlocking等待。
6.4 Android资源文件在测试中为null
本地测试访问R.string.xxx或者context.getResources()返回null,这也是高频问题。Robolectric能处理大部分资源访问,但如果你只是在纯JUnit测试里创建了一个Context的mock,那getResources()必然返回null。
我的建议是:所有跟资源打交道的测试,都走Robolectric。如果只是传个String给你,那就不需要Context,直接把String作为参数传进去,越简单越好测。
6.5 一个特别容易被忽略的问题:测试之间状态污染
测试类里的静态变量、单例对象、数据库连接,如果一个测试没清理干净,会悄悄影响后面测试的结果。特别是加上了协程和LiveData规则之后,Dispatchers.Main和ArchTaskExecutor都是全局状态,没在tearDown里恢复原状的话,测试顺序一变化,结果就飘了。
所以我建议团队坚持几条纪律:
- 每个测试类尽量只测一个被测类,不要混着测别的类。
@Before里初始化的对象,@After里如果想清理就一定要清理干净,别图省事。- 静态工具类如果内部持有可变状态,要么不用,要么测试里串行执行。
- 定期跑一次全量测试,不要只看单个测试类绿了就以为万事大吉。
6.6 问题速查表
| 报错信息 | 可能原因 | 解决方案 |
|---|---|---|
| Method xxx in android.util.Log not mocked | 本地测试调用Android SDK | 引入Robolectric;临时可用returnDefaultValues |
| Module with the Main dispatcher had failed to initialize | 协程用到Main调度器 | 添加MainDispatcherRule替换Dispatchers.Main |
| LiveData value is always null | LiveData赋值异步 | 添加InstantTaskExecutorRule |
| Mockito when() returns default value | mock对象和被测对象依赖不是同一个;final方法 | 检查@InjectMocks注入;确认版本支持 |
| test timed out after 10000 ms | 真实网络或delay未被替换 | mock掉Repository,确保走虚拟时间 |
| java.lang.RuntimeException: Method getResources not mocked | 使用Context资源 | 改用Robolectric测试 |
7. 把单测真正落地到团队开发流程里
写测试这件事,最难的不是技术本身,而是能不能坚持下来。我在团队里推过几轮,最大的感受是:必须把“测试覆盖率”和“测试有效性”分开来看。覆盖率只是一个数字,如果测试断言全是assertNotNull,覆盖率再高也没用。所谓测试有效性,就是这些测试能不能在你改代码的时候,精准地告诉你哪里被你改坏了。
7.1 从今天开始可以马上动起来的建议
不管你的项目是Java还是Kotlin,我建议从这三个动作开始:
- 挑一个近期改动频繁或之前在线上出过bug的工具类,把核心逻辑全部用JUnit4补上测试。
- 给项目加上
MainDispatcherRule和InstantTaskExecutorRule,把ViewModel的通用测试模板沉淀到项目里。 - 把测试任务绑定到CI上。哪怕只跑本地单元测试,也会让团队形成习惯。没有CI持续监督,人都有惰性,时间一长测试就变成摆设。
7.2 对“测试效率”的一点个人看法
总是有人争论单元测试和仪器化测试谁更重要。我的观点是:核心业务逻辑用单测覆盖,UI交互和系统集成场景用仪器化测试补足,两者各司其职。不必强求100%覆盖率,但核心路径必须要有测试保护。特别是重构的时候,有测试兜底和没有测试兜底,完全是两种心态。
8. 写在最后:测试的根本目的是高质量的代码
如果非要总结一句,我可以很肯定地说:写单元测试的过程,会让你发现自己代码里的坏味道。耦合度高、依赖混乱、可读性差,这些平时被忽略的问题,在写测试时都会原形毕露。
我曾经接手过一个模块,代码大概2000多行,想补测试不知道怎么下手。后来花了一个星期重构,把大方法拆成小函数、把依赖抽到构造函数里,测试补到300多个。从那以后,这个模块再也没出过大的回归问题。而这300多个测试里,我觉得最值钱的反而不是那些跑得飞快的用例,而是写测试逼着我重构的过程。
所以,不要把这篇文章看成一个单测教程,我更希望它能给你一个信号:从今天起,找个最短小的类,写几个测试,感受一下这种“把代码牢牢捏在自己手里”的感觉。你会上瘾的。