单元测试基础:到底什么是单元测试?

你可能听过很多次“单元测试”这四个字。很多新同事问我:不就是写个方法跑一下吗?其实没那么简单。我个人习惯把单元测试看作是一面“代码反光镜”——它能照出你在写逻辑时没注意到的细节。说白了,单元测试是对代码中最小的可测试单元(通常是一个方法或一个类)进行验证的过程。你可以想象成盖楼时先检查每一块砖的质量,而不是等整栋楼盖完才发现砖是脆的。

单元测试的核心:隔离与可重复

嗯,这里要注意。单元测试有两个关键特征:隔离和可重复。什么叫隔离?就是不依赖外部系统——数据库、网络、文件系统、其他服务。如果跑一个测试,它还要连真实数据库,那就不叫单元测试了,那是集成测试。我在项目中遇到过很多次,团队把集成测试当成单元测试跑,结果每次CI都因为网络抖动失败,浪费大量时间。

可重复就更重要了。同一份代码,不管在谁的电脑上跑,不管跑多少遍,结果必须一致。如果你写的测试这次过、下次不过,那它就不是一个好的单元测试。你想想看,如果测试的结果是随机的,那测得再勤快也找不到真正的问题。

JUnit框架:Android测试的主力军

在Android世界,JUnit几乎是标配。JUnit 4和JUnit 5是主流,现在新项目我建议直接用JUnit 5(也叫JUnit Jupiter)。它的核心概念其实不多,你掌握下面几个就够了。

概念 说明 个人经验
@Test 标记一个方法为测试方法 我见过有人把@Test写在类上,那是错的,只用在方法上
@BeforeEach / @BeforeAll 每个测试前/所有测试前执行初始化 记得清理残留数据,否则测试间会互相影响
@AfterEach / @AfterAll 每个测试后/所有测试后执行清理 曾经有个测试没关闭文件流,导致内存泄漏,后来加了这个才解决
Assertions.* 断言方法,用来判断实际结果是否符合预期 assertEquals(expected, actual)是最常用的,顺序别搞反
@DisplayName 给测试取个可读的名字 JUnit 5的新特性,中文名也能用,方便报告
@Disabled 临时跳过某个测试 别滥用,如果你经常跳过测试,说明代码可能已经坏了

项目配置:在Android Studio里拉起JUnit

配置其实很简单。如果你是新建的Android项目,默认已经带了JUnit 4的依赖。但为了更好地支持JUnit 5,我建议手动调整一下。来看build.gradle里的配置(以模块级为例)。

dependencies {
    // JUnit 5
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0'

    // 如果需要参数化测试或更多扩展
    testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.0'
}

注意,这里用的是 testImplementation,只对单元测试目录(src/test/java)生效。如果你要用Mockito,还得再加一行:

testImplementation 'org.mockito:mockito-junit-jupiter:5.5.0'
小提示:配置完成后,记得在Android Studio里点击Sync Now。如果发现JUnit 5的注解不生效,检查一下是否还残留了JUnit 4的依赖(比如androidx.test.ext:junit),优先移除它们。

一个完整的JUnit 5测试示例

光说不练假把式。我们来写一个真实的单元测试。假设我们有一个简单的计算器类 Calculator,它有一个加法方法。

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

对应的JUnit 5测试类长这样:

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("计算器单元测试")
class CalculatorTest {

    @Test
    @DisplayName("加法:正数加法应返回正确和")
    void testAdd_PositiveNumbers() {
        Calculator calc = new Calculator();
        int result = calc.add(3, 5);
        assertEquals(8, result, "3 + 5 应该等于 8");
    }

    @Test
    @DisplayName("加法:负数相加应返回正确结果")
    void testAdd_NegativeNumbers() {
        Calculator calc = new Calculator();
        int result = calc.add(-2, -7);
        assertEquals(-9, result);
    }
}

你看,每个测试方法只测一个场景,方法名最好能表达测试意图。我在项目中习惯用 test[方法名]_[场景] 的命名方式,配合 @DisplayName,跑测试时一眼就能看出哪个用例挂了。

JUnit 核心生命周期与流程

把整个流程画成图会更好理解。下面是我为本节梳理的JUnit 5单元测试执行流程。

JUnit 5 单元测试执行流程 编译 & 发现 @Test 方法 实例化测试类 (每个@Test一次) @BeforeEach 初始化 执行 @Test 方法 @AfterEach 清理 输出测试报告 (PASS / FAIL) 注意 每个@Test都会 重新实例化测试类 保证隔离性

这个流程里,最容易被忽略的是“实例化测试类”这一步。JUnit每个@Test方法都会重新new一个测试类实例。为什么要这样做?就是为了完全隔离,防止测试之间共享状态。我以前写过一个测试,里面放了一个静态HashMap,结果测试顺序一变就挂,排查了好久。后来改成每个测试独立实例,才彻底解决。

避坑指南:单元测试中的常见坑

曾经我踩过的一个大坑:在JUnit 4时代,如果测试方法没有抛出异常但是声明了 throws Exception,JUnit会强制捕获然后吞掉,导致测试永远通过。后来升级到JUnit 5,这个行为变了,但依然建议不要滥用异常声明。另一个常见问题是:测私有方法。单元测试应该只测公有接口,私有方法属于实现细节,测它反而是过度耦合。如果私有方法很重要,把它提取到一个新类中再测。
一个小技巧:用 @DisplayName 写中文描述,方便你半年后回来看报告时迅速理解。比如 @DisplayName("用户输入空字符串时返回默认值")。别用一串英文驼峰命名做测试名,那读起来像在猜谜。

JUnit 与 Android 测试环境的关系

在Android项目中,单元测试跑在本地JVM上,也就是你的电脑,不需要模拟器。所以你写的测试代码不能依赖Android框架API(比如Context、Activity)。如果确实需要,可以借助Robolectric或者Mockito来模拟。这个后面我会详细讲。做配置时,记得把测试源文件放在 src/test/java 目录下,而不是 src/androidTest/java。前者是单元测试,后者是插桩测试。

最后,再说一个个人经验:刚开始写单元测试时,别想着覆盖率100%。先给核心逻辑、边界条件、异常分支写测试。等你习惯了,再慢慢补全。我见过太多人一上来就想测所有代码,结果坚持两周就放弃了。

好了,关于单元测试的基础,JUnit框架的核心概念和项目配置,就讲这些。你可以在自己项目里先创建一个 CalculatorTest 试试手,跑一下看看绿条亮起来的成就感。