A
第 30 章JAVA45 分钟

测试: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%——覆盖率高不等于测试质量高,关键是覆盖核心逻辑与边界条件。

常见坑与最佳实践

  1. 测试依赖 Android 框架:src/test/ 中默认是 JVM 测试,无法直接用 Context。需要 Android 类要么换 Robolectric,要么迁到 src/androidTest/。
  2. LiveData 测试不挂 InstantTaskExecutorRule:postValue 异步切到主线程会卡死测试,必须用 InstantTaskExecutorRule。
  3. Mockito 版本与 Java 不匹配:Java 17+ 需 Mockito 5+,否则可能因模块化限制报错。
  4. 仪器测试不关闭数据库:每个测试要 db.close(),否则下一个用例拿到旧数据或文件锁冲突。
  5. Espresso 测试不稳定:通常源于异步未等待。用 IdlingResource 或 Espresso.onIdle() 同步,避免 Thread.sleep。
  6. 滥用 verify:测试应当 断言行为结果,而不是验证实现细节(“调用了几次 cache.put”)。Mockito 的 verify 用在边界依赖(外部 API 调用)最有价值。
  7. 测试与生产代码强耦合:测试照搬实现细节,重构时大量失败。测试应面向公共契约,而非私有实现。
  8. 仪器测试启动真实网络:会让测试慢且不稳定,应通过 DI 替换为 FakeApi(参见第 28 章)。
  9. 覆盖率指标造假:写一堆空断言提高覆盖率毫无价值,覆盖关键分支与边界条件才有效。

章节小结

本章覆盖了 Android 测试金字塔(单元 / 集成 / UI)、JUnit4 注解与断言、Mockito 桩件与验证、Espresso UI 测试、Room 测试、Robolectric 与 JaCoCo 覆盖率。关键原则:单元测试要快、要独立;UI 测试覆盖端到端核心路径但不求多;用 DI 提供假依赖让测试稳定;测试面向公共契约而非实现细节。

下一章预告

Part 5 至此结束。Part 6 将进入体验优化领域,涵盖动画、自定义 View、性能优化等让你的 App 更流畅、更精致的主题。