第77章 单元测试:JUnit4、Mockito、Robolectric、测试Activity与Fragment

单元测试这件事,说实话,我入行前三年基本没怎么认真写过。那时候总觉得「功能跑通了就行」,直到有一次重构一个支付模块,改了一行代码,结果三个页面同时崩溃……嗯,从那以后,我再也不敢不写单元测试了。

这一章,我们来聊聊 Android 单元测试的四大金刚:JUnit4、Mockito、Robolectric,以及如何测试 Activity 和 Fragment。我会把我在项目中踩过的坑、总结的经验,一并倒出来。

核心观点:单元测试不是为了测试而测试,它是你代码的「安全网」。没有这张网,重构就是走钢丝。

77.1 测试金字塔与 Android 测试分层

先看一张图,这是我每次给团队做技术分享时必画的。

Android 测试金字塔 单元测试(Unit Tests) JUnit4 + Mockito + Robolectric 占比 70% · 速度快 · 隔离性强 集成测试(Integration Tests) Activity/Fragment 测试 · 占比 20% UI 测试(E2E) Espresso · 占比 10% 执行速度:快 → 慢 隔离性:高 → 低 本章重点:底层单元测试 + 中层集成测试

你想想看,金字塔底层是单元测试,跑得最快、最稳定。我个人的习惯是:业务逻辑层和数据层必须全覆盖,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 框架的类,比如 ContextIntentSharedPreferences。这些类在 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