主题
界面
主题
这是测试专项线的第一节课。你已经做过大量功能测试——测试设计能力不缺,缺的是把测试写成代码。
本节不教"怎么设计用例"(那是你的强项),只解决一件事:把脑子里的用例,翻译成结构清晰、能自动运行的 Java 代码。
完成本节后,你应该能够:
assertEquals / assertTrue / assertThrows / assertAll 完成常见断言;src/test/java 的作用吗?(《IDEA 与项目创建》)手工测试的根本问题不是"慢",而是不可重复且没有记录:
| 手工测试 | 单元测试 | |
|---|---|---|
| 执行成本 | 每次都要点一遍 | 一次编写,随时重跑 |
| 回归能力 | 改代码后没人愿意全量回归 | mvn test 几秒钟跑完 |
| 记录方式 | 口头/文档,容易过期 | 代码即记录,不会和实现脱节 |
| 失败反馈 | 靠人观察 | 精确定位到方法与断言 |
而且单元测试还有第二个价值:它是唯一不会过期的文档。注释会腐烂,文档会过期,但测试如果过期就会变红——它不允许自己撒谎。
从 QA 到测试开发的关键跃迁
你现在的测试用例多半写在测试管理平台里(步骤、预期结果、实际结果)。写成代码后,这些字段会变成:
结构是一样的,只是执行者从人变成了 JVM。
▲ UI / E2E 测试(少、慢、脆)
▲▲▲ 集成测试(中)
▲▲▲▲▲ 单元测试(多、快、稳)← 本节在这里单元测试的目标是单个类或方法的行为,不启动 Spring、不连数据库、不打开浏览器。因此它能在毫秒级跑完,也最不容易失败于"环境问题"。
@Test
void should_apply_discount_when_total_reaches_threshold() {
// Arrange:准备数据与对象
OrderCalculator calculator = new OrderCalculator();
// Act:执行被测行为(只调一次)
long payable = calculator.payableInCents(6_000L, 2);
// Assert:验证结果
assertEquals(11_000L, payable);
}三条纪律:
| 命名 | 问题 |
|---|---|
test1() | 失败后完全不知道测什么 |
testCalculator() | 还是不知道测什么 |
should_apply_discount_when_total_reaches_threshold() | 失败信息自带解释 |
推荐格式:should_<期望行为>_when_<条件>。中文场景可以配合 @DisplayName:
@DisplayName("总额达到 10000 分时,减免 1000 分")@DisplayName 的中文会出现在 IDEA 的测试报告和 Maven 的测试输出里,比方法名更直观。
assertEquals(期望值, 实际值, "失败时的提示"); // 相等(注意参数顺序!)
assertTrue(条件, "失败提示"); // 为真
assertFalse(条件, "失败提示"); // 为假
assertNull(对象); // 为 null
assertNotNull(对象, "对象不该为空"); // 不为 null
assertThrows(IllegalArgumentException.class, () -> calculator.payableInCents(100, 0));
assertDoesNotThrow(() -> calculator.payableInCents(100, 1));
assertAll("订单金额校验", // 多断言全部执行,不因第一个失败而中断
() -> assertEquals(5_000L, result.amount()),
() -> assertEquals("已支付", result.status())
);assertAll 是常用技巧:普通断言在第一个失败处停止,assertAll 会把所有失败一次性报出来,适合一次校验多个相关字段。
@Test
void should_return_unit_price_when_quantity_is_one() {
OrderCalculator calculator = new OrderCalculator();
long payable = calculator.payableInCents(5_000L, 1);
assertEquals(5_000L, payable);
}被测方法有两条业务规则:满 10000 分减 1000 分、非法参数抛异常。对应四个测试:
@Test
void should_not_apply_discount_when_total_below_threshold() { ... } // 9999 分,不减
@Test
void should_apply_discount_when_total_equals_threshold() { ... } // 10000 分,边界值
@Test
void should_throw_when_unit_price_is_not_positive() { ... } // 0 分,异常
@Test
void should_throw_when_quantity_is_not_positive() { ... } // 数量 0,异常边界值分析在这里体现得最直接:9999 / 10000 / 10001 三个点的行为差异,正是你在测试用例设计里做过无数次的事。
@Test
void testCalculator() {
calculator.payableInCents(5_000L, 1); // 没有断言,永远不会失败
}
@Test
void testAll() {
assertEquals(5_000L, calculator.payableInCents(5_000L, 1));
assertEquals(11_000L, calculator.payableInCents(6_000L, 2));
assertEquals(2_000L, calculator.payableInCents(2_000L, 1));
assertThrows(IllegalArgumentException.class, () -> calculator.payableInCents(0, 1));
// 第一个失败后,后面三个根本不执行——一次只能修一个问题
}第一条是"假测试",它只证明方法没抛异常;第二条是"巨型测试",失败信息模糊且互相遮挡。一个测试验证一个行为。
被测类:labs/testing-unit/src/main/java/cn/kb/learn/testing/OrderCalculator.java
package cn.kb.learn.testing;
public class OrderCalculator {
private static final long DISCOUNT_THRESHOLD_IN_CENTS = 10_000L;
private static final long DISCOUNT_IN_CENTS = 1_000L;
/**
* 计算应付金额:单价 × 数量,总额达到阈值时减免固定金额。
*/
public long payableInCents(long unitPriceInCents, int quantity) {
if (unitPriceInCents <= 0) {
throw new IllegalArgumentException("单价必须为正");
}
if (quantity <= 0) {
throw new IllegalArgumentException("数量必须为正");
}
long total = unitPriceInCents * quantity;
if (total >= DISCOUNT_THRESHOLD_IN_CENTS) {
total -= DISCOUNT_IN_CENTS;
}
return total;
}
}测试类:labs/testing-unit/src/test/java/cn/kb/learn/testing/OrderCalculatorTest.java
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertAll;
class OrderCalculatorTest {
private final OrderCalculator calculator = new OrderCalculator();
@Test
@DisplayName("数量为 1 时不触发满减")
void should_return_unit_price_when_quantity_is_one() {
// Arrange
long unitPrice = 5_000L;
int quantity = 1;
// Act
long payable = calculator.payableInCents(unitPrice, quantity);
// Assert
assertEquals(5_000L, payable, "未满减时应等于单价");
}
@Test
@DisplayName("总额刚好达到阈值(边界值)时触发满减")
void should_apply_discount_when_total_equals_threshold() {
long payable = calculator.payableInCents(5_000L, 2);
assertEquals(9_000L, payable, "10000 分应减 1000 分");
}
@Test
@DisplayName("差一分未达阈值时不触发满减(边界值)")
void should_not_apply_discount_when_total_below_threshold() {
long payable = calculator.payableInCents(9_999L, 1);
assertEquals(9_999L, payable, "9999 分不减");
}
@Test
@DisplayName("数量为 0 时抛出 IllegalArgumentException")
void should_throw_when_quantity_is_not_positive() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.payableInCents(100L, 0));
assertEquals("数量必须为正", exception.getMessage());
}
@Test
@DisplayName("多个结果字段一次校验")
void should_verify_multiple_values_at_once() {
long payable = calculator.payableInCents(6_000L, 2);
assertAll("满减结果",
() -> assertEquals(11_000L, payable),
() -> assertEquals(1_000L, 12_000L - payable));
}
}运行方式(Maven):
mvn -pl testing-unit test运行方式(无 Maven 时的快速验证):
javac --release 21 -cp ".libs/junit-platform-console-standalone-1.11.3.jar" -d testing-unit/target/test-classes (Get-ChildItem -Recurse testing-unit/src/main/java, testing-unit/src/test/java -Filter *.java).FullName
java -jar .libs/junit-platform-console-standalone-1.11.3.jar execute --class-path "testing-unit/target/test-classes" --scan-class-path预期输出(Maven):
Tests run: 14, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS预期输出(Console Launcher):
[ 14 tests found ]
[ 14 tests successful ]
[ 0 tests failed ]数量包含本节正文的 5 个测试,以及编程题与综合任务的参考测试——它们都在 labs/testing-unit 里,可以一起跑。
long payable = calculator.payableInCents(unitPrice, quantity); 处打断点,用 Debug 模式运行测试(点测试方法旁的绿色三角选 Debug),观察 unitPrice、quantity、返回值 payable;9_000L 改成 8_000L),观察 IDEA 测试面板的差异展示:Expected / Actual 并排显示,右侧还有 Click to see difference;assertThrows 的 lambda 里打断点,观察异常抛出前的调用栈——这是排查"为什么抛的不是这个异常"的最快路径。assertEquals(payable, 9_000L); // 反了:第一个参数必须是期望值修复:记牢 assertEquals(期望, 实际, 消息)。顺序反了不会报错,但失败信息里 Expected/Actual 会颠倒,排查时会被误导。
@Test
void testCalculate() {
calculator.payableInCents(100L, 1); // 只证明不抛异常
}修复:要么加断言,要么用 assertDoesNotThrow 明确表达"只关注不抛异常"。
class CounterTest {
static int count = 0; // 危险:所有测试共享
@Test
void should_increment() {
count++;
assertEquals(1, count); // 单独跑通过,一起跑失败
}
}修复:每个测试自己创建所需对象;共享初始化放到 @BeforeEach(T1-002 生命周期)。
System.out.println("结果是 " + payable); // 人要看,程序不看修复:断言才是测试的判断依据;打印只在临时排查时使用,提交前删掉。
目标:为一个业务规则写一组完整测试,并故意制造一次失败来理解输出。
步骤:
labs/testing-unit),运行测试,确认 5 个全绿;should_apply_discount_when_total_equals_threshold 的期望值改成 8_000L,运行并截图失败信息;OrderCalculator 增加一条规则:总额超过 100000 分时,最多减 2000 分;验收方式:能解释第 2 步失败信息里的 Expected 与 Actual 分别来自哪里。
扩展任务:用同样的 AAA 结构,为 payableInCents 写一个"负数单价"的测试,并说明它与"单价为 0"的测试是否重复。
基础题
should_xxx_when_yyy 这种长名字?代码阅读题
@Test
void check() {
assertEquals(calculator.payableInCents(5_000L, 2), 9_000L);
}编程题
public class PasswordValidator {
/** 密码长度必须 ≥ 8,且至少包含一个数字,否则返回 false */
public boolean isValid(String password) {
if (password == null) {
return false;
}
if (password.length() < 8) {
return false;
}
return password.chars().anyMatch(Character::isDigit);
}
}综合任务
OrderCalculator 加上"会员折扣":普通用户减 500 分,会员再额外减 500 分(用枚举或布尔参数表示)。要求: @DisplayName 给每个测试写中文说明。提示 1(方向):第 5 题的边界值是"长度 7 / 8 / 9"和"无数字 / 有数字",异常情况是 null。
提示 2(关键点):第 6 题的组合场景至少有四种:非会员未满减、非会员满减、会员未满减、会员满减;边界是"减完刚好落在阈值上"。
提示 3(接近答案):第 6 题先用 assertEquals 把期望值写出来,再改实现让它变绿——这就是测试驱动开发(TDD)的基本节奏。
这个测试能通过(值相等),但写法有问题:assertEquals 的两个参数顺序反了。
一旦将来失败,失败信息里 Expected 显示的是实际值、Actual 显示的是期望值,排查方向会完全颠倒。这是最隐蔽的测试缺陷之一——平时全绿,出事时误导。
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PasswordValidatorTest {
private final PasswordValidator validator = new PasswordValidator();
@Test
@DisplayName("长度足够且含数字时通过")
void should_accept_valid_password() {
assertTrue(validator.isValid("abcd1234"));
}
@Test
@DisplayName("长度为 8 且含数字(边界)时通过")
void should_accept_when_length_equals_minimum() {
assertTrue(validator.isValid("1234567a"));
}
@Test
@DisplayName("长度为 7(边界外侧)时拒绝")
void should_reject_when_length_below_minimum() {
assertFalse(validator.isValid("123456a"));
}
@Test
@DisplayName("长度足够但没有数字时拒绝")
void should_reject_when_no_digit() {
assertFalse(validator.isValid("abcdefgh"));
}
@Test
@DisplayName("密码为 null 时拒绝而不抛异常")
void should_reject_when_password_is_null() {
assertFalse(validator.isValid(null));
}
}先写测试(节选):
@Test
@DisplayName("非会员未满减:应付等于总额")
void should_charge_full_price_for_non_member_below_threshold() {
assertEquals(5_000L, calculator.payableInCents(5_000L, 1, false));
}
@Test
@DisplayName("非会员满减:总额减 500 分")
void should_apply_member_small_discount_for_non_member() {
assertEquals(10_500L, calculator.payableInCents(5_500L, 2, false));
}
@Test
@DisplayName("会员满减:总额减 1000 分")
void should_apply_member_large_discount_for_member() {
assertEquals(10_000L, calculator.payableInCents(5_500L, 2, true));
}
@Test
@DisplayName("会员但未满减:仍减 500 分")
void should_apply_member_discount_even_below_threshold() {
assertEquals(4_500L, calculator.payableInCents(5_000L, 1, true));
}参考实现:
public long payableInCents(long unitPriceInCents, int quantity, boolean member) {
if (unitPriceInCents <= 0) {
throw new IllegalArgumentException("单价必须为正");
}
if (quantity <= 0) {
throw new IllegalArgumentException("数量必须为正");
}
long total = unitPriceInCents * quantity;
if (member) {
total -= 500L;
}
if (total >= DISCOUNT_THRESHOLD_IN_CENTS) {
total -= 500L;
}
return total;
}注意这里的取舍:先减会员、再判断阈值,还是先判断阈值、再减会员,会导致不同的结果。这类"规则顺序"的问题,正是测试能帮你固定下来的设计决策——先用测试写清期望,再让实现去满足它。
@DisplayName 让测试报告可读;《JUnit 5:生命周期与参数化测试》 讲清 @BeforeEach 与 @ParameterizedTest,让重复的测试数据不再手写多份。