Skip to content

JUnit 5:生命周期与参数化测试 ​

T1-002必学进阶约 90 分钟JDK 21测试专项 A:单元测试 · JUnit 5

1. 本节定位 ​

《JUnit 5:测试结构与断言》 的最后一个"常见错误"是:测试之间共享可变的静态状态。本节要正面解决它——用生命周期注解让每个测试拿到干净的环境;以及用参数化测试消灭"复制粘贴改一行"的重复用例。

这两件事都直接影响测试的可靠性:前者决定测试会不会互相污染,后者决定边界用例有没有被穷尽。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说清 @BeforeAll / @BeforeEach / @AfterEach / @AfterAll 的执行时机与次数;
  • 解释为什么 JUnit 5 默认"每个测试方法一个新实例",以及它如何保证隔离;
  • 知道什么时候需要 @TestInstance(PER_CLASS),以及它的代价;
  • 用 @Nested 组织有层次的测试,用 @DisplayName 让报告可读;
  • 用 @ParameterizedTest + @CsvSource / @ValueSource / @MethodSource 写数据驱动测试;
  • 用 @RepeatedTest 验证结果稳定性;
  • 说出"参数化测试"相比"复制粘贴多个测试"的两个好处。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

看一段"看起来没问题"的测试:

java
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 存在的意义。

5. 核心概念 ​

5.1 四个生命周期注解 ​

注解执行时机次数能否是静态
@BeforeAll所有测试之前整个类一次必须是 static(默认模式下)
@BeforeEach每个测试之前每个测试一次非静态
@AfterEach每个测试之后每个测试一次非静态
@AfterAll所有测试之后整个类一次必须是 static(默认模式下)

为什么 @BeforeAll 必须静态:JUnit 5 默认对每个测试方法创建一个新的测试类实例,所以"整个类只执行一次"的方法不能依赖实例状态,只能是静态的。

选择原则:

资源放哪例子
每个测试都要重建@BeforeEach被测对象、测试用的集合、Mock
创建昂贵、可共享@BeforeAll数据库连接池、测试容器、HTTP 服务器
必须清理@AfterEach临时文件、数据库里的测试数据

5.2 默认生命周期:每个测试一个新实例 ​

java
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 重建,不能指望它还在。这正是一个好测试该有的样子:自给自足,不依赖别人留下的状态。

5.3 @TestInstance(PER_CLASS):共享实例 ​

java
@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);             // 能看到上一个测试的结果
    }
}

什么时候用:

  1. @BeforeAll / @AfterAll 需要用实例方法或实例字段(不用写 static);
  2. 测试类的创建成本确实很高(罕见)。

代价:失去了实例级隔离,测试之间会通过实例字段互相影响。如果你发现自己需要它,先问一句"是不是该把共享部分挪到 @BeforeAll 的静态字段里?"

5.4 @Nested:把测试分层次 ​

java
class OrderCalculatorTest {
    @Nested
    @DisplayName("单个数量")
    class WhenQuantityIsOne {
        @Test
        void no_discount() { }
    }

    @Nested
    @DisplayName("达到满减阈值")
    class WhenTotalReachesThreshold {
        @Test
        void applies_discount() { }
    }
}

收益:报告里显示两层层级,扫描失败列表时一眼看出"哪类场景出问题"。嵌套类可以有自己独立的 @BeforeEach,与外层的会叠加执行(外层 → 内层)。

5.5 参数化测试:一份代码,多组数据 ​

痛点:边界值测试会写成这样:

java
@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)); }
// ... 再来十个

参数化写法:

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));
}

每个数据行都是一个独立测试:失败时报告会告诉你"哪一行参数失败了",其余行照常执行。

四种数据来源:

注解用途例子
@ValueSource单个参数的一组值ints = {0, -1}、strings = {"", " "}
@CsvSource多个参数的表格(最常用)"5000, 1, 5000"
@MethodSource复杂对象或动态生成的数据引用同名的 static Stream<Arguments>
@EnumSource枚举的全部或部分取值MemberType.class

name 属性的占位符:{0}、{1} 是参数值,{index} 是序号;@RepeatedTest 用 {currentRepetition} / {totalRepetitions}。把场景描述写进 name,报告会自己解释自己。

5.6 @RepeatedTest:验证稳定性 ​

java
@RepeatedTest(value = 3, name = "第 {currentRepetition} / {totalRepetitions} 次重复")
void repeated_runs_are_stable() {
    assertEquals(9_000L, calculator.payableInCents(5_000L, 2));
}

它抓的是"偶发问题":数据依赖执行顺序、随机数、时间相关、并发相关的实现,跑一次通过、跑三次可能就暴露了。概率相关的逻辑(抽奖、随机分配)尤其该用它。

6. 递进示例 ​

6.1 最小示例:干净环境 ​

java
private OrderCalculator calculator;

@BeforeEach
void setUp() {
    calculator = new OrderCalculator();      // 每个测试都有自己的实例
}

6.2 业务示例:边界值参数化 ​

见 5.5 节。边界值 + 参数化是最高性价比的组合:五个边界写五行数据,比写五个方法清楚得多。

