单元测试基础:到底什么是单元测试?
你可能听过很多次“单元测试”这四个字。很多新同事问我:不就是写个方法跑一下吗?其实没那么简单。我个人习惯把单元测试看作是一面“代码反光镜”——它能照出你在写逻辑时没注意到的细节。说白了,单元测试是对代码中最小的可测试单元(通常是一个方法或一个类)进行验证的过程。你可以想象成盖楼时先检查每一块砖的质量,而不是等整栋楼盖完才发现砖是脆的。
单元测试的核心:隔离与可重复
嗯,这里要注意。单元测试有两个关键特征:隔离和可重复。什么叫隔离?就是不依赖外部系统——数据库、网络、文件系统、其他服务。如果跑一个测试,它还要连真实数据库,那就不叫单元测试了,那是集成测试。我在项目中遇到过很多次,团队把集成测试当成单元测试跑,结果每次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'
一个完整的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每个@Test方法都会重新new一个测试类实例。为什么要这样做?就是为了完全隔离,防止测试之间共享状态。我以前写过一个测试,里面放了一个静态HashMap,结果测试顺序一变就挂,排查了好久。后来改成每个测试独立实例,才彻底解决。
避坑指南:单元测试中的常见坑
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 试试手,跑一下看看绿条亮起来的成就感。