JUnit 单元测试基础 | JavaSE
JUnit 单元测试基础
一、学习目标
学完本章,你应该能够:
- 解释什么是单元测试(Unit Testing),以及“单元”通常指什么。
- 说明只使用
main()方法人工测试代码存在的问题。 - 理解 JUnit、JUnit Platform、JUnit Jupiter 之间的基本关系。
- 在 Java 项目中正确引入 JUnit。
- 使用
@Test编写并运行一个基本测试方法。 - 理解测试代码与业务代码为什么应该分离。
- 能够独立为一个简单业务方法创建对应的测试类。
二、核心知识
2.1 什么是软件测试
我们编写程序以后,一个非常重要的问题是:
怎么证明程序真的按照我们的预期工作?
例如有这样一个方法:
public static int add(int a, int b) {
return a + b;
}
代码看起来非常简单。
但真正开发一个系统的时候,一个方法可能包含:
- 参数校验
- 条件判断
- 集合处理
- 数据计算
- 异常处理
- 数据库调用
- 网络调用
如果修改了一行代码,我们怎么知道有没有破坏之前已经正常工作的功能?
这就是软件测试需要解决的问题。
2.2 什么是单元测试
单元测试(Unit Testing)是:
对程序中的一个较小、相对独立的功能单元进行自动化验证。
在 Java 入门阶段,这个“单元”通常可以理解为:
一个方法
例如:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
我们希望验证:
add(1, 2) → 3
add(0, 0) → 0
add(-1, 1) → 0
这些验证就是针对 add() 方法进行的单元测试。
不过在真实工程中,“单元”并没有绝对固定的定义,也可能是:
- 一个类
- 一个组件中的局部逻辑
- 一个具有明确职责的业务对象
本阶段先把它理解成:
测试一个方法的行为是否符合预期。
2.3 以前我们是怎么测试代码的
在学习 Java 基础时,我们经常这样测试:
public class Calculator {
public static int add(int a, int b) {
return a + b;
}
public static void main(String[] args) {
int result = add(10, 20);
System.out.println(result);
}
}
运行程序后:
30
然后程序员自己观察:
嗯,30,应该对了。
这种方式当然可以使用,但它存在明显问题。
2.4 使用 main 方法测试的问题
问题一:需要人工观察结果
例如:
System.out.println(add(10, 20));
输出:
30
程序本身并不知道:
30 到底是不是正确答案
需要程序员自己判断。
问题二:测试代码污染业务代码
原本:
Calculator
应该负责计算。
结果里面出现大量:
main()
System.out.println()
测试数据
业务逻辑和测试逻辑混在了一起。
问题三:难以批量执行
假设现在有:
UserService
OrderService
PaymentService
FileUtil
StringUtil
Calculator
总共几百个方法。
如果全部依靠:
main()
逐个测试,效率会非常低。
问题四:修改代码以后无法快速回归
假设今天:
100 个方法全部正常
明天修改其中一个公共方法。
这个修改可能影响几十个地方。
如果没有自动化测试,就很难快速判断:
原来的功能有没有被改坏?
问题五:一个异常可能中断后续测试
例如:
public static void main(String[] args) {
testA();
testB();
testC();
}
如果:
testA()
直接抛出异常,后面的测试可能根本没有执行。
2.5 JUnit 是什么
JUnit 是 Java 生态中最重要的自动化测试框架之一。
它允许我们写这样的代码:
@Test
void addShouldReturn30() {
int result = Calculator.add(10, 20);
assertEquals(30, result);
}
JUnit 会自动判断:
期望:30
实际:30
如果相同:
PASS
如果不同:
FAIL
于是:
测试是否成功不再依赖程序员盯着控制台。
2.6 使用 JUnit 的核心优势
可以概括为:
JUnit
│
├── 自动执行测试
├── 自动判断测试结果
├── 可以单独运行某个测试
├── 可以一次运行全部测试
├── 测试之间可以保持相对独立
├── IDE / Maven 可以生成测试结果
└── 可以不断重复运行
这也是自动化测试相比人工测试最大的价值。
2.7 JUnit 的现代体系
现代 JUnit 可以简单理解为三个重要部分:
JUnit
│
├── JUnit Platform
│ ↓
│ 提供测试发现和执行的基础平台
│
├── JUnit Jupiter
│ ↓
│ 编写现代 JUnit 测试使用的 API 与测试引擎
│
└── JUnit Vintage
↓
主要用于兼容旧的 JUnit 3 / JUnit 4 测试
对于我们现在学习 Java:
主要学习 JUnit Jupiter。
我们经常使用的:
@Test
@BeforeEach
@AfterEach
Assertions.assertEquals(...)
都属于 JUnit Jupiter。
三、使用方法
3.1 Maven 项目目录结构
一个典型 Maven 项目:
project
├── pom.xml
└── src
├── main
│ └── java
│ └── com.example
│ └── Calculator.java
│
└── test
└── java
└── com.example
└── CalculatorTest.java
其中:
src/main/java
存放业务代码。
而:
src/test/java
专门存放测试代码。
这是非常重要的工程结构:
业务代码 ≠ 测试代码
3.2 引入 JUnit
JUnit 并不是 JDK 自带 API。
在 Maven 项目中,需要添加测试依赖。
例如:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
这里尤其注意:
<scope>test</scope>
表示这个依赖主要用于:
测试代码的编译和运行
而不会作为正常业务运行依赖使用。
3.3 准备一个业务类
例如:
package com.example;
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
我们的目标是测试:
add()
3.4 创建测试类
在:
src/test/java
创建:
CalculatorTest
代码:
package com.example;
import org.junit.jupiter.api.Test;
class CalculatorTest {
@Test
void add() {
}
}
注意:
@Test
表示:
这个方法是一个测试方法。
JUnit 测试引擎会发现它,并把它作为测试执行。
3.5 编写第一个测试
完整代码:
package com.example;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@Test
void add() {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20);
assertEquals(30, result);
}
}
其中最关键的是:
assertEquals(30, result);
意思是:
我期望结果是 30
JUnit 会比较:
Expected:30
Actual:result
如果:
result == 30
测试通过。
3.6 故意制造一个错误
把业务代码改成:
public int add(int a, int b) {
return a - b;
}
再次运行:
assertEquals(30, calculator.add(10, 20));
实际结果:
-10
JUnit 就会报告:
Expected :30
Actual :-10
这就是自动化测试最核心的思想:
程序自动验证程序。
3.7 如何运行测试
在 IDEA 中通常可以:
方法级运行
点击:
@Test 方法旁边的运行按钮
只运行当前测试。
类级运行
运行:
CalculatorTest
可以执行整个测试类。
整个项目运行
可以执行项目中的全部测试。
Maven 项目还可以使用:
mvn test
Maven 会发现测试代码并执行。
3.8 一个业务方法可以有多个测试
错误示范:
@Test
void add() {
assertEquals(3, calculator.add(1, 2));
}
只验证了一种情况。
更合理的是:
@Test
void addPositiveNumbers() {
assertEquals(30, calculator.add(10, 20));
}
@Test
void addZero() {
assertEquals(10, calculator.add(10, 0));
}
@Test
void addNegativeNumbers() {
assertEquals(-30, calculator.add(-10, -20));
}
因为一个方法往往需要验证多个输入场景。
3.9 测试方法的基本要求
现代 JUnit Jupiter 中,普通测试方法通常写成:
@Test
void testSomething() {
}
一般无需写:
public
测试类也通常不需要:
public
例如:
class CalculatorTest {
@Test
void add() {
}
}
测试方法:
- 不能是
private - 普通
@Test方法不能返回业务结果 - 一般写成实例方法
- 可以在特定扩展机制下接收由 JUnit 解析的参数
因此初学阶段推荐统一采用:
@Test
void methodName() {
}
这是最清晰的形式。
四、原理与进阶
4.1 @Test 到底做了什么
@Test 本质上是一个注解(Annotation)。
例如:
@Test
void add() {
}
JUnit 在运行测试时会:
扫描测试类
↓
发现 @Test
↓
确定测试方法
↓
创建测试实例
↓
调用测试方法
↓
记录执行结果
你可能已经发现:
JUnit 怎么能够“找到一个类中的方法并自动调用”?
这背后会涉及后面马上学习的:
注解 + 反射
所以这里实际上已经开始为:
反射
注解
框架思想
做铺垫。
4.2 为什么 JUnit 可以运行而不需要 main
普通 Java 程序:
main()
↓
程序入口
JUnit 测试并不是通过你自己编写的:
public static void main(String[] args)
启动。
而是:
IDE / Maven
↓
JUnit Platform
↓
测试引擎
↓
发现 @Test
↓
执行测试方法
因此:
测试框架本身充当了程序执行的控制者。
这是一种非常典型的框架思想。
过去:
你调用框架/API
现在:
框架调用你的代码
后续学习 Spring 时你会频繁看到类似思想。
4.3 测试代码也是代码
很多初学者容易产生一个误区:
“测试代码无所谓,反正不是正式代码。”
实际上大型项目中:
测试代码
也是工程代码的一部分。
它同样需要:
- 清晰命名
- 合理结构
- 易于维护
- 可重复运行
- 尽量稳定
- 避免无意义重复
高质量项目经常包含大量测试代码。
五、实践应用
5.1 为什么修改旧代码前最好有测试
假设:
public double calculatePrice(...) {
...
}
已经在线上运行一年。
现在你准备重构它。
最危险的问题并不是:
新代码能不能运行
而是:
以前正确的功能有没有被你改坏
如果存在完整测试:
修改代码
↓
运行全部测试
↓
全部通过
你就获得了一层重要保障。
这叫:
回归测试(Regression Testing)
5.2 单元测试与 Debug 的区别
两者不是同一个概念。
Debug
主要解决:
程序为什么错?
例如使用断点:
变量现在是什么值?
程序走到了哪个分支?
异常在哪里出现?
Unit Test
主要解决:
程序现在对不对?以后改完以后还对不对?
可以这样理解:
JUnit → 自动发现问题
Debug → 人工定位问题
两者通常配合使用。
5.3 单元测试与集成测试
单元测试通常关注:
一个较小功能单元
例如:
calculatePrice()
集成测试(Integration Testing)关注多个组件协作,例如:
Controller
↓
Service
↓
Mapper
↓
Database
整个调用链是否正确。
所以:
单元测试 → 局部正确性
集成测试 → 组件协作正确性
六、常见问题
6.1 JUnit 是 JDK 自带的吗?
不是。
JDK 21 提供 Java 标准平台。
JUnit 是独立测试框架,需要通过:
Maven
Gradle
IDE
引入项目。
6.2 为什么 IDEA 可以直接提示添加 JUnit?
IDEA 可以帮助:
添加测试框架依赖
生成测试类
运行测试
展示测试结果
但这不意味着:
JUnit 属于 JDK。
6.3 测试通过是不是说明程序绝对没有 Bug?
不是。
测试只能说明:
你设计并执行的这些测试场景通过了。
假设只测试:
add(1, 2)
并不能证明:
所有输入都正确
所以后面需要学习:
正常值
边界值
异常值
等测试设计方法。
6.4 测试方法一定必须 public 吗?
现代 JUnit Jupiter:
不需要。
通常推荐:
class CalculatorTest {
@Test
void add() {
}
}
即可。
不要机械套用旧 JUnit 教程中的:
public void testXXX()
规则。
6.5 一个测试失败会导致其他测试都失败吗?
正常情况下:
一个测试 FAIL
不会自动意味着其他测试也是 FAIL。
JUnit 会分别记录测试结果。
但是如果你的测试之间共享了错误的全局状态,就可能出现:
A 测试影响 B 测试
因此测试之间应该尽可能保持独立。
6.6 测试到底应该输出 System.out.println 吗?
可以用于临时调试。
但是:
System.out.println(result);
不能代替:
assertEquals(expected, actual);
测试是否正确应该由:
断言
自动判断,而不是依赖人眼观察。
七、练习与验收
7.1 知识问答
- 什么是单元测试?“单元”通常表示什么粒度?
- 为什么只在
main()方法中测试代码不适合大型项目? - JUnit 能解决传统人工测试中的哪些问题?
@Test的作用是什么?- 为什么测试代码通常放在
src/test/java? - JUnit 是不是 JDK 21 的组成部分?
- 为什么测试方法应该尽可能相互独立?
- 单元测试和 Debug 有什么区别?
- 单元测试和集成测试有什么区别?
- 为什么修改旧代码时自动化测试尤其重要?
7.2 代码阅读
阅读代码:
class CalculatorTest {
@Test
void add() {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20);
assertEquals(30, result);
}
}
回答:
@Test的作用是什么?- 哪一行真正调用了业务代码?
- 哪一行负责判断结果是否正确?
- 如果
result为29,测试结果是什么? - 是否需要自己写
main()方法?
7.3 手写代码
定义:
public class MathUtil {
public static int max(int a, int b) {
return Math.max(a, b);
}
}
要求:
- 创建
MathUtilTest。 - 编写至少三个
@Test方法。 - 分别测试:
- 第一个数较大
- 第二个数较大
- 两个数相等
7.4 Debug
下面代码存在测试设计问题:
@Test
void add() {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20);
System.out.println(result);
}
回答:
- 代码能否运行?
- 它能否自动判断结果是否正确?
- 问题的根本原因是什么?
- 应该使用什么机制修改?
7.5 综合训练
编写:
public class StringUtil {
public static int getLastIndex(String text) {
if (text == null || text.isEmpty()) {
return -1;
}
return text.length() - 1;
}
}
为它编写 JUnit 测试,至少考虑:
普通字符串
单字符字符串
空字符串
null
暂时不要只追求测试数量。
重点思考:
每个测试究竟在验证什么业务规则?
7.6 本章验收
你应该能够在不查看资料的情况下完成:
- 口述什么是单元测试。
- 说出使用
main()手工测试的至少三个问题。 - 解释 JUnit 的核心价值。
- 手写一个
@Test测试方法。 - 在 Maven 项目中指出业务代码与测试代码分别放在哪里。
- 使用 IDEA 或
mvn test执行测试。 - 解释为什么打印结果不能代替断言。