JUnit 断言与测试规范 | JavaSE
JUnit 断言与测试规范
一、学习目标
学完本章,你应该能够:
- 解释什么是断言(Assertion)以及断言为什么是自动化测试的核心。
- 熟练使用
assertEquals()、assertTrue()、assertFalse()、assertNull()、assertNotNull()等常见断言。 - 使用
assertThrows()验证异常行为。 - 正确区分 Expected Value 与 Actual Value。
- 按照 Arrange-Act-Assert 思想组织测试代码。
- 为正常值、边界值和异常值设计测试。
- 编写相互独立、可重复执行并具有明确语义的测试方法。
二、核心知识
2.1 为什么仅仅执行方法还不够
例如:
@Test
void add() {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20);
System.out.println(result);
}
运行以后:
30
这个测试虽然调用了业务代码,但存在一个重要问题:
JUnit 不知道 30 是对还是错。
因此真正的自动化测试必须告诉测试框架:
我期望什么结果
然后让框架自动比较:
期望结果
vs
实际结果
这就是:
断言(Assertion)
2.2 什么是断言
断言可以理解为:
对程序应该满足的条件作出明确声明。
例如:
assertEquals(30, result);
表示:
我断言 result 必须等于 30。
如果:
result == 30
测试继续并最终通过。
如果:
result != 30
测试失败。
2.3 Expected 与 Actual
这是单元测试非常重要的一组概念:
Expected → 期望值
Actual → 实际值
例如:
assertEquals(30, calculator.add(10, 20));
其中:
30 → Expected
calculator.add(10, 20) → Actual
推荐始终保持这种顺序:
assertEquals(expected, actual);
这样测试失败时信息更加清晰。
2.4 自动化测试的完整逻辑
一个真正有意义的测试其实是在描述:
给定某种输入
↓
执行某个行为
↓
应该得到某个结果
例如:
@Test
void addTwoPositiveNumbersShouldReturnTheirSum() {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20);
assertEquals(30, result);
}
背后的逻辑就是:
Given:10 和 20
When :执行 add
Then :结果应该是 30
三、使用方法
3.1 Assertions 类
JUnit Jupiter 的常用断言位于:
org.junit.jupiter.api.Assertions
可以这样使用:
Assertions.assertEquals(30, result);
也可以使用静态导入:
import static org.junit.jupiter.api.Assertions.*;
之后直接写:
assertEquals(30, result);
测试类通常推荐使用静态导入,使代码更接近自然语言。
3.2 assertEquals
用于判断:
期望值 == 实际值
例如:
@Test
void add() {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20);
assertEquals(30, result);
}
字符串也可以:
assertEquals("LingXi", user.getName());
3.3 assertNotEquals
判断:
实际结果不应该等于某值
例如:
assertNotEquals(0, user.getId());
但是如果可以明确知道正确值:
assertEquals(expected, actual);
通常比:
assertNotEquals(...)
表达得更精确。
3.4 assertTrue
判断条件必须为:
true
例如:
@Test
void adultUserShouldBeAdult() {
User user = new User(20);
boolean result = user.isAdult();
assertTrue(result);
}
也可以:
assertTrue(age >= 18);
3.5 assertFalse
要求:
条件必须为 false
例如:
assertFalse(user.isDeleted());
3.6 assertNull
判断对象必须为:
null
例如:
assertNull(repository.findById(-1));
3.7 assertNotNull
判断对象不能为:
null
例如:
User user = repository.findById(1);
assertNotNull(user);
3.8 assertSame 与 assertNotSame
assertSame() 判断的是:
两个引用是否指向同一个对象。
例如:
Object object = new Object();
assertSame(object, object);
注意它和:
assertEquals()
不是同一个概念。
可以粗略理解:
assertEquals → 比较“是否相等”
assertSame → 比较“是不是同一个对象”
这与之前学习:
equals()
==
对象引用
的知识直接关联。
3.9 给断言添加失败信息
例如:
assertEquals(
30,
result,
"10 + 20 的结果应该为 30"
);
当测试失败时,这段信息可以帮助定位问题。
不过不要写没有价值的信息:
assertEquals(30, result, "错了");
更好的消息应该说明:
哪条业务规则没有满足
3.10 assertThrows:测试异常
异常行为同样属于程序行为。
例如:
public class Calculator {
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("除数不能为0");
}
return a / b;
}
}
当:
divide(10, 0)
时,正确行为不是返回一个数字。
而应该:
抛出 IllegalArgumentException
因此测试应该写:
@Test
void divideByZeroShouldThrowException() {
Calculator calculator = new Calculator();
assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
}
这里:
IllegalArgumentException.class
表示期望异常类型。
Lambda:
() -> calculator.divide(10, 0)
表示需要执行并观察是否抛异常的代码。
3.11 进一步检查异常内容
assertThrows() 会返回捕获到的异常对象:
@Test
void divideByZeroShouldThrowException() {
Calculator calculator = new Calculator();
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
assertEquals("除数不能为0", exception.getMessage());
}
现在我们同时验证了:
异常类型正确
+
异常信息正确
3.12 assertAll
有时我们需要验证一个对象多个属性:
User user = service.getUser();
如果依次写:
assertEquals(1, user.getId());
assertEquals("LingXi", user.getName());
assertEquals(20, user.getAge());
第一条失败以后,后面的断言可能无法继续给出完整信息。
可以使用:
assertAll(
() -> assertEquals(1, user.getId()),
() -> assertEquals("LingXi", user.getName()),
() -> assertEquals(20, user.getAge())
);
这样可以把多个相关断言组织在一起。
初学阶段不用滥用。
当:
多个断言共同描述同一个业务结果
时再考虑使用。
四、原理与进阶
4.1 测试不是“举几个例子”
假设:
public int divide(int a, int b)
只测试:
divide(10, 2)
是不够的。
至少应该思考三类数据:
正常值
边界值
异常值
4.2 正常值
正常业务输入:
divide(10, 2)
期望:
5
例如:
@Test
void divideNormalNumbers() {
assertEquals(5, calculator.divide(10, 2));
}
4.3 边界值
边界值是:
位于业务规则边界附近的数据。
假设:
boolean isAdult(int age)
判断:
age >= 18
那么:
17
18
19
非常值得测试。
因为 Bug 最容易出现在:
>
>=
<
<=
这些边界判断上。
例如:
@Test
void age17ShouldNotBeAdult() {
assertFalse(userService.isAdult(17));
}
@Test
void age18ShouldBeAdult() {
assertTrue(userService.isAdult(18));
}
其中:
18
就是关键边界。
4.4 异常值
例如:
divide(10, 0)
或者:
setAge(-1)
这些输入可能应该:
拒绝
抛异常
返回特殊结果
无论采用什么设计,都应该通过测试明确行为。
4.5 一个测试应该尽量只表达一个行为
不推荐:
@Test
void testEverything() {
// 测登录
// 测注册
// 测删除
// 测查询
// 测密码修改
}
这种测试一旦失败:
到底哪个功能坏了?
不够清晰。
推荐:
loginWithCorrectPasswordShouldSucceed
loginWithWrongPasswordShouldFail
registerWithDuplicateUsernameShouldThrowException
deleteExistingUserShouldSucceed
一个测试关注一个明确行为。
五、实践应用
5.1 AAA 测试结构
一种非常实用的测试组织方式叫:
Arrange — Act — Assert
简称:
AAA
Arrange:准备
准备对象和测试数据:
Calculator calculator = new Calculator();
Act:执行
调用被测试代码:
int result = calculator.add(10, 20);
Assert:验证
判断结果:
assertEquals(30, result);
组合起来:
@Test
void addTwoNumbersShouldReturnSum() {
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.add(10, 20);
// Assert
assertEquals(30, result);
}
对于较短测试可以不写 AAA 注释,但思维结构应该保持。
5.2 Given-When-Then
另一种表达方式:
Given
When
Then
它和 AAA 本质非常接近:
| AAA | Given-When-Then | | ------- | --------------- | | Arrange | Given | | Act | When | | Assert | Then |
例如:
Given:用户年龄为 18
When :判断是否成年
Then :应该返回 true
这会迫使你思考:
我到底正在验证哪条业务规则?
5.3 测试方法如何命名
差:
@Test
void test1() {
}
稍好:
@Test
void testLogin() {
}
更清晰:
@Test
void loginWithCorrectPasswordShouldSucceed() {
}
或者:
@Test
void shouldReturnTrueWhenAgeIs18() {
}
测试名最好可以直接告诉阅读者:
什么条件
+
什么行为
+
什么结果
5.4 测试应该可以重复运行
如果今天运行:
PASS
明天什么都没有改变,再运行却:
FAIL
这种测试价值会大幅下降。
高质量测试应该尽可能:
确定
稳定
可重复
因此应该谨慎依赖:
- 当前时间
- 随机数据
- 网络状态
- 外部服务
- 其他测试留下的数据
- 测试执行顺序
5.5 测试之间不要相互依赖
错误设计:
test1 创建用户
↓
test2 必须依赖 test1 创建的用户
↓
test3 再删除这个用户
一旦只运行:
test2
它可能立即失败。
测试应该尽可能做到:
任何一个测试都可以单独运行
5.6 一个完整示例
业务类:
public class StringUtil {
public static int getLastIndex(String text) {
if (text == null) {
throw new IllegalArgumentException("text不能为null");
}
if (text.isEmpty()) {
return -1;
}
return text.length() - 1;
}
}
测试:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class StringUtilTest {
@Test
void normalStringShouldReturnLastIndex() {
int result = StringUtil.getLastIndex("abcdef");
assertEquals(5, result);
}
@Test
void singleCharacterShouldReturnZero() {
int result = StringUtil.getLastIndex("a");
assertEquals(0, result);
}
@Test
void emptyStringShouldReturnMinusOne() {
int result = StringUtil.getLastIndex("");
assertEquals(-1, result);
}
@Test
void nullShouldThrowException() {
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> StringUtil.getLastIndex(null)
);
assertEquals("text不能为null", exception.getMessage());
}
}
现在测试覆盖了:
普通值
边界值
特殊值
异常值
比单纯写:
System.out.println(StringUtil.getLastIndex("abcdef"));
可靠得多。
六、常见问题
6.1 assertEquals 的两个参数谁在前?
推荐:
assertEquals(expected, actual);
即:
期望值在前
实际值在后
例如:
assertEquals(6, result);
而不是:
assertEquals(result, 6);
虽然很多情况下结果一样,但前者能让失败报告语义更加自然。
6.2 System.out.println 能不能代替断言?
不能。
System.out.println(result);
只是:
展示结果
而:
assertEquals(expected, result);
才是:
验证结果
这是人工测试与自动化测试之间的关键区别。
6.3 异常出现是不是一定说明测试失败?
不是。
如果需求规定:
非法参数必须抛 IllegalArgumentException
那么:
assertThrows(
IllegalArgumentException.class,
() -> method()
);
成功捕获预期异常意味着:
测试通过
所以:
异常本身不等于错误。
关键在于它是不是预期行为。
6.4 是否应该一个测试写几十个 assert?
通常不推荐。
如果几十个断言描述的是完全不同业务规则,应该拆分测试。
测试失败以后,我们应该能够快速知道:
哪条行为规则被破坏了
6.5 测试覆盖率越高是不是项目质量一定越高?
不是。
例如你可以让很多代码:
被测试执行过
却没有真正验证正确结果。
所以:
测试覆盖率高
不等于:
测试质量高
真正重要的是:
测试是否覆盖了关键行为、边界和错误场景。
6.6 浮点数能直接 assertEquals 吗?
浮点计算可能存在精度误差。
例如:
0.1 + 0.2
并不一定精确等于:
0.3
对于需要允许误差的浮点结果,可以指定:
assertEquals(expected, actual, delta);
例如:
assertEquals(0.3, 0.1 + 0.2, 0.000001);
其中:
delta
表示允许误差。
而涉及金额等精确十进制业务时,更应该首先考虑之前学习过的:
BigDecimal
七、练习与验收
7.1 知识问答
- 什么是断言?
- 为什么打印测试结果不能称为完整自动化测试?
assertEquals()的两个核心参数分别是什么?assertTrue()与assertFalse()分别适合什么情况?assertNull()与assertNotNull()有什么区别?assertSame()与assertEquals()有什么区别?assertThrows()用于验证什么?- 什么是正常值、边界值、异常值?
- 什么是 AAA 测试结构?
- 为什么测试之间不应该存在执行顺序依赖?
7.2 代码阅读
阅读:
@Test
void divideByZero() {
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
assertEquals("除数不能为0", exception.getMessage());
}
回答:
- 这个测试验证了几个条件?
- 为什么出现异常以后测试反而可能 PASS?
assertThrows()的返回值是什么?- Lambda 中的代码什么时候执行?
- 如果实际抛出
NullPointerException,结果如何?
7.3 手写代码
为:
public static boolean isAdult(int age) {
if (age < 0) {
throw new IllegalArgumentException("年龄不能小于0");
}
return age >= 18;
}
设计测试。
至少覆盖:
17
18
19
0
-1
要求:
- 使用
assertTrue - 使用
assertFalse - 使用
assertThrows
7.4 Debug
找出下面测试的问题:
@Test
void testAge() {
assertTrue(isAdult(30));
assertTrue(isAdult(20));
assertFalse(isAdult(15));
assertFalse(isAdult(10));
assertThrows(
IllegalArgumentException.class,
() -> isAdult(-1)
);
}
思考:
- 语法是否一定有问题?
- 测试设计是否清晰?
- 如果第三个断言失败,你能否快速从测试名判断失败的业务规则?
- 是否值得拆成多个测试方法?
- 应该如何命名?
7.5 综合训练
实现一个简单业务方法:
public class PasswordValidator {
public static boolean isValid(String password) {
// 自行完成
return false;
}
}
规定:
password == null → 抛 IllegalArgumentException
长度 < 8 → false
长度 >= 8 → true
要求设计完整测试:
null
""
"1234567"
"12345678"
"123456789"
先写:
测试场景表
再写代码。
不要一上来就敲测试。
7.6 本章验收
不查看资料完成以下任务:
- 手写
assertEquals()、assertTrue()、assertFalse()、assertThrows()示例。 - 解释 Expected 与 Actual。
- 给一个具有边界条件的方法设计至少 5 个测试数据。
- 使用 AAA 描述一个完整测试。
- 判断一个测试是否存在其他测试依赖。
- 为异常行为编写测试。
- 能够解释为什么“测试运行了”不等于“测试有效”。
- 能够把一个杂乱的大测试拆分成多个职责清晰的小测试。