6.3 反例:把逻辑塞进 @BeforeEach ​

java
@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。

7. 完整可运行代码 ​

文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/LifecycleTest.java(节选)

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,节选):

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));
}

运行命令:

powershell
cd labs
./mvnw -pl testing-unit test -Dtest=LifecycleTest*
./mvnw -pl testing-unit test -Dtest=ParameterizedOrderCalculatorTest

预期输出(生命周期的执行次数一眼可见):

text
[LifecycleTest] @BeforeAll 执行(整个类一次)
[LifecycleTest] @BeforeEach 执行(每个测试前)
[LifecycleTest] @AfterEach 执行(每个测试后)
[LifecycleTest] @BeforeEach 执行(每个测试前)
[LifecycleTest] @AfterEach 执行(每个测试后)
...(每个测试方法前后各一组,共 6 组)
[LifecycleTest] @AfterAll 执行(整个类一次)

Tests run: 6, Failures: 0, Errors: 0, Skipped: 0

参数化测试的结果:

text
Tests run: 15, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.251 s
  -- in cn.kb.learn.testing.ParameterizedOrderCalculatorTest

15 个测试是怎么来的:

来源数量
@CsvSource 的 5 行数据5
@ValueSource(ints = {0, -1, -100})3
@MethodSource 的 4 组 Arguments4
@RepeatedTest(3)3
合计15

一份测试代码,15 个测试用例——这就是参数化的价值。而且失败时报告会指出具体是哪一行数据。

8. Debug 观察点 ​

  1. 在三个测试方法的 assertEquals(1, perTestMethodLog.size()) 处分别断点:观察 this 的 id 各不相同——每个测试拿到的是新实例;
  2. 在 @BeforeAll 方法里断点,观察它只在最开始命中一次;再在 @BeforeEach 断点,观察它命中 6 次(每个测试一次);
  3. 运行 PerClassLifecycle 的两个测试,在第二个测试里观察 calls 里已经有 "first"——PER_CLASS 下实例是共享的;
  4. 在 @MethodSource 的 memberDiscountCases 方法里断点,观察它在测试开始前被调用一次,返回 4 组参数;也可以在返回的 Stream 上加断点看每组数据;
  5. 把 @CsvSource 里的 "10000, 1, 9000" 改成 "10000, 1, 9100"(错的期望值),重跑,观察失败信息里明确给出第几行数据与期望值/实际值——这是参数化最实用的地方。

9. 常见错误 ​

错误 1:@BeforeAll 忘了 static ​

java
@BeforeAll
void beforeAll() { }        // 编译错误 / 运行时错误:必须是静态方法

修复:加 static,或用 @TestInstance(PER_CLASS)(但要接受实例共享的代价)。

错误 2:在 @BeforeEach 里漏掉关键初始化 ​

java
@BeforeEach
void setUp() {
    calculator = new OrderCalculator();
    // 忘记清空 items,导致测试之间互相污染
}

症状:测试单独跑通过、一起跑失败。修复:把所有"每个测试都必须是干净状态"的字段都在 @BeforeEach 里重建(或干脆声明为实例字段并直接初始化)。

错误 3:静态可变状态 ​

java
private static final List<String> ITEMS = new ArrayList<>();    // 所有测试共享

修复:去掉 static,改成实例字段。只有真正只读的常量才该是静态的(如 static final long TIMEOUT = 1000L;)。

错误 4:@MethodSource 的方法不是静态的 ​

java
Stream<Arguments> cases() { }        // 非静态:JUnit 找不到它

修复:加 static(或在 @TestInstance(PER_CLASS) 下改为非静态)。

错误 5:参数化测试的 name 没写 ​

java
@ParameterizedTest
@CsvSource({"5000, 1, 5000"})
void case(long a, int b, long c) { }

症状:报告里显示 case(long, int, long) [1] 这种无法阅读的名字,失败时不知道是哪组数据。修复:name = "单价 {0} × {1} 件 → {2} 分"。

错误 6:为了参数化而参数化 ​

java
@ParameterizedTest
@ValueSource(ints = {5_000})
void single_case(int price) { }      // 只有一组数据,不如普通 @Test

判断标准:数据 ≥ 2 组且结构相同才值得参数化。

10. 动手实验 ​

目标:把一组"复制粘贴的测试"改造成参数化测试,并验证隔离。

步骤:

  1. 运行第 7 节的两条命令,核对输出(6 个测试、15 个测试);
  2. 故意制造污染:把 LifecycleTest 的 perTestMethodLog 改成 static,重跑,观察哪些测试失败——亲手看到"共享可变状态"的破坏力(完成后改回来);
  3. 给 PayableValidator(或自己写一个"年龄校验"方法)写参数化测试:
    • 用 @CsvSource 覆盖 5 个边界值(0、1、17、18、150、151);
    • 用 @ValueSource 覆盖非法输入;
    • 用 @MethodSource 覆盖"输入 + 期望错误消息"的组合场景;
  4. 用 @RepeatedTest(5) 测试一个"随机抽取"或"时间相关"的方法,观察是否稳定;
  5. 用 @Nested 把第 3 步的测试按"合法输入 / 非法输入 / 边界值"分为三个内部类,给每个内部类合适的外层 @BeforeEach。

