测试:JUnit4 + Mockito + Espresso
Android 测试体系:测试金字塔、JUnit4 单元测试、Mockito 桩件与验证、Espresso UI 测试、Room 测试、Robolectric 与测试覆盖率分析。
学习目标
- 理解 Android 测试金字塔与各类测试的边界
- 掌握 JUnit4 注解与断言
- 能够用 Mockito 创建桩件、设置行为、验证调用
- 用 Espresso 编写 UI 自动化测试
- 了解 Room 测试、Robolectric 与测试覆盖率
学习目标
- 理解 Android 测试金字塔与各类测试的边界;
- 掌握 JUnit4 注解与断言;
- 能够用 Mockito 创建桩件、设置行为、验证调用;
- 用 Espresso 编写 UI 自动化测试;
- 了解 Room 测试、Robolectric 与测试覆盖率。
测试:JUnit4 + Mockito + Espresso
测试金字塔
Android 测试遵循经典金字塔模型:
┌──────┐
│ UI │ 少量,慢(Espresso,需设备)
┌──┴──────┴──┐
│ Integration │ 中量,中速(Robolectric + Room)
┌──┴──────────────┴──┐
│ Unit Tests │ 大量,快(JUnit4 + Mockito)
└────────────────────┘
- 单元测试(Unit Test):JVM 上运行,使用 JUnit4 + Mockito,覆盖业务逻辑、ViewModel、Presenter;
- 集成测试(Integration):需要 Android 框架部分能力,使用 Robolectric 在 JVM 模拟 Android 组件,或使用真实 Room 数据库;
- UI 测试(UI Test):在设备/模拟器运行,使用 Espresso 操作真实界面,验证端到端行为。
依赖配置
android {
compileSdk 34
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
testOptions {
unitTests.includeAndroidResources = true // Robolectric
}
}
dependencies {
implementation "androidx.appcompat:appcompat:1.7.0"
// ---- JUnit4 单元测试 ----
testImplementation "junit:junit:4.13.2"
testImplementation "org.mockito:mockito-core:5.12.0"
// ---- Robolectric(让 JVM 跑 Android 类)----
testImplementation "org.robolectric:robolectric:4.13"
testImplementation "androidx.test:core:1.6.1"
testImplementation "androidx.test.ext:junit:1.2.1"
testImplementation "androidx.arch.core:core-testing:2.2.0" // 测试 LiveData
// ---- Espresso UI 测试 ----
androidTestImplementation "androidx.test.ext:junit:1.2.1"
androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1"
androidTestImplementation "androidx.test:runner:1.6.1"
androidTestImplementation "androidx.test:rules:1.6.1"
// 真实 Room(仪器测试用)
androidTestImplementation "androidx.room:room-testing:2.6.1"
}
要点:
testImplementation用于 JVM 测试(src/test/);androidTestImplementation用于仪器测试(src/androidTest/),需要在设备/模拟器运行。
JUnit4
基本注解
public class CalculatorTest {
private Calculator calc;
@Before
public void setUp() {
// 每个测试方法前执行
calc = new Calculator();
}
@After
public void tearDown() {
// 每个测试方法后执行
calc = null;
}
@Test
public void add_twoNumbers_returnsSum() {
assertEquals(5, calc.add(2, 3));
}
@Test
public void divide_byZero_throwsException() {
assertThrows(ArithmeticException.class, () -> calc.divide(10, 0));
}
@Test
@Ignore("暂不验证")
public void pendingTest() { }
@AfterClass
public static void afterAll() { /* 类级别,所有测试后执行 */ }
}
常用断言
assertEquals(expected, actual);
assertNotEquals(unexpected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);
assertSame(expected, actual); // 引用相等
assertArrayEquals(expectedArray, actualArray);
@Rule
@Rule 用于复用测试基础设施。一个常用例子是测试 LiveData 的 InstantTaskExecutorRule:
public class LoginViewModelTest {
@Rule
public InstantTaskExecutorRule rule = new InstantTaskExecutorRule();
@Test
public void loginSuccess_updatesStateToSuccess() {
LoginViewModel vm = new LoginViewModel();
vm.login("admin", "123456");
assertEquals(UiState.SUCCESS, vm.getState().getValue());
}
}
InstantTaskExecutorRule 让 LiveData 的异步任务在主线程同步执行,避免测试卡死。
Mockito
mock:创建桩件
mock 创建一个类的桩件,所有方法默认返回默认值(对象返回 null、int 返回 0):
public class LoginManagerTest {
@Test
public void login_withValidCredentials_returnsToken() {
HttpService http = mock(HttpService.class); // 桩件
UserCache cache = mock(UserCache.class);
LoginManager mgr = new LoginManager(http, cache);
// ...
}
}
也可以使用 @Mock 注解 + MockitoAnnotations.openMocks(this) 或 MockitoExtension:
@ExtendWith(MockitoExtension.class)
public class LoginManagerTest {
@Mock HttpService http;
@Mock UserCache cache;
@InjectMocks LoginManager mgr;
@Test
public void testLogin() { /* ... */ }
}
when:设置行为
when(http.post("/login", body))
.thenReturn(new Response(200, "token-abc"));
when(cache.get("user"))
.thenReturn(new User(1, "admin"))
.thenReturn(null); // 第二次返回 null
// 抛异常
when(http.get("/users/0"))
.thenThrow(new IOException("网络错误"));
// 无返回值方法(void)
doNothing().when(cache).clear();
verify:验证调用
vm.login("admin", "123456");
verify(http).post("/login", argThat(b -> b.username.equals("admin")));
verify(cache).put(eq("user"), any(User.class));
verify(cache, never()).clear(); // 验证从未调用
verify(http, times(2)).get(anyString()); // 验证调用次数
verify(http, atLeastOnce()).get(anyString());
verify(http, timeout(100)).get(anyString()); // 异步:100ms 内被调用
参数匹配器:eq / any / anyString / anyInt / argThat。一旦用了匹配器,所有参数都必须用匹配器。
ArgumentCaptor:捕获参数做断言
@Mock UserCache cache;
@InjectMocks LoginManager mgr;
@Test
public void loginSuccess_savesUserToCache() {
when(http.post(anyString(), any())).thenReturn(new Response(200, "token-abc"));
mgr.login("admin", "123456");
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(cache).put(eq("user"), captor.capture());
User saved = captor.getValue();
assertEquals("admin", saved.username);
}
spy:部分桩件
spy 包装真实对象,未 stub 的方法走真实逻辑:
List<String> real = new ArrayList<>();
List<String> spy = spy(real);
when(spy.size()).thenReturn(100); // 仅 size 被 stub
spy.add("a");
assertEquals(100, spy.size()); // 桩件返回
assertEquals(1, real.size()); // 真实 add 已发生
注意:stub spy 的真实方法时用 doReturn().when(spy).method(),避免 when(spy.method()).thenReturn() 触发真实方法调用。
Espresso UI 测试
基本结构
Espresso 测试在设备/模拟器运行,使用 onView 定位、perform 操作、check 断言:
@RunWith(AndroidJUnit4.class)
public class LoginActivityTest {
@Rule
public ActivityScenarioRule<LoginActivity> rule =
new ActivityScenarioRule<>(LoginActivity.class);
@Test
public void loginSuccess_navigatesToHome() {
// 输入用户名
onView(withId(R.id.et_username))
.perform(typeText("admin"), closeSoftKeyboard());
// 输入密码
onView(withId(R.id.et_password))
.perform(typeText("123456"), closeSoftKeyboard());
// 点击登录
onView(withId(R.id.btn_login)).perform(click());
// 验证结果文本
onView(withId(R.id.tv_result))
.check(matches(withText(containsString("成功"))));
}
}
@Rule ActivityScenarioRule 在每个测试前启动 Activity,结束后自动清理。
常用匹配器与操作
// 定位
onView(withId(R.id.btn_login));
onView(withText("登录"));
onView(withHint("请输入用户名"));
onView(allOf(withId(R.id.tv), isDisplayed()));
// 操作
.perform(click())
.perform(typeText("admin"))
.perform(clearText())
.perform(replaceText("hello"))
.perform(scrollTo())
.perform(pressBack())
.perform(closeSoftKeyboard())
// 断言
.check(matches(isDisplayed()))
.check(matches(withText("成功")))
.check(matches(not(isEnabled())))
.check(matches(hasErrorText("用户名不能为空")))
// RecyclerView
onView(withId(R.id.rv_users))
.perform(RecyclerViewActions.actionOnItemAtPosition(0, click()));
IdlingResource:等待异步
异步加载完成后才能断言 UI,否则测试不稳定。用 IdlingResource 让 Espresso 等待:
public class LoginIdlingResource implements IdlingResource {
private volatile boolean idle = false;
private ResourceCallback callback;
public void setIdle(boolean idle) {
this.idle = idle;
if (idle && callback != null) callback.onTransitionToIdle();
}
@Override public boolean isIdleNow() { return idle; }
@Override public void registerIdleTransitionCallback(ResourceCallback cb) {
this.callback = cb;
}
@Override public String getName() { return "LoginIdlingResource"; }
}
在 Activity 中通过 IdlingRegistry.getInstance().register(...) 注册并 setIdle(true) 标记完成。
测试 LiveData / ViewModel
测试 ViewModel 时用 InstantTaskExecutorRule + Mockito:
public class UserListViewModelTest {
@Rule
public InstantTaskExecutorRule rule = new InstantTaskExecutorRule();
@Mock UserRepo repo;
@InjectMocks UserListViewModel vm;
@Test
public void refresh_loadsUsers_success() {
List<User> fake = Arrays.asList(new User(1, "a"), new User(2, "b"));
when(repo.fetchUsers()).thenReturn(new MutableLiveData<>(fake));
vm.refresh();
UserListState s = vm.getState().getValue();
assertNotNull(s);
assertFalse(s.loading);
assertEquals(2, s.data.size());
assertNull(s.error);
}
}
注意 LiveData 在测试中必须配合 InstantTaskExecutorRule,否则异步 postValue 不会触发回调。
Room 测试
Room DAO 测试可以走两种路线:
- JVM 测试:使用 Robolectric + 内存数据库;
- 仪器测试:使用真实设备数据库。
下面用仪器测试示例(推荐,最贴近真实行为):
@RunWith(AndroidJUnit4.class)
public class UserDaoTest {
private AppDatabase db;
private UserDao dao;
@Before
public void setUp() {
Context ctx = ApplicationProvider.getApplicationContext();
db = Room.inMemoryDatabaseBuilder(ctx, AppDatabase.class)
.allowMainThreadQueries() // 仅测试用
.build();
dao = db.userDao();
}
@After
public void tearDown() {
db.close();
}
@Test
public void insertUser_thenQueryById_returnsUser() {
dao.insert(new User(1, "admin"));
User loaded = dao.findById(1);
assertNotNull(loaded);
assertEquals("admin", loaded.username);
}
@Test
public void delete_removesUser() {
dao.insert(new User(1, "admin"));
dao.deleteById(1);
assertNull(dao.findById(1));
}
}
inMemoryDatabaseBuilder 让数据库仅在内存中,测试结束销毁,互不干扰。
Robolectric
Robolectric 在 JVM 上模拟 Android 框架(Context、Activity、SharedPreferences 等),让大量集成测试可以在本地 JVM 跑:
@RunWith(RobolectricTestRunner.class)
public class SharedPreferencesTest {
@Test
public void putString_persistsValue() {
Context ctx = ApplicationProvider.getApplicationContext();
SharedPreferences sp = ctx.getSharedPreferences("test", MODE_PRIVATE);
sp.edit().putString("name", "admin").apply();
assertEquals("admin", sp.getString("name", null));
}
}
@Config(sdk = {33}) 可指定模拟的 SDK 版本。Robolectric 不支持所有原生组件(如 Camera、SurfaceView),需要真实设备的就别用 Robolectric。
测试覆盖率
测试覆盖率衡量测试“跑过”了多少行/分支代码。Java 生态主流工具是 JaCoCo。
plugins {
id "com.android.application" version "8.5.0"
id "jacoco"
}
android { /* ... */ }
task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) {
reports {
xml.required = true
html.required = true
}
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*',
'**/Manifest*.*', '**/*$ViewBinding*.*']
def debugTree = fileTree(dir: "$buildDir/intermediates/javac/debug/classes",
excludes: fileFilter)
classDirectories.setFrom(debugTree)
sourceDirectories.setFrom(["src/main/java"])
executionData.setFrom(fileTree(buildDir).include("**/*.exec"))
}
执行 ./gradlew jacocoTestReport 后在 build/reports/jacoco/jacocoTestReport/html/index.html 查看覆盖率。建议覆盖率门槛设置 60-70%(业务关键路径可达 80%+),不要盲目追求 100%——覆盖率高不等于测试质量高,关键是覆盖核心逻辑与边界条件。
常见坑与最佳实践
- 测试依赖 Android 框架:
src/test/中默认是 JVM 测试,无法直接用Context。需要 Android 类要么换 Robolectric,要么迁到src/androidTest/。 - LiveData 测试不挂 InstantTaskExecutorRule:
postValue异步切到主线程会卡死测试,必须用InstantTaskExecutorRule。 - Mockito 版本与 Java 不匹配:Java 17+ 需 Mockito 5+,否则可能因模块化限制报错。
- 仪器测试不关闭数据库:每个测试要
db.close(),否则下一个用例拿到旧数据或文件锁冲突。 - Espresso 测试不稳定:通常源于异步未等待。用
IdlingResource或Espresso.onIdle()同步,避免Thread.sleep。 - 滥用 verify:测试应当 断言行为结果,而不是验证实现细节(“调用了几次 cache.put”)。Mockito 的
verify用在边界依赖(外部 API 调用)最有价值。 - 测试与生产代码强耦合:测试照搬实现细节,重构时大量失败。测试应面向公共契约,而非私有实现。
- 仪器测试启动真实网络:会让测试慢且不稳定,应通过 DI 替换为 FakeApi(参见第 28 章)。
- 覆盖率指标造假:写一堆空断言提高覆盖率毫无价值,覆盖关键分支与边界条件才有效。
章节小结
本章覆盖了 Android 测试金字塔(单元 / 集成 / UI)、JUnit4 注解与断言、Mockito 桩件与验证、Espresso UI 测试、Room 测试、Robolectric 与 JaCoCo 覆盖率。关键原则:单元测试要快、要独立;UI 测试覆盖端到端核心路径但不求多;用 DI 提供假依赖让测试稳定;测试面向公共契约而非实现细节。
下一章预告
Part 5 至此结束。Part 6 将进入体验优化领域,涵盖动画、自定义 View、性能优化等让你的 App 更流畅、更精致的主题。