主题
界面
主题
《JUnit 5:测试结构与断言》 的最后一个"常见错误"是:测试之间共享可变的静态状态。本节要正面解决它——用生命周期注解让每个测试拿到干净的环境;以及用参数化测试消灭"复制粘贴改一行"的重复用例。
这两件事都直接影响测试的可靠性:前者决定测试会不会互相污染,后者决定边界用例有没有被穷尽。
完成本节后,你应该能够:
@BeforeAll / @BeforeEach / @AfterEach / @AfterAll 的执行时机与次数;@TestInstance(PER_CLASS),以及它的代价;@Nested 组织有层次的测试,用 @DisplayName 让报告可读;@ParameterizedTest + @CsvSource / @ValueSource / @MethodSource 写数据驱动测试;@RepeatedTest 验证结果稳定性;看一段"看起来没问题"的测试:
class CartTest {
private static final List<String> items = new ArrayList<>(); // 静态!
@Test
void add_first_item() {
items.add("键盘");
assertEquals(1, items.size());
}
@Test
void add_second_item() {
items.add("鼠标");
assertEquals(1, items.size()); // 单独跑通过,一起跑失败
}
}单独运行每个测试都通过,一起运行就失败——因为 items 是静态的,第二个测试看到的是第一个测试留下的数据。
更麻烦的是顺序依赖:JUnit 5 默认不保证测试方法执行顺序,所以这种失败"有时出现、有时不出现"。这种测试比没有测试更糟糕,因为它会消耗你排查"假故障"的时间。
正确的做法是让每个测试从干净的状态开始——这正是 @BeforeEach 存在的意义。
| 注解 | 执行时机 | 次数 | 能否是静态 |
|---|---|---|---|
@BeforeAll | 所有测试之前 | 整个类一次 | 必须是 static(默认模式下) |
@BeforeEach | 每个测试之前 | 每个测试一次 | 非静态 |
@AfterEach | 每个测试之后 | 每个测试一次 | 非静态 |
@AfterAll | 所有测试之后 | 整个类一次 | 必须是 static(默认模式下) |
为什么 @BeforeAll 必须静态:JUnit 5 默认对每个测试方法创建一个新的测试类实例,所以"整个类只执行一次"的方法不能依赖实例状态,只能是静态的。
选择原则:
| 资源 | 放哪 | 例子 |
|---|---|---|
| 每个测试都要重建 | @BeforeEach | 被测对象、测试用的集合、Mock |
| 创建昂贵、可共享 | @BeforeAll | 数据库连接池、测试容器、HTTP 服务器 |
| 必须清理 | @AfterEach | 临时文件、数据库里的测试数据 |
class LifecycleTest {
private final List<String> perTestMethodLog = new ArrayList<>(); // 实例字段
@BeforeEach
void beforeEach() {
perTestMethodLog.add("before");
}
@Test
void testA() {
assertEquals(1, perTestMethodLog.size()); // 永远是 1
}
@Test
void testB() {
assertEquals(1, perTestMethodLog.size()); // 也是 1,不是 2
}
}JUnit 5 为每个测试方法创建一个新的测试类实例。所以实例字段天然不会跨测试污染——这是隔离的第一道保障。
推论:测试里"上一步的结果"必须靠 @BeforeEach 重建,不能指望它还在。这正是一个好测试该有的样子:自给自足,不依赖别人留下的状态。
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SharedInstanceTest {
private int callCount; // 所有测试共享
@Test
@Order(1)
void first() {
callCount++;
assertEquals(1, callCount);
}
@Test
@Order(2)
void second() {
assertEquals(1, callCount); // 能看到上一个测试的结果
}
}什么时候用:
@BeforeAll / @AfterAll 需要用实例方法或实例字段(不用写 static);代价:失去了实例级隔离,测试之间会通过实例字段互相影响。如果你发现自己需要它,先问一句"是不是该把共享部分挪到 @BeforeAll 的静态字段里?"
class OrderCalculatorTest {
@Nested
@DisplayName("单个数量")
class WhenQuantityIsOne {
@Test
void no_discount() { }
}
@Nested
@DisplayName("达到满减阈值")
class WhenTotalReachesThreshold {
@Test
void applies_discount() { }
}
}收益:报告里显示两层层级,扫描失败列表时一眼看出"哪类场景出问题"。嵌套类可以有自己独立的 @BeforeEach,与外层的会叠加执行(外层 → 内层)。
痛点:边界值测试会写成这样:
@Test void discount_below_threshold() { assertEquals(9_999L, calc.payableInCents(9_999L, 1)); }
@Test void discount_at_threshold() { assertEquals(9_000L, calc.payableInCents(10_000L, 1)); }
@Test void discount_above_threshold() { assertEquals(9_000L, calc.payableInCents(20_000L, 1)); }
// ... 再来十个参数化写法:
@ParameterizedTest(name = "单价 {0} 分 × {1} 件 → 应付 {2} 分")
@CsvSource({
"5000, 1, 5000",
"5000, 2, 9000",
"9999, 1, 9999",
"10000, 1, 9000",
"20000, 1, 19000"
})
void payable_amount_cases(long unitPrice, int quantity, long expected) {
assertEquals(expected, calculator.payableInCents(unitPrice, quantity));
}每个数据行都是一个独立测试:失败时报告会告诉你"哪一行参数失败了",其余行照常执行。
四种数据来源:
| 注解 | 用途 | 例子 |
|---|---|---|
@ValueSource | 单个参数的一组值 | ints = {0, -1}、strings = {"", " "} |
@CsvSource | 多个参数的表格(最常用) | "5000, 1, 5000" |
@MethodSource | 复杂对象或动态生成的数据 | 引用同名的 static Stream<Arguments> |
@EnumSource | 枚举的全部或部分取值 | MemberType.class |
name 属性的占位符:{0}、{1} 是参数值,{index} 是序号;@RepeatedTest 用 {currentRepetition} / {totalRepetitions}。把场景描述写进 name,报告会自己解释自己。
@RepeatedTest(value = 3, name = "第 {currentRepetition} / {totalRepetitions} 次重复")
void repeated_runs_are_stable() {
assertEquals(9_000L, calculator.payableInCents(5_000L, 2));
}它抓的是"偶发问题":数据依赖执行顺序、随机数、时间相关、并发相关的实现,跑一次通过、跑三次可能就暴露了。概率相关的逻辑(抽奖、随机分配)尤其该用它。
private OrderCalculator calculator;
@BeforeEach
void setUp() {
calculator = new OrderCalculator(); // 每个测试都有自己的实例
}见 5.5 节。边界值 + 参数化是最高性价比的组合:五个边界写五行数据,比写五个方法清楚得多。
@BeforeEach @BeforeEach
void setUp() {
order = new Order();
// 反例:@BeforeEach 里塞了业务逻辑和分支
if (testEnv.equals("ci")) { ... }
for (int i = 0; i < 3; i++) { order.addItem(item(i)); }
order.pay();
}问题:@BeforeEach 里做的事越多,测试的"前置条件"越难看清,失败时也难定位是"准备"错了还是"被测代码"错了。
修复:@BeforeEach 只做共性的机械准备(创建对象、清空容器);每个场景特有的准备写在测试方法开头,也就是 AAA 里的 Arrange。
文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/LifecycleTest.java(节选)
package cn.kb.learn.testing;
import org.junit.jupiter.api.*;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@DisplayName("生命周期演示")
class LifecycleTest {
/** 静态字段:整个测试类只有一份,所以只能由 @BeforeAll 初始化 */
private static int beforeAllCount;
private static List<String> sharedLog;
/** 实例字段:每个测试方法都会拿到新对象,@BeforeEach 负责重建 */
private final List<String> perTestMethodLog = new ArrayList<>();
private OrderCalculator calculator;
@BeforeAll
static void beforeAll() {
beforeAllCount++;
sharedLog = new ArrayList<>();
sharedLog.add("由 @BeforeAll 初始化一次");
System.out.println("[LifecycleTest] @BeforeAll 执行(整个类一次)");
}
@AfterAll
static void afterAll() {
sharedLog.clear();
System.out.println("[LifecycleTest] @AfterAll 执行(整个类一次)");
}
@BeforeEach
void beforeEach() {
calculator = new OrderCalculator();
perTestMethodLog.add("before");
System.out.println("[LifecycleTest] @BeforeEach 执行(每个测试前)");
}
@AfterEach
void afterEach() {
perTestMethodLog.add("after");
System.out.println("[LifecycleTest] @AfterEach 执行(每个测试后)");
}
@Test
@DisplayName("实例字段在第一个测试里是全新的")
void instance_state_is_fresh_in_first_test() {
assertEquals(1, perTestMethodLog.size());
}
@Test
@DisplayName("实例字段在第二个测试里同样是全新的")
void instance_state_is_fresh_in_second_test() {
assertEquals(1, perTestMethodLog.size());
}
@Test
@DisplayName("静态字段在所有测试间共享")
void static_state_is_shared_across_tests() {
assertEquals(1, beforeAllCount);
assertEquals(1, sharedLog.size());
}
@Nested
@DisplayName("嵌套类:单个数量")
class WhenQuantityIsOne {
@Test
@DisplayName("嵌套测试同样拿到全新的外层状态")
void nested_test_gets_fresh_outer_state() {
assertEquals(1, perTestMethodLog.size());
assertEquals(5_000L, calculator.payableInCents(5_000L, 1));
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DisplayName("PER_CLASS:实例在所有测试间共享")
class PerClassLifecycle {
private final List<String> calls = new ArrayList<>();
@Test
@Order(1)
void first_call_records_state() {
calls.add("first");
assertEquals(1, calls.size());
}
@Test
@Order(2)
void second_call_sees_state_from_first() {
calls.add("second");
assertEquals(2, calls.size());
}
}
}参数化测试(ParameterizedOrderCalculatorTest.java,节选):
@ParameterizedTest(name = "单价 {0} 分 × {1} 件 → 应付 {2} 分")
@CsvSource({
"5000, 1, 5000",
"5000, 2, 9000",
"9999, 1, 9999",
"10000, 1, 9000",
"20000, 1, 19000"
})
void payable_amount_cases(long unitPrice, int quantity, long expected) {
assertEquals(expected, calculator.payableInCents(unitPrice, quantity));
}
static Stream<Arguments> memberDiscountCases() {
return Stream.of(
Arguments.of("非会员未满减", 5_000L, 1, false, 5_000L),
Arguments.of("非会员满减", 5_500L, 2, false, 10_500L),
Arguments.of("会员未满减", 5_000L, 1, true, 4_500L),
Arguments.of("会员满减叠加", 5_500L, 2, true, 10_000L));
}
@ParameterizedTest(name = "{0}")
@MethodSource("memberDiscountCases")
void member_discount_cases(String scenario, long unitPrice, int quantity,
boolean member, long expected) {
assertEquals(expected, calculator.payableInCents(unitPrice, quantity, member), scenario);
}
@RepeatedTest(value = 3, name = "第 {currentRepetition} / {totalRepetitions} 次重复")
void repeated_runs_are_stable() {
assertEquals(9_000L, calculator.payableInCents(5_000L, 2));
}运行命令:
cd labs
./mvnw -pl testing-unit test -Dtest=LifecycleTest*
./mvnw -pl testing-unit test -Dtest=ParameterizedOrderCalculatorTest预期输出(生命周期的执行次数一眼可见):
[LifecycleTest] @BeforeAll 执行(整个类一次)
[LifecycleTest] @BeforeEach 执行(每个测试前)
[LifecycleTest] @AfterEach 执行(每个测试后)
[LifecycleTest] @BeforeEach 执行(每个测试前)
[LifecycleTest] @AfterEach 执行(每个测试后)
...(每个测试方法前后各一组,共 6 组)
[LifecycleTest] @AfterAll 执行(整个类一次)
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0参数化测试的结果:
Tests run: 15, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.251 s
-- in cn.kb.learn.testing.ParameterizedOrderCalculatorTest15 个测试是怎么来的:
| 来源 | 数量 |
|---|---|
@CsvSource 的 5 行数据 | 5 |
@ValueSource(ints = {0, -1, -100}) | 3 |
@MethodSource 的 4 组 Arguments | 4 |
@RepeatedTest(3) | 3 |
| 合计 | 15 |
一份测试代码,15 个测试用例——这就是参数化的价值。而且失败时报告会指出具体是哪一行数据。
assertEquals(1, perTestMethodLog.size()) 处分别断点:观察 this 的 id 各不相同——每个测试拿到的是新实例;@BeforeAll 方法里断点,观察它只在最开始命中一次;再在 @BeforeEach 断点,观察它命中 6 次(每个测试一次);PerClassLifecycle 的两个测试,在第二个测试里观察 calls 里已经有 "first"——PER_CLASS 下实例是共享的;@MethodSource 的 memberDiscountCases 方法里断点,观察它在测试开始前被调用一次,返回 4 组参数;也可以在返回的 Stream 上加断点看每组数据;@CsvSource 里的 "10000, 1, 9000" 改成 "10000, 1, 9100"(错的期望值),重跑,观察失败信息里明确给出第几行数据与期望值/实际值——这是参数化最实用的地方。@BeforeAll 忘了 static @BeforeAll
void beforeAll() { } // 编译错误 / 运行时错误:必须是静态方法修复:加 static,或用 @TestInstance(PER_CLASS)(但要接受实例共享的代价)。
@BeforeEach 里漏掉关键初始化 @BeforeEach
void setUp() {
calculator = new OrderCalculator();
// 忘记清空 items,导致测试之间互相污染
}症状:测试单独跑通过、一起跑失败。修复:把所有"每个测试都必须是干净状态"的字段都在 @BeforeEach 里重建(或干脆声明为实例字段并直接初始化)。
private static final List<String> ITEMS = new ArrayList<>(); // 所有测试共享修复:去掉 static,改成实例字段。只有真正只读的常量才该是静态的(如 static final long TIMEOUT = 1000L;)。
@MethodSource 的方法不是静态的 Stream<Arguments> cases() { } // 非静态:JUnit 找不到它修复:加 static(或在 @TestInstance(PER_CLASS) 下改为非静态)。
name 没写 @ParameterizedTest
@CsvSource({"5000, 1, 5000"})
void case(long a, int b, long c) { }症状:报告里显示 case(long, int, long) [1] 这种无法阅读的名字,失败时不知道是哪组数据。修复:name = "单价 {0} × {1} 件 → {2} 分"。
@ParameterizedTest
@ValueSource(ints = {5_000})
void single_case(int price) { } // 只有一组数据,不如普通 @Test判断标准:数据 ≥ 2 组且结构相同才值得参数化。
目标:把一组"复制粘贴的测试"改造成参数化测试,并验证隔离。
步骤:
LifecycleTest 的 perTestMethodLog 改成 static,重跑,观察哪些测试失败——亲手看到"共享可变状态"的破坏力(完成后改回来);PayableValidator(或自己写一个"年龄校验"方法)写参数化测试: @CsvSource 覆盖 5 个边界值(0、1、17、18、150、151);@ValueSource 覆盖非法输入;@MethodSource 覆盖"输入 + 期望错误消息"的组合场景;@RepeatedTest(5) 测试一个"随机抽取"或"时间相关"的方法,观察是否稳定;@Nested 把第 3 步的测试按"合法输入 / 非法输入 / 边界值"分为三个内部类,给每个内部类合适的外层 @BeforeEach。验收方式:能解释第 2 步失败的原因,以及第 5 步"外层与内层 @BeforeEach 的执行顺序"。
扩展任务:给参数化测试加 @DisplayName 与 name 占位符,让 Maven 报告的中文显示足够清楚——设想你在排查 CI 上的一次失败,报告能不能直接告诉你哪组数据错了。
基础题
@BeforeEach 在整个测试类中只执行一次。(对 / 错)代码阅读题
class DemoTest {
private static int staticCounter;
private int instanceCounter;
@BeforeAll
static void beforeAll() { staticCounter++; }
@BeforeEach
void beforeEach() { instanceCounter++; }
@Test
void testA() {
System.out.println(staticCounter + " / " + instanceCounter);
}
@Test
void testB() {
System.out.println(staticCounter + " / " + instanceCounter);
}
}instanceCounter 改成 static,输出会变成什么?为什么这很危险?编程题
PasswordStrength evaluate(String password) 返回枚举 WEAK / MEDIUM / STRONG;@CsvSource 覆盖至少 6 组数据(含空串、空格、纯数字、超长密码);@MethodSource 覆盖"边界长度"场景(7、8、9、15、16 字符);@RepeatedTest 验证同一输入多次评估结果一致。综合任务
Cart:addItem(name, priceInCents, quantity)、totalInCents()、applyCoupon(code);@Nested 分成三组:当购物车为空、当有商品时、当使用优惠券时;@BeforeEach 保证每个测试的购物车都是干净的;@BeforeAll 准备一份只读的"优惠券表"(静态常量);@ParameterizedTest 覆盖优惠券的生效与失效场景;提示 1(方向):第 4 题的关键是 static 字段在所有测试间共享,而实例字段每个测试重建。
提示 2(关键点):第 6 题的边界值要注意"7 与 8 的分界"——参数化测试最适合表达这种"同结构、不同数据"的场景。
提示 3(接近答案):第 7 题的判断依据是"这个状态会不会被测试修改":会被修改的必须是实例字段(如购物车本身),只读的可以静态(如优惠券表)。
@BeforeEach 在每个测试方法之前都执行一次。整个类只执行一次的是 @BeforeAll。@BeforeAll 与 @AfterAll;@BeforeEach 与 @AfterEach。第 4 题:两个测试都输出 1 / 1。
staticCounter 由 @BeforeAll 自增一次,之后不变,所以是 1;instanceCounter 每个测试都拿到新实例(初始为 0),@BeforeEach 自增一次,所以也是 1。第 5 题:如果 instanceCounter 改成 static,两个测试会输出 1 / 1 和 1 / 2(第二个测试看到第一个测试留下的值)。
为什么危险:这会让测试结果依赖执行顺序。JUnit 5 默认不保证方法顺序,所以同一份代码在本地通过、在 CI 上失败(或反过来)都是可能的——这是最消耗排查时间的一类问题。
而且更糟的变体是"断言恰好仍成立":测试通过了,但它验证的不再是"这一条逻辑",而是"前面某个测试留下的状态"。
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PasswordStrengthTest {
enum PasswordStrength { WEAK, MEDIUM, STRONG }
static PasswordStrength evaluate(String password) {
if (password == null || password.length() < 8) {
return PasswordStrength.WEAK;
}
boolean hasUpper = password.chars().anyMatch(Character::isUpperCase);
boolean hasLower = password.chars().anyMatch(Character::isLowerCase);
boolean hasDigit = password.chars().anyMatch(Character::isDigit);
boolean hasSpecial = password.chars().anyMatch(ch -> !Character.isLetterOrDigit(ch));
if (hasUpper && hasLower && hasDigit && hasSpecial) {
return PasswordStrength.STRONG;
}
if (hasUpper && hasLower && hasDigit) {
return PasswordStrength.MEDIUM;
}
return PasswordStrength.WEAK;
}
@ParameterizedTest(name = "密码 [{0}] → {1}")
@CsvSource({
"'', WEAK",
"' ', WEAK",
"12345678, WEAK",
"abcdefgh, WEAK",
"Abcdefg1, MEDIUM",
"Abcdefg1!, STRONG",
"Abcdefg1!Abcdefg1!Abcdefg1!, STRONG"
})
void evaluate_cases(String password, PasswordStrength expected) {
assertEquals(expected, evaluate(password));
}
static Stream<Arguments> boundary_lengths() {
return Stream.of(
Arguments.of("Abcdef1", PasswordStrength.WEAK), // 7 位
Arguments.of("Abcdefg1", PasswordStrength.MEDIUM), // 8 位(边界)
Arguments.of("Abcdefg12", PasswordStrength.MEDIUM), // 9 位
Arguments.of("Abcdefghijklmno", PasswordStrength.WEAK), // 15 位但缺数字
Arguments.of("Abcdefg12345678", PasswordStrength.MEDIUM)); // 16 位
}
@ParameterizedTest(name = "长度边界:{0} → {1}")
@MethodSource("boundary_lengths")
void evaluate_boundary_lengths(String password, PasswordStrength expected) {
assertEquals(expected, evaluate(password));
}
@RepeatedTest(value = 3, name = "同一输入第 {currentRepetition} 次评估")
@DisplayName("同一输入多次评估结果一致")
void repeated_evaluation_is_stable() {
assertEquals(PasswordStrength.STRONG, evaluate("Abcdefg1!"));
}
}注意 @CsvSource 里带引号的空值与空格:'', WEAK 表示空串,' ', WEAK 表示八个空格——CSV 里的空白容易被编辑器吞掉,用单引号包起来最稳妥。
package cn.kb.learn.testing;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CartTest {
/** 只读数据:所有测试共享,可以静态 */
private static final Map<String, Long> COUPONS = new HashMap<>();
/** 会被测试修改:必须是实例字段,每个测试重建 */
private Cart cart;
@BeforeAll
static void prepareCoupons() {
COUPONS.put("SAVE10", 1_000L);
COUPONS.put("SAVE50", 5_000L);
}
@BeforeEach
void setUp() {
cart = new Cart(COUPONS);
}
@Nested
@DisplayName("当购物车为空")
class WhenEmpty {
@Test
void total_is_zero() {
assertEquals(0L, cart.totalInCents());
}
@Test
void coupon_still_applies_to_zero() {
cart.applyCoupon("SAVE10");
assertEquals(0L, cart.totalInCents());
}
}
@Nested
@DisplayName("当有商品时")
class WhenHasItems {
@Test
void total_sums_price_times_quantity() {
cart.addItem("键盘", 19_900L, 1);
cart.addItem("鼠标", 9_900L, 2);
assertEquals(39_700L, cart.totalInCents());
}
@Test
void same_item_added_twice_accumulates() {
cart.addItem("键盘", 19_900L, 1);
cart.addItem("键盘", 19_900L, 1);
assertEquals(39_800L, cart.totalInCents());
}
}
@Nested
@DisplayName("当使用优惠券时")
class WhenUsingCoupon {
@ParameterizedTest(name = "优惠券 {0} 抵扣 {1} 分")
@CsvSource({"SAVE10, 1000", "SAVE50, 5000"})
void valid_coupon_deducts_amount(String code, long deduction) {
cart.addItem("键盘", 19_900L, 1);
cart.applyCoupon(code);
assertEquals(19_900L - deduction, cart.totalInCents());
}
@ParameterizedTest(name = "无效优惠券 {0} 不抵扣")
@CsvSource({"NOT_EXIST", "save10", ""})
void invalid_coupon_does_not_deduct(String code) {
cart.addItem("键盘", 19_900L, 1);
cart.applyCoupon(code);
assertEquals(19_900L, cart.totalInCents());
}
@Test
void total_never_goes_below_zero() {
cart.addItem("橡皮", 100L, 1);
cart.applyCoupon("SAVE50");
assertEquals(0L, cart.totalInCents());
}
}
}哪些字段可以是静态的:
| 字段 | 能否静态 | 依据 |
|---|---|---|
COUPONS | 可以 | 只读常量,测试不会改它 |
cart | 不可以 | 每个测试都会往里加商品、用券,必须隔离 |
判断依据一句话:"这个状态会不会被测试修改?" 会改的必须实例化;只有"初始化一次、之后只读"的才能静态。
注意:COUPONS 放在 @BeforeAll 里填充而不是静态初始化块,是为了明确表达"这是测试夹具(fixture)"——它的语义是"为测试准备的环境",而不是生产代码里的常量。
@TestInstance(PER_CLASS) 的适用场景与代价;@Nested 与 @DisplayName 让报告可读;name 加占位符;《异常测试与超时测试》:把"抛异常"和"耗时"这两类断言写对。