第77章 单元测试:JUnit4、Mockito、Robolectric、测试Activity与Fragment
单元测试这件事,说实话,我入行前三年基本没怎么认真写过。那时候总觉得「功能跑通了就行」,直到有一次重构一个支付模块,改了一行代码,结果三个页面同时崩溃……嗯,从那以后,我再也不敢不写单元测试了。
这一章,我们来聊聊 Android 单元测试的四大金刚:JUnit4、Mockito、Robolectric,以及如何测试 Activity 和 Fragment。我会把我在项目中踩过的坑、总结的经验,一并倒出来。
核心观点:单元测试不是为了测试而测试,它是你代码的「安全网」。没有这张网,重构就是走钢丝。
77.1 测试金字塔与 Android 测试分层
先看一张图,这是我每次给团队做技术分享时必画的。
你想想看,金字塔底层是单元测试,跑得最快、最稳定。我个人的习惯是:业务逻辑层和数据层必须全覆盖,UI 层只测关键路径。为什么?因为 UI 测试太慢了,跑一次要等几分钟,你受不了的。
77.2 JUnit4:一切测试的基石
JUnit4 是 Java 生态最基础的测试框架。说白了,它就是帮你「写断言、跑用例、看结果」的工具。
77.2.1 核心注解
| 注解 | 作用 | 执行次数 |
|---|---|---|
@Test |
标记一个测试方法 | 每个用例执行一次 |
@Before |
每个测试方法前执行 | 每个用例前执行一次 |
@After |
每个测试方法后执行 | 每个用例后执行一次 |
@BeforeClass |
所有测试前执行一次 | 整个类只执行一次 |
@AfterClass |
所有测试后执行一次 | 整个类只执行一次 |
@Ignore |
跳过该测试方法 | 不执行 |
77.2.2 断言方法
JUnit4 提供了一组 assertXxx 方法,用来验证结果是否符合预期。我最常用的是这几个:
import org.junit.Test;
import static org.junit.Assert.*;
public class CalculatorTest {
@Test
public void addition_shouldReturnCorrectSum() {
Calculator calc = new Calculator();
int result = calc.add(3, 5);
assertEquals("3 + 5 应该等于 8", 8, result);
}
@Test
public void division_byZero_shouldThrowException() {
Calculator calc = new Calculator();
assertThrows(ArithmeticException.class, () -> calc.divide(10, 0));
}
@Test
public void list_shouldNotBeNull() {
List<String> list = new ArrayList<>();
assertNotNull("列表不应该为 null", list);
assertTrue("列表应该为空", list.isEmpty());
}
}
我的小技巧:断言时一定要加 message 参数。比如 assertEquals("3 + 5 应该等于 8", 8, result)。这样测试失败时,你能一眼看出哪里出了问题,不用再去翻代码。
77.3 Mockito:隔离依赖的利器
真实项目中,一个类往往依赖网络请求、数据库、SharedPreferences 等外部组件。你不可能在单元测试里真的去联网、读写数据库——那样太慢了,而且不稳定。
Mockito 就是用来解决这个问题的。它可以「模拟」一个对象,让你控制它的行为。
77.3.1 基本用法
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import static org.mockito.Mockito.*;
@RunWith(MockitoJUnitRunner.class)
public class LoginViewModelTest {
@Mock
private UserRepository mockRepository;
@Mock
private LoginCallback mockCallback;
@Test
public void login_withValidCredentials_shouldCallSuccess() {
// 准备:模拟仓库返回成功
when(mockRepository.login("admin", "123456"))
.thenReturn(new User("admin", "token_abc"));
// 执行
LoginViewModel viewModel = new LoginViewModel(mockRepository);
viewModel.login("admin", "123456", mockCallback);
// 验证
verify(mockCallback).onSuccess(any(User.class));
verify(mockCallback, never()).onError(anyString());
}
@Test
public void login_withInvalidPassword_shouldCallError() {
// 准备:模拟仓库抛出异常
when(mockRepository.login("admin", "wrong"))
.thenThrow(new AuthException("密码错误"));
// 执行
LoginViewModel viewModel = new LoginViewModel(mockRepository);
viewModel.login("admin", "wrong", mockCallback);
// 验证
verify(mockCallback).onError("密码错误");
verify(mockCallback, never()).onSuccess(any());
}
}
77.3.2 常用 Mockito 方法速查
| 方法 | 用途 |
|---|---|
mock(Class) |
创建一个 Mock 对象 |
when(...).thenReturn(...) |
指定方法调用时返回固定值 |
when(...).thenThrow(...) |
指定方法调用时抛出异常 |
verify(mock).method() |
验证某个方法是否被调用过 |
verify(mock, times(n)) |
验证方法被调用了 n 次 |
verify(mock, never()) |
验证方法从未被调用 |
any(), anyString() |
匹配任意参数 |
ArgumentCaptor |
捕获方法参数,用于后续断言 |
我曾经踩过的坑:用 when(mock.method()).thenReturn(value) 时,如果 method 是 void 类型,会编译报错。void 方法要用 doReturn(value).when(mock).method() 这种写法。这个细节坑了我整整一个下午。
77.4 Robolectric:在 JVM 上跑 Android 测试
Android 的单元测试有个痛点:很多代码依赖 Android 框架的类,比如 Context、Intent、SharedPreferences。这些类在 JVM 上跑不了,因为它们是 Android 系统的。
Robolectric 就是来解决这个问题的。它模拟了 Android 框架的核心类,让你可以在开发机上直接跑测试,不需要模拟器。
77.4.1 配置与基本使用
// build.gradle 依赖
testImplementation 'org.robolectric:robolectric:4.10.3'
testImplementation 'androidx.test:core:1.5.0'
// 测试代码
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 33) // 指定 Android SDK 版本
public class SharedPreferencesTest {
@Test
public void saveAndRead_shouldWorkCorrectly() {
// 获取模拟的 Context
Context context = RuntimeEnvironment.getApplication();
// 使用 SharedPreferences
SharedPreferences prefs = context.getSharedPreferences("test", Context.MODE_PRIVATE);
prefs.edit().putString("key", "hello_robolectric").apply();
String value = prefs.getString("key", "");
assertEquals("hello_robolectric", value);
}
}
77.4.2 Robolectric 能做什么
- 模拟 Context、Application、Resources:可以读取资源文件、字符串、颜色等
- 模拟 Activity 生命周期:可以创建 Activity、调用 onCreate/onResume 等
- 模拟 Intent、Bundle:可以验证跳转逻辑
- 模拟 SharedPreferences、数据库:可以测试数据持久化
- 模拟 Toast、Dialog:可以验证 UI 交互
我的建议:Robolectric 适合测试「与 Android 框架交互」的逻辑,比如 ViewModel 里用到了 Application Context。但不要用它来测试纯 Java/Kotlin 逻辑——那种用 JUnit4 就够了,跑得更快。
77.5 测试 Activity 与 Fragment
这部分是很多人的痛点。Activity 和 Fragment 有复杂的生命周期,怎么测?
我的答案是:用 Robolectric + 官方测试框架。
77.5.1 测试 Activity
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.android.controller.ActivityController;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 33)
public class MainActivityTest {
@Test
public void activity_shouldDisplayTitle() {
// 创建 Activity 并控制生命周期
ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class);
controller.create().start().resume();
MainActivity activity = controller.get();
TextView titleView = activity.findViewById(R.id.tv_title);
assertNotNull("标题控件不应为 null", titleView);
assertEquals("欢迎", titleView.getText().toString());
}
@Test
public void buttonClick_shouldNavigateToDetail() {
// 创建 Activity
ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class);
controller.create().start().resume();
MainActivity activity = controller.get();
Button btn = activity.findViewById(R.id.btn_detail);
// 模拟点击
btn.performClick();
// 验证跳转
Intent expectedIntent = new Intent(activity, DetailActivity.class);
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
Intent actualIntent = shadowActivity.getNextStartedActivity();
assertNotNull("应该触发跳转", actualIntent);
assertEquals(expectedIntent.getComponent(), actualIntent.getComponent());
}
}
77.5.2 测试 Fragment
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import androidx.fragment.app.testing.FragmentScenario;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 33)
public class ProfileFragmentTest {
@Test
public void fragment_shouldDisplayUserName() {
// 使用 FragmentScenario 启动 Fragment
FragmentScenario<ProfileFragment> scenario =
FragmentScenario.launchInContainer(ProfileFragment.class);
// 验证 UI
scenario.onFragment(fragment -> {
TextView nameView = fragment.getView().findViewById(R.id.tv_name);
assertNotNull(nameView);
assertEquals("张三", nameView.getText().toString());
});
}
@Test
public void fragment_withArgs_shouldDisplayCorrectData() {
// 传入参数
Bundle args = new Bundle();
args.putString("user_id", "12345");
FragmentScenario<ProfileFragment> scenario =
FragmentScenario.launchInContainer(
ProfileFragment.class, args);
scenario.onFragment(fragment -> {
// 验证 Fragment 根据参数加载了正确的数据
assertEquals("12345", fragment.getUserId());
});
}
}
77.5.3 测试生命周期场景
Activity 和 Fragment 的生命周期是测试的重点。我遇到过很多 bug,都是因为「页面切到后台再回来,状态丢了」。
@Test
public void activity_shouldSaveStateOnConfigurationChange() {
ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class);
controller.create().start().resume();
MainActivity activity = controller.get();
EditText input = activity.findViewById(R.id.et_input);
input.setText("用户输入的内容");
// 模拟屏幕旋转(销毁重建)
Bundle savedState = new Bundle();
controller.saveInstanceState(savedState)
.pause()
.stop()
.destroy();
// 重建 Activity
ActivityController<MainActivity> newController =
Robolectric.buildActivity(MainActivity.class)
.create(savedState)
.start()
.resume();
MainActivity newActivity = newController.get();
EditText newInput = newActivity.findViewById(R.id.et_input);
assertEquals("屏幕旋转后应该恢复输入内容",
"用户输入的内容", newInput.getText().toString());
}
我曾经踩过的坑:测试 Fragment 时,如果 Fragment 里有 requireActivity() 或 getContext() 调用,一定要确保 Fragment 已经 attach 到 Activity。用 FragmentScenario.launchInContainer() 会自动处理,但如果你手动创建 Fragment,别忘了调用 fragment.onAttach(context)。
77.6 测试覆盖率与最佳实践
测试不是写得越多越好。我见过有人给 getter/setter 也写单元测试——说实话,那是在浪费生命。
77.6.1 哪些代码值得测
| 优先级 | 测试对象 | 举例 |
|---|---|---|
| P0(必须测) | 核心业务逻辑 | 登录验证、支付计算、数据转换 |
| P1(建议测) | 数据层操作 | 数据库读写、网络请求封装 |
| P2(可选测) | UI 交互逻辑 | 按钮点击、页面跳转 |
| P3(不测) | 纯模板代码 | getter/setter、简单布局 |
77.6.2 我的测试习惯
- 先写测试,再写代码:TDD 虽然听起来麻烦,但能帮你设计出更松耦合的代码
- 每个测试只测一件事:方法名要能说清楚测什么,比如
login_withEmptyPassword_shouldShowError - 保持测试独立:测试之间不要有依赖,每个测试都能单独运行
- 用覆盖率工具检查:我一般要求核心模块的代码覆盖率达到 80% 以上
总结一下:JUnit4 是基础,Mockito 帮你隔离依赖,Robolectric 让你在 JVM 上跑 Android 测试。三者配合,可以覆盖 90% 以上的测试场景。Activity 和 Fragment 的测试,重点在于生命周期和状态恢复——这些是线上最容易出问题的地方。
嗯,这一章的内容就到这里。测试这件事,刚开始写会觉得麻烦,但当你经历过一次「改一行代码,跑全部测试,5 秒后知道有没有问题」的爽感后,你就再也回不去了。
公众号:蓝海资料掘金营,微信deep3321