验收方式:能解释第 2 步失败的原因,以及第 5 步"外层与内层 @BeforeEach 的执行顺序"。

扩展任务:给参数化测试加 @DisplayName 与 name 占位符,让 Maven 报告的中文显示足够清楚——设想你在排查 CI 上的一次失败,报告能不能直接告诉你哪组数据错了。

11. 练习题 ​

基础题

  1. 判断:@BeforeEach 在整个测试类中只执行一次。(对 / 错)
  2. 填空:整个类只执行一次的准备与清理用 ______ 与 ______;每个测试前后的准备与清理用 ______ 与 ______。
  3. 简答:JUnit 5 默认"每个测试方法一个新实例"带来什么好处?

代码阅读题

java
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);
    }
}
  1. 两个测试各输出什么?
  2. 如果把 instanceCounter 改成 static,输出会变成什么?为什么这很危险?

编程题

  1. 写一个"密码强度校验器"的参数化测试:
    • 被测方法 PasswordStrength evaluate(String password) 返回枚举 WEAK / MEDIUM / STRONG;
    • 规则自定(如:长度 < 8 为 WEAK;含大小写与数字为 MEDIUM;再含特殊字符为 STRONG);
    • 用 @CsvSource 覆盖至少 6 组数据(含空串、空格、纯数字、超长密码);
    • 用 @MethodSource 覆盖"边界长度"场景(7、8、9、15、16 字符);
    • 用 @RepeatedTest 验证同一输入多次评估结果一致。

综合任务

  1. 为一个"购物车结算"重构测试结构:
    • 被测类 Cart:addItem(name, priceInCents, quantity)、totalInCents()、applyCoupon(code);
    • 用 @Nested 分成三组:当购物车为空、当有商品时、当使用优惠券时;
    • 用 @BeforeEach 保证每个测试的购物车都是干净的;
    • 用 @BeforeAll 准备一份只读的"优惠券表"(静态常量);
    • 至少写一个 @ParameterizedTest 覆盖优惠券的生效与失效场景;
    • 说明:哪些字段必须是实例的,哪些可以是静态的,判断依据是什么?
分级提示

提示 1(方向):第 4 题的关键是 static 字段在所有测试间共享,而实例字段每个测试重建。

提示 2(关键点):第 6 题的边界值要注意"7 与 8 的分界"——参数化测试最适合表达这种"同结构、不同数据"的场景。

提示 3(接近答案):第 7 题的判断依据是"这个状态会不会被测试修改":会被修改的必须是实例字段(如购物车本身),只读的可以静态(如优惠券表)。

12. 参考答案 ​

基础题
  1. 错。@BeforeEach 在每个测试方法之前都执行一次。整个类只执行一次的是 @BeforeAll。
  2. @BeforeAll 与 @AfterAll;@BeforeEach 与 @AfterEach。
  3. 它保证了实例级隔离:每个测试都在全新的对象上运行,实例字段不可能被上一个测试污染。这直接消灭了"测试互相影响"和"依赖执行顺序"这两类问题——测试因此可以任意顺序、单独或批量运行,结果都一样。
代码阅读题(第 4、5 题)

第 4 题:两个测试都输出 1 / 1。

  • staticCounter 由 @BeforeAll 自增一次,之后不变,所以是 1;
  • instanceCounter 每个测试都拿到新实例(初始为 0),@BeforeEach 自增一次,所以也是 1。

第 5 题:如果 instanceCounter 改成 static,两个测试会输出 1 / 1 和 1 / 2(第二个测试看到第一个测试留下的值)。

为什么危险:这会让测试结果依赖执行顺序。JUnit 5 默认不保证方法顺序,所以同一份代码在本地通过、在 CI 上失败(或反过来)都是可能的——这是最消耗排查时间的一类问题。

而且更糟的变体是"断言恰好仍成立":测试通过了,但它验证的不再是"这一条逻辑",而是"前面某个测试留下的状态"。

编程题(第 6 题)
java
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 里的空白容易被编辑器吞掉,用单引号包起来最稳妥。

综合任务(第 7 题)
java
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)"——它的语义是"为测试准备的环境",而不是生产代码里的常量。

13. 掌握标准 ​

  • [ ] 能说清四个生命周期注解的时机与次数,并正确选择用哪个;
  • [ ] 能解释默认"每测试一新实例"如何保证隔离;
  • [ ] 知道 @TestInstance(PER_CLASS) 的适用场景与代价;
  • [ ] 能用 @Nested 与 @DisplayName 让报告可读;
  • [ ] 能用四种数据源写参数化测试,并给 name 加占位符;
  • [ ] 遇到"单独跑通过、一起跑失败"时,能立刻想到共享可变状态。

14. 扩展资源 ​

15. 下一步学习 ​

《异常测试与超时测试》:把"抛异常"和"耗时"这两类断言写对。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引