简介围绕JUnit单元测试的入门实验文档面向软件测试初学者和需要完成课程实验的学生重点解决从环境安装到编写第一个测试类的完整流程问题。全文以步枪销售佣金计算为实例将JUnit安装、测试方法编写、断言使用与运行结果串联起来并给出Salary类的业务代码及针对销售额、佣金边界的测试用例设计表便于读者理解单元测试如何验证业务逻辑、如何用边界值法挑选输入数据。资源包仅含1个docx文件约1.09MB轻量便于直接查阅和打印。该文档已有4894人学习下载适合课堂实验参考、课后复盘或自学JUnit时对照练习读者可从中获得安装步骤、代码骨架、测试用例思路和结果分析等可直接借鉴的内容。1. 从一份实验报告说起JUnit 单元测试卡人的地方拿到这份实验报告时我最先看的不是结论而是那张列了 27 条的用例表。JUnit 单元测试的门槛从来不在写Test那一行而在于被测代码愿不愿意让你测。报告里的佣金计算器把输入、计算、打印、范围校验全塞进一个类main一跑就要从控制台读数测试代码只能硬编码一组常量断言还得靠一个三参数的同名equals兜底。这份文档的价值恰恰在这里它把一个新手最容易翻车的现场完整保留了下来——边界值表列得漂亮测试代码却只落地了其中一行。适合正在做软件测试课程设计、想把 JUnit 从「能跑」推到「可信」的人也适合要给遗留代码补第一层测试网的工程师。2. 佣金逻辑拆解为什么这个类天生适合当被测对象报告里的业务场景是一个计件销售佣金计算器三类配件按件计价销售商每月有配额上限月末汇总总金额再按阶梯费率算佣金。场景本身不复杂但它同时具备三个特征——分段函数、硬边界、状态依赖这三样恰好是单元测试最常踩雷的地方。把命名中性化之后价格 45/30/25、限额 70/80/90、分段 10%/15%/20% 一字未改这个类是一个相当标准的教学样本规则清晰到能手算期望值又足够脏到能暴露测试设计的问题。2.1 三段阶梯与两条分界线把业务规则翻译成可断言的数先做数学层面的拆解。总价T 45a 30b 25c在有效配额内a∈[1,70]、b∈[1,80]、c∈[1,90]于是T的取值范围是[100, 7800]。佣金的计算方式如下表这也是后面所有期望值的唯一来源分段总价区间美元费率佣金公式区间内佣金范围第一段100 ≤ T ≤ 100010%0.10 × T10.00 ~ 100.00第二段1000 T ≤ 180015%100 0.15 × (T − 1000)100.00 ~ 220.00第三段T 180020%220 0.20 × (T − 1800)220.00 ~ 1420.00这张表藏着一个很容易被忽略的事实佣金函数在 1000 和 1800 两个分界点上都是连续的。T1000用第一段算出 100用第二段算也是100 0.15×0 100T1800用第二段是 220用第三段是220 0.20×0 220。也就是说只测这两个分界点测不出「分段判反」「少减基数」这类典型 bug——它只能证明代码没崩证明不了公式是对的。这是边界值测试里最容易被跳过的一步分界点本身要测但真正有区分力的是分界点两侧的邻点比如 1025第二段生效、1825第三段生效只有跨过分界线之后再走一格两套公式才会给出不同的数。还有一个构造用例时的细节值得记下来。三档单价 45、30、25 都是 5 的倍数所以任何合法组合的总价必然是 5 的倍数。这意味着 999、1001 这类「分界点 ±1」的值在物理上根本构造不出来硬要找邻点只能按 5 的步长去凑。比如a11, b10, c8得到 495 300 200 995这是 1000 下方最近的可达点佣金 99.50而 1000 上方最近的可达点是a10, b10, c11的 1025佣金 103.75。这两个数字才是能真正把第一段和第二段劈开的用例。2.2 把 main 里的输入输出摘出去可测类该长什么样原代码最大的结构问题是不可测构造函数收三个 int 没问题但totalPrice()里直接System.out.println输入靠main里的Scanner范围校验失败时返回-1而commission()又依赖totalPrice这个实例字段是否已经被算过。一条测试如果只调commission()拿到的是-1因为totalPrice还是 0。这种「调用顺序决定正确性」的设计是单元测试里最难缠的一类耦合。我一般会先做一次最小重构把计算拆成纯函数式的方法入参出参明确副作用打印全部外移非法输入抛异常而不是返回哨兵值。改造后的形态大致如下package commission; public class CommissionCalculator { public static final int PRICE_A 45; public static final int PRICE_B 30; public static final int PRICE_C 25; public static final int LIMIT_A 70; public static final int LIMIT_B 80; public static final int LIMIT_C 90; private final int countA; private final int countB; private final int countC; public CommissionCalculator(int countA, int countB, int countC) { this.countA countA; this.countB countB; this.countC countC; } /** 配额是否落在合法区间内纯判断无副作用 */ public boolean inRange() { return countA 1 countA LIMIT_A countB 1 countB LIMIT_B countC 1 countC LIMIT_C; } /** 总价非法输入直接抛异常不打印不返回 -1 */ public double totalAmount() { if (!inRange()) { throw new IllegalArgumentException( count out of range: countA , countB , countC); } return countA * PRICE_A countB * PRICE_B countC * PRICE_C; } /** 佣金内部自取总价不依赖外部调用顺序 */ public double commission() { double t totalAmount(); if (t 1800) { return (t - 1800) * 0.20 800 * 0.15 1000 * 0.10; } if (t 1000) { return (t - 1000) * 0.15 1000 * 0.10; } return t * 0.10; } }几个参数和设计点需要说明。countA/countB/countC用final修饰让实例天然不可变测试之间不会互相污染。commission()内部重新调用totalAmount()而不是读字段多算一次乘加的成本可以忽略换来的是「任何调用顺序都正确」。inRange()单独抽出来是为了让「越界」和「计算」这两类断言可以分开写——越界时断言异常类型合法时断言数值职责清晰。分段判断沿用原报告的而非因为 1800 和 1000 两个点落在哪一段在数学上等价但注释里要写清楚否则下一个人接手会以为是笔误然后「顺手修好」把 1800 的期望值改错。2.3 三参数 equals 的陷阱一个自己和自己比的断言报告里这个方法是整篇文档中最值得拿来复盘的一段代码public boolean equals(double etotalPrice, double ecommission, Object object) { if (object instanceof Salary) { Salary salary (Salary) object; return salary.totalPrice() etotalPrice salary.commission() ecommission; } return false; }测试里调用的是salary.equals(1800, 220, salary)。它有三个层次的问题。第一它不是重写。Object.equals的签名是equals(Object)这里多加了两个double参数只是一个普通重载方法assertEquals(1800, salary)走的仍然是引用比较两个内容相同的不同实例永远返回 false。第二它自己和自己比。三个参数里第一个对象传的就是salary本身object instanceof Salary恒为真salary.totalPrice()又恰好把字段赋了值于是断言成立——通过的原因不是逻辑正确而是调用顺序凑巧对上了。第三浮点用比。0.1 0.15这一类运算在 IEEE 754 下会产生尾差1025 这一档算出 103.75 没问题但如果分段公式里有除法尾差立刻现形。正确的写法是把「值比较」交给断言库别自己造 equalsCommissionCalculator calc new CommissionCalculator(18, 18, 18); assertEquals(1800.0, calc.totalAmount(), 1e-9); // 浮点必须带 delta assertEquals(220.0, calc.commission(), 1e-9);1e-9这个 delta 不是随便填的。佣金计算只涉及乘以 0.10/0.15/0.20最大绝对误差在 1e-12 量级以内1e-9 足够宽容又能挡住真实错误。如果被测逻辑里有除法或者累加上万次delta 要按量级放大到 1e-6。这条血泪经验是delta 写死不如写注释把「为什么是这个精度」写在测试方法上方半年后的自己会感谢你。3. IDEA 里跑通第一组测试依赖、目录与注解版本环境搭建看着最没技术含量实际上是新手卡住最久的一步。报告里写的是「settings → Plugins 搜索安装 → 下载对应版本 JUnit → Project Structure 添加模块」这条路能走通但它把 JUnit 当成 IDE 插件来管和真实的构建工具依赖是两套体系。插件只负责让 IDE 认识注解、给自动补全测试能不能编译、能不能被执行取决于项目的依赖配置和源码目录结构。两套体系一旦对不上就会看到「代码全红但不报错」「运行按钮点了没反应」「0 tests run」这些玄学现场。3.1 Maven 坐标与源码布局先把测试放对地方标准 Maven 布局里主代码在src/main/java测试代码在src/test/java测试依赖用scopetest引入。测试目录不存在就手动建IDEA 通常会自动把它标记成 Test Sources Root目录图标变绿。dependencies !-- 主代码零依赖测试侧统一引入 JUnit 5 聚合包 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.2/version scopetest/scope /dependency dependency groupIdorg.junit.platform/groupId artifactIdjunit-platform-launcher/artifactId version1.10.2/version scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.2.5/version /plugin /plugins /buildjunit-jupiter是聚合包一次带来 api、params、engine 三个模块参数化测试才不用额外加依赖。junit-platform-launcher在 IDE 里跑单个方法时会被用到缺了它偶尔会出现「测试类识别到了但执行报 launcher 相关异常」。surefire 插件版本要显式写老版本默认的 surefire 2.x 不认 JUnit 5 的引擎mvn test会静默跳过所有测试报「Tests run: 0」——这是最容易被误判成「环境没配好」的一次翻车。版本号别照抄以你们仓库里已有的 BOM 为准。混着来会出问题平台版本 1.10.x 配引擎 5.9.x运行时可能报NoSuchMethodError或者干脆不发现测试。3.2 JUnit 4 与 JUnit 5注解对照与迁移代价报告用的是 JUnit 4extends TestCase、org.junit.Test、junit.framework.Assert而现在新建项目基本都是 JUnit 5。两套注解长得像但包名完全不同混用是造成「0 tests run」的头号原因。对照关系如下能力JUnit 4JUnit 5测试方法org.junit.Testorg.junit.jupiter.api.Test每个方法前/后Before/AfterBeforeEach/AfterEach类级别前/后BeforeClass/AfterClassBeforeAll/AfterAll断言入口org.junit.Assert静态导入org.junit.jupiter.api.Assertions异常断言Test(expected...)或 try-catchassertThrows(...)参数化RunWith(Parameterized.class) 构造器ParameterizedTest 参数源注解扩展机制Runner不可组合Extension可组合一个关键差异是可见性。JUnit 4 的测试方法必须是public void且无参写成包级私有或者带返回值运行时会报initializationErrorJUnit 5 放宽到包级私有即可但带参数的方法如果没有参数源注解会抛ParameterResolutionException。这两类报错信息都很难读新手常以为是依赖没配好实际是签名写错了。迁移时还要注意extends TestCase要删掉——JUnit 5 里它没有任何作用留着反而会让 IDE 的 JUnit 4 运行器接管出现「一半测试跑了、一半没跑」的诡异结果。3.3 第一个测试方法从断言数值到断言异常先把最小可跑的骨架立起来跑绿之后再往上堆用例。下面这版是 JUnit 5 的写法覆盖一条正常路径和一条越界路径package commission; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class CommissionCalculatorTest { Test DisplayName(配额全部取 1总价 100佣金 10) void shouldComputeMinimumCase() { CommissionCalculator calc new CommissionCalculator(1, 1, 1); assertEquals(100.0, calc.totalAmount(), 1e-9); assertEquals(10.0, calc.commission(), 1e-9); } Test DisplayName(配额越界应抛 IllegalArgumentException) void shouldRejectOutOfRange() { CommissionCalculator calc new CommissionCalculator(71, 80, 90); assertFalse(calc.inRange()); assertThrows(IllegalArgumentException.class, calc::totalAmount); } }两个方法承担不同职责。第一个断言数值用assertEquals(double, double, double)三参版本delta 必填。第二个断言行为先验证inRange()的判断再验证越界时totalAmount()抛出的异常类型——两步分开写失败时能立刻定位是判断逻辑错了还是抛出逻辑错了塞进一个assertThrows里报错信息只会告诉你「没抛异常」看不出哪一层出的问题。calc::totalAmount是方法引用比写() - calc.totalAmount()更简洁语义也更清楚这里验证的是「调用这个动作会炸」。顺带说一句assertThrows返回的是捕获到的异常对象如果需要对异常消息做断言可以接住再检查IllegalArgumentException ex assertThrows(IllegalArgumentException.class, () - new CommissionCalculator(0, 1, 1).totalAmount()); assertTrue(ex.getMessage().contains(out of range));这么写能防住「抛了异常但抛错了地方」的情况——比如构造器里就抛了而你以为是在计算方法里抛的。异常消息的断言别写全等加一句contains就够了全等会让任何日志格式调整都变成测试失败。4. 边界值落地把 27 条手工用例变成参数化测试报告里的用例表设计得不错问题出在落地方式27 条用例代码里只写了一组10/20/30还是靠那个三参数equals做的断言。这个落差是所有「测试报告写得漂亮、代码一跑就露馅」的典型。表里的每一行其实都是一条独立的测试数据用参数化测试把它们逐行灌进去才叫真的测过。这一步做完后续任何人改动了分段费率只要跑一次mvn test哪一行炸了一眼就能看到。4.1 用例表的组织三个维度决定取值用例的取值不是随机的围绕三个维度展开配额区间的两个端点、总价的两个分界点、以及分界点两侧的邻点。整理成一张可以直接喂给参数化测试的表编号ABC期望总价期望佣金覆盖目标111110010.00区间下界21110899599.50第一段上邻点31010101000100.00分界点一41010111025103.75第二段入口51818171775216.25第二段上邻点61818181800220.00分界点二71818191825225.00第三段入口870808977751415.00上界内缩一格970809078001420.00区间上界10708190—异常B 越界一格编号 2 和 5 是这张表的灵魂。少了它们编号 1/3/4 只能证明「总价小于 1000 时算对了」证明不了「刚好跨过 1000 之后换了费率」编号 6/7 同理。编号 8 和 9 是「上界内缩一格 上界本身」的经典组合能抓住countC 90被误写成 90的差一错误。编号 10 单独拿出来因为它的期望不是数值而是异常没法和其他行共用同一套断言。每一行的期望值都必须手算不能靠跑一遍程序把输出抄回来——那是把 bug 一起固化进测试里。手算过程写进测试方法的注释或者用例表备注列比如 1025 那行10×45 10×30 11×25 450 300 275 1025佣金(1025−1000)×0.15 100 3.75 100 103.75。4.2 CsvSource 与 MethodSource参数源怎么选数据量小、类型简单、全是字面量用CsvSource需要复杂对象、需要动态生成、需要从外部文件读用MethodSource。上面这张表可以直接写成一串 CSV 行package commission; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import static org.junit.jupiter.api.Assertions.assertEquals; class CommissionCalculatorParamTest { ParameterizedTest(name [{index}] {0}/{1}/{2} - 金额 {3}, 佣金 {4}) CsvSource({ 1, 1, 1, 100.0, 10.00, 11, 10, 8, 995.0, 99.50, 10, 10, 10, 1000.0, 100.00, 10, 10, 11, 1025.0, 103.75, 18, 18, 17, 1775.0, 216.25, 18, 18, 18, 1800.0, 220.00, 18, 18, 19, 1825.0, 225.00, 70, 80, 89, 7775.0, 1415.00, 70, 80, 90, 7800.0, 1420.00 }) void shouldMatchExpectedAmountAndCommission(int a, int b, int c, double expectedAmount, double expectedCommission) { CommissionCalculator calc new CommissionCalculator(a, b, c); assertEquals(expectedAmount, calc.totalAmount(), 1e-9); assertEquals(expectedCommission, calc.commission(), 1e-9); } }name属性里的占位符是排错的关键。{index}是执行序号{0}到{4}对应方法参数位置。不写这个失败时控制台只会输出「第 4 个用例失败」你还得回去数 CSV 行数写了之后直接显示[4] 10/10/11 - 金额 1025.0, 佣金 103.75一眼定位。CSV 行里的空格会被自动 trim缩进对齐不影响解析放心排版。MethodSource的写法适合需要计算的场景比如要程序化生成「所有配额取值的笛卡尔积」做一轮遍历static StreamArguments boundaryCases() { return Stream.of( Arguments.of(1, 1, 1, 100.0, 10.00), Arguments.of(10, 10, 10, 1000.0, 100.00), Arguments.of(18, 18, 18, 1800.0, 220.00), Arguments.of(70, 80, 90, 7800.0, 1420.00) ); }方法必须是static、返回StreamArguments、无参方法名与MethodSource(boundaryCases)里的字符串严格一致——写成非静态方法会得到一个相当难读的ParameterResolutionException。4.3 越界输入断言异常而不是断言哨兵值原始实现里越界返回-1并打印一句「输入有错误」。这个设计在测试层面很糟糕-1和合法值混在同一个double返回值里调用方必须靠文档记住「-1 是错误」参数化测试也没法用同一套期望值结构表达。改造成抛异常之后越界就是独立的一条测试路径ParameterizedTest(name [{index}] 越界组合 {0}/{1}/{2}) CsvSource({71, 80, 90, 70, 81, 90, 70, 80, 91, 0, 1, 1, -1, 1, 1}) void shouldRejectOutOfRangeCombination(int a, int b, int c) { CommissionCalculator calc new CommissionCalculator(a, b, c); assertFalse(calc.inRange(), 该组合应被判为越界: a / b / c); assertThrows(IllegalArgumentException.class, calc::totalAmount); assertThrows(IllegalArgumentException.class, calc::commission); }五个组合覆盖了三个维度的上界越界一格、下界取 0、下界取负数。commission()也断言一次是因为改造前它读字段、改造后它调totalAmount()两条路径都必须保证异常穿透出去而不是被吃掉返回-1。assertFalse里带的那个消息参数别省。共用一个参数化方法时失败信息只说「期望 false 实际 true」五个组合长得一模一样你会盯着屏幕反复跑。把组合拼进消息里失败时直接告诉你70/80/91这一组没被拦住。5. 避坑与排查五个让测试「绿得可疑」的现场测试跑绿不等于测对了。下面这五个现场我在不同的项目里反复见过每一个都有明确的现象、成因和处置方式。5.1 全部通过但改错公式依然全绿现象mvn test输出 Tests run: 5, Failures: 0把commission()里的0.15改成0.20再跑一遍还是全绿。原因测试只固定了一组输入而那一组总价落在第一段T ≤ 1000压根没走 0.15 那条分支。报告里10/20/30那组就是典型——总价 450 600 750 1800恰好落在分界点上走的是第二段可代码里把期望值写成了1800和220改费率当然不会炸因为它比的是同一个对象算出来的值参见 2.3 的三参数 equals。解决用例至少覆盖每个分段各一组然后做一次「变异自检」——手动把某个常量改错确认有测试变红再改回来。这一步花不了五分钟但它是判断测试网有没有漏洞的唯一低成本手段。改哪个最有效改0.15 → 0.20和1800 → 1700各一次前者验证费率分支被覆盖后者验证分段边界被覆盖。5.2 单独跑能过一起跑就返回 -1现象只执行testCommission()时拿到正确佣金跑整个测试类时某个方法稳定返回-1。原因commission()依赖totalPrice实例字段字段值靠totalPrice()调用时赋值。测试执行顺序由框架决定谁先谁后不保证于是出现「有时候过有时候不过」的玄学。更隐蔽的是同一实例被多个测试方法共用BeforeEach里复用同一个静态实例上一个测试的残留状态污染下一个。解决先消除状态依赖让commission()内部自算总价不读字段实在改不动被测代码就在每个测试方法里新建实例绝不跨方法共享可变对象。排查时打开 IDE 的测试执行顺序面板把方法名按字母序强制排序跑一遍能快速复现这类问题。5.3 0 tests run注解对了但包名不对现象命令行mvn test输出Tests run: 0, Failures: 0IDE 里点绿三角却能跑。原因测试类里 import 的是org.junit.TestJUnit 4而项目依赖只有 JUnit 5 的引擎或者反过来依赖了 JUnit 5 但 surefire 版本太老不认 Jupiter 引擎。还有一种测试类写在src/main/java下Maven 根本不会把它当测试源码编译。解决先看 import 的包名JUnit 5 必须是org.junit.jupiter.api.Test再确认 pom 里junit-jupiter的 scope 是test且 surefire 在 3.x最后检查目录测试类必须放在src/test/java对应包路径下。三处都对还不行执行mvn dependency:tree看看是不是同时混进了 JUnit 4 的junit:junit和 vintage 引擎两个引擎抢发现权时也会出现计数异常。5.4 浮点断言编译通过但断言永远失败现象assertEquals(103.75, calc.commission())报期望 103.75 实际 103.75000000000001。原因JUnit 5 里assertEquals(double, double)的两参版本已被移除/废弃某些版本会把它解析成assertEquals(Object, Object)对两个Double做equals比较而Double.equals比的是位模式任何尾差都会判不等。解决一律用三参版本assertEquals(expected, actual, delta)delta 按量级取 1e-9。如果是从 JUnit 4 迁过来的代码注意 JUnit 4 的两参版本是「已废弃但能用」编译期只有警告很容易被忽略迁移时才集中爆出来。5.5 测试类混进主代码目录打包体积莫名变大现象mvn package出来的 jar 里出现了CommissionCalculatorTest.class或者测试类突然编译不过提示「找不到 junit 包」。原因测试类被放进了src/main/java。主代码的 classpath 里没有testscope 的依赖所以编译失败如果 IDE 手动加了库编译能过但打包时会被一起塞进产物。解决把测试类整体平移到src/test/java包名保持与主代码一致这样测试类能访问包级私有成员又不会进产物。移动后在 IDE 里右键目录 → Mark Directory as → Test Sources Root让索引重建。顺手在.gitignore之外再加一道保险CI 里跑一次jar tf target/*.jar | grep Test有输出就说明目录又放错了。6. 从跑绿到可信覆盖率读数与一条回归卡口用例跑全之后下一步是确认「跑的那些行到底覆盖了多少分支」。给 pom 挂上 JaCoCo看commission()的三个分支是不是每个都被走到plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.12/version executions executiongoalsgoalprepare-agent/goal/goals/execution execution idreport/idphasetest/phase goalsgoalreport/goal/goals /execution /executions /pluginmvn test之后打开target/site/jacoco/index.html重点不看行覆盖率看分支覆盖率。这个类只有两个if四个分支对照关系很清楚分支触发条件命中用例t 1800为真1825 / 7775 / 7800编号 7、8、9t 1800为假≤ 1800 的所有组合编号 1~6t 1000为真1025 / 1775 / 1800编号 4、5、6t 1000为假≤ 1000 的组合编号 1、2、3四个分支全绿才算把分段逻辑测穿了。如果t 1800为真这一行是黄的说明第三段根本没被执行过——这时候哪怕行覆盖率显示 90%那 10% 恰恰是最贵的 20% 费率段。最后加一道卡口把覆盖率阈值写进构建低于阈值直接失败别指望人每次记得看报告mvn test jacoco:report # 分支覆盖率低于 0.80 时构建失败 mvn org.jacoco:jacoco-maven-plugin:0.8.12:check -Djacoco.haltOnFailuretrue阈值别一上来就定 0.9。遗留项目第一次接入能把新增代码卡在 0.8 就已经很有价值老代码补测试是个长期活儿阈值定太高只会逼着人写一堆没有断言的「占位测试」来凑数。从那以后我每次交测试代码前都强制走一遍「改错一个常量 → 看它变红 → 改回来」这个动作哪怕只有三行断言。它比看覆盖率报告快也比覆盖率诚实——报告能骗人改坏的代码骗不了。希望帮到你。本文还有配套的精品资源点击获取