Android单元测试实战:从环境搭建到架构设计的完整指南

发布时间:2026/9/20 10:36:26
Android单元测试实战:从环境搭建到架构设计的完整指南 说实话刚接手团队那会儿一听到“保证测试覆盖率”这种要求我就头疼。以前写功能代码有多爽补单元测试就有多痛苦。但真正静下心来把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 trueAndroid 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的核心思想很简单用一个假的替身对象代替真实依赖然后定义替身的行为最后验证替身有没有被正确调用。先看一个最典型的用法我们需要测试一个LoginViewModelpublic 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); ArgumentCaptorString 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怎么测InstantTaskExecutorRuleLiveData的更新是异步的而且是跑到主线程的。测试环境下主线程又不存在所以同样需要一个规则来兜底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 mutableListOfUser() 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临时可用returnDefaultValuesModule with the Main dispatcher had failed to initialize协程用到Main调度器添加MainDispatcherRule替换Dispatchers.MainLiveData value is always nullLiveData赋值异步添加InstantTaskExecutorRuleMockito when() returns default valuemock对象和被测对象依赖不是同一个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多个测试里我觉得最值钱的反而不是那些跑得飞快的用例而是写测试逼着我重构的过程。所以不要把这篇文章看成一个单测教程我更希望它能给你一个信号从今天起找个最短小的类写几个测试感受一下这种“把代码牢牢捏在自己手里”的感觉。你会上瘾的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询