JUnit单元测试实战:从边界值用例到参数化收敛与断言避坑
简介这份实验报告文档面向软件测试与Java单元测试初学者围绕JUnit的安装配置和实例应用展开帮助读者理解单元测试从环境搭建、测试类编写到结果验证的完整流程。压缩包内共1个docx文件大小约1.09MB内容以实验报告形式组织便于直接查阅和整理。文档选用佣金计算案例给出Salary.java的枪机、枪托、枪管销售金额与分段佣金计算代码并结合边界值方法列出测试用例表与运行结果可用来观察正常输入、边界输入及异常提示的处理方式。已有4894人学习下载适合课程实验、课后练习或自学JUnit时对照参考重点掌握测试用例设计、断言校验和问题定位思路。1. 一份 .docx 实验报告绿条跑出来了断言就一定站得住吗绿条亮起来的那一刻多数人以为 JUnit 单元测试已经做完了。真正的问题在后面这条绿到底证明了什么。手头这份实验报告值得拆原因不在代码多漂亮而在它把「被测类 → 业务规则 → 边界值用例表 → 测试代码 → 运行结果 → 反思」六段完整串成了一条链26 条输入组合的期望值全部写死在表格里任何一个数字都能手算复核。它适合两类人要交软件测试课设、被要求「必须用 JUnit」的学生以及接手一个只有main方法、想补第一层测试网的老项目维护者。下文按这份报告的原始顺序往下推先讲清佣金模型的输入域再落到环境搭建、用例收敛和踩过的坑。2. 把佣金问题拆成可测单元45/30/25 单价与 1000/1800 两条累进线这份报告的被测对象是一个销售佣金计算器某经销商销售三类部件部件 A 单价 45 美元部件 B 单价 30 美元部件 C 单价 25 美元每月每个部件有销售限额A 最多 70 件、B 最多 80 件、C 最多 90 件并且每月至少要售出一套完整组合也就是三类部件各至少 1 件。月末汇总三类部件的数量算出总销售额再按累进费率算佣金。这个模型参数少、规则硬、期望值可手算是练手 JUnit 单元测试的理想样本——它的价值不在于业务本身而在于它天然包含「有效等价类」「无效等价类」「累进分段」三类典型结构。2.1 业务规则先落成公式100 美元和 220 美元两个台阶报告里给出的佣金规则是三段累进不是一刀切。销售额不超过 1000 美元的部分按 10% 计1000 到 1800 美元之间的部分按 15% 计超过 1800 美元的部分按 20% 计。注意「累进」这两个字第二段只对超出 1000 的那部分加价第一段那 1000 美元的 10% 不会因为跨档而重新计算。把它们写成式子被测逻辑就清楚了S 45 * A 30 * B 25 * C # 总销售额A/B/C 为三类部件数量 S 1000 : commission S * 0.10 1000 S 1800 : commission 1000 * 0.10 (S - 1000) * 0.15 S 1800 : commission 1000 * 0.10 800 * 0.15 (S - 1800) * 0.20两处台阶值要背下来S 1000 时佣金恰好 100 美元S 1800 时佣金恰好 220 美元。这两个数字是所有边界用例的锚点写测试时以它们为基准往上加减一个「最小可构造步长」比凭感觉凑数字靠谱得多。再看全区间输入域下限是 ABC1此时 S 45 30 25 100 美元佣金 10 美元上限是 A70、B80、C90此时 S 3150 2400 2250 7800 美元佣金 220 (7800 - 1800) × 0.2 1420 美元。这一头一尾正好对应报告用例表里的第 1 行和第 26 行说明那张表不是随手编的而是先算了极值再往里插点。区间销售额范围该段费率累计佣金上界第一档0 S ≤ 100010%100第二档1000 S ≤ 180015%220第三档S 180020%220 (S − 1800) × 0.2这张表在写断言时直接当期望值来源用。比如测 1800 这个点期望佣金写 220不要写 220.0 之外的任何近似值测 1825 这个点期望写 225因为 25 × 0.2 5。2.2 输入域的四个约束三组限额加一条「至少一套」很多人写这类测试只盯着金额分段忘了数量本身的约束结果越界用例一条都没有。原始约束有四个1 ≤ A ≤ 70A 为整数1 ≤ B ≤ 80B 为整数1 ≤ C ≤ 90C 为整数每月至少售出一套完整组合即三类各至少 1 件。前三条是区间约束第四条其实是前三条下限的合并但由于业务上强调「至少一套完整」在用例设计时它值得单列一条注释因为它解释了下限为什么是 1 而不是 0。这一点直接影响无效等价类的划分A0、B80、C90 属于「数量越界」而 A1、B1、C1 属于「刚好满足完整性」的有效最小值两者的期望结果天差地别一个是错误提示一个是佣金 10 美元。原始代码用一行联合条件把四个约束全塞进去了if (locks 1 locks 70 stocks 1 stocks 80 barrels 1 barrels 90) { totalPrice locks * lockPrice stocks * stockPrice barrels * barrelPrice; return totalPrice; } else { System.out.println(输入有错误); return -1; }这行if的短路求值顺序是 A 的上限、B 的上限、C 的上限依次判断写测试时如果只改一个变量短路会让后面的条件根本没被执行到。换句话说分支覆盖看起来满了条件覆盖其实缺了一大块。想把这块补上就得设计「只越 A 的界」「只越 B 的界」「只越 C 的界」三条独立用例而不是一条 A70、B81、C90 全越的组合。报告用例表的第 26、27 行分别给了70 81 90和0 80 90前者只越 B 的界后者只越 A 的下界思路是对的但还差一条只越 C 的界。2.3 顺序耦合单独调 commission() 为什么会返回 −1这是原始类里最隐蔽的一处设计问题也是初学者测试跑红时最容易被误导的地方。看commission()的开头public double commission() { if (totalPrice 0) { // ... 按 totalPrice 分段计算 return commission; } else { System.out.println(输入有错误); return -1; } }它读的是成员变量totalPrice而totalPrice只在totalPrice()方法被调用时才被赋值。构造完对象直接调commission()totalPrice还是默认值 0.0条件不成立方法返回 −1 并打印「输入有错误」。新手看到这个结果第一反应往往是「构造器写错了」然后在构造器里翻半天——其实构造器没问题问题在于两个方法之间存在隐式调用顺序。报告里的测试代码之所以能过某种程度上是巧合Assert.assertTrue(salary.equals(1800, 220, salary));而equals内部是salary.totalPrice() etotalPrice salary.commission() ecommission从左往右求值先调totalPrice()把字段填上再调commission()就拿到了正确的值。换个写法比如先断言佣金再断言总额整条测试立刻翻车。提示这类隐式状态依赖是单元测试里最常见的「翻车点」之一。被测方法之间有顺序要求测试就必须显式体现这个顺序或者在重构时把它消掉。稳妥的做法有两个方向。方向一是让测试显式遵守顺序assert总价在前、佣金在后并在测试方法上写清注释方向二是把计算逻辑改成纯函数入参即全部依赖public class Salary { private static final int PRICE_A 45; private static final int PRICE_B 30; private static final int PRICE_C 25; private final int a, b, c; public Salary(int a, int b, int c) { this.a a; this.b b; this.c c; } /** 校验输入域越界直接抛异常而不是打印后返回 -1 */ public void validate() { if (a 1 || a 70) throw new IllegalArgumentException(部件A数量越界: a); if (b 1 || b 80) throw new IllegalArgumentException(部件B数量越界: b); if (c 1 || c 90) throw new IllegalArgumentException(部件C数量越界: c); } /** 纯函数不依赖任何外部状态随时可调 */ public double totalPrice() { validate(); return a * PRICE_A b * PRICE_B c * PRICE_C; } /** 纯函数入参是销售额不再读成员字段 */ public double commission(double total) { if (total 1000) return total * 0.10; if (total 1800) return 100.0 (total - 1000) * 0.15; return 220.0 (total - 1800) * 0.20; } }改造点有三个常量用private static final固定避免测试期间被意外改写validate()单拎出来越界用异常表达测试可以用assertThrows精确断言commission(double)改成接收参数调用它不再需要先调totalPrice()顺序耦合消失。这三处改完测试代码的写法立刻从「小心翼翼排顺序」变成「闭着眼睛随便调」。3. 在 IDEA 里把 JUnit 环境搭起来插件、依赖、测试源根三件事的先后顺序报告里给的搭建步骤是「装插件 → 下载 JUnit 加模块 → 新建测试项目 → CtrlShiftT」方向没错但只说了一半。实际卡住新手的地方几乎都不在插件上而在依赖坐标和目录标记上。我按真正会出问题的顺序重排一遍。3.1 插件只是 IDE 侧的壳编译期靠的是依赖IDEA 的 JUnit 插件提供的是「生成测试类」「右键运行」这些 IDE 能力它不含junit.jar。代码里写import org.junit.Test;能通过语法高亮但一编译就报程序包org.junit不存在原因就是编译期 classpath 上没有这个包。路径一Maven/Gradle 工程推荐。在pom.xml里加依赖作用域必须是testdependencies !-- JUnit 4 写法 -- dependency groupIdjunit/groupId artifactIdjunit/artifactId version4.13.2/version scopetest/scope /dependency !-- 或者 JUnit 5 写法二选一不要混着引 -- !-- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.2/version scopetest/scope /dependency -- /dependenciesscopetest/scope的含义是这个依赖只在编译和运行测试代码时可见打包进生产产物时会被排除。把它写成compile不算错但会把测试框架带进发布包属于典型的规范问题。路径二非 Maven 的普通 Java 工程。这时候要手动把 jar 加到模块依赖里。报告里「下载对应版本的 Junit 并在 Project Structure 中添加模块 Junit」说的就是这一步。操作次序是File → Project Structure → Modules → Dependencies → → JARs or directories选中下载好的junit-4.13.2.jar和hamcrest-core-1.3.jarJUnit 4 的断言依赖 Hamcrest漏了它会在运行期报NoClassDefFoundError: org/hamcrest/SelfDescribing作用域同样选Test。提示JUnit 4 需要 hamcrest-coreJUnit 5 不需要。这两个 jar 一起下载、一起加比事后看着NoClassDefFoundError猜要快得多。版本选择上给一条硬规矩项目里 JDK 8 就配 JUnit 4.13.2JDK 11 及以上可以上 JUnit 5。不要在同一模块里既引 JUnit 4 又引 JUnit 5除非你明确知道 Vintage 引擎在做什么否则Test注解读哪个包会变成一个玄学问题。3.2 测试源根与包名CtrlShiftT 找不到类的真实原因CtrlShiftTWindows/Linux或CmdShiftTmacOS是 IDEA 的「创建测试」快捷键光标停在被测类名上按下即可。它失效通常只有两个原因。第一个原因是目录没被标记为测试源根。Maven 约定测试代码放在src/test/javaIDEA 需要在Project Structure → Modules → Sources里把该目录标记为Test Sources目录图标变绿。没标记的目录IDEA 视为普通目录生成的测试类不会进入测试 classpath右键跑的时候会提示No tests found。第二个原因是包名对不上。IDEA 生成测试类时会默认带上与被测类相同的包声明。如果被测类在package main;下报告里就是这样测试类也必须写package main;并且物理路径要与包名一致。手工把测试类挪到别的包Test方法依然能编译但反射查找时可能找不到表现同样是No tests found。目录结构长这样demo-project/ ├── pom.xml └── src/ ├── main/ │ └── java/ │ └── main/ │ └── Salary.java └── test/ └── java/ └── main/ └── SalaryTest.java包名main是个历史包袱报告里沿用了实际项目里建议改成com.example.salary这类有域名的包名避免和其他模块冲突。3.3 一份能独立跑起来的最小测试骨架报告里的测试类同时继承了junit.framework.TestCaseJUnit 3 的写法又用了Before、TestJUnit 4 的写法这是典型的混搭。它能跑是因为 JUnit 4 检测到TestCase子类时会退回JUnit38ClassRunner按方法名前缀test去找用例Test注解形同虚设Before也不会执行。规范写法二选一。这里给 JUnit 4 的完整骨架能直接在 IDEA 里跑package main; import org.junit.After; import org.junit.Before; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; public class SalaryTest { private Salary salary; Before // 每个 Test 执行前都会跑一次 public void setUp() { salary new Salary(10, 20, 30); // A*45B*30C*25 1800 } After // 每个 Test 执行后都会跑一次用于释放资源 public void tearDown() { salary null; } Test public void testTotalPriceAtBoundary() { assertEquals(1800.0, salary.totalPrice(), 1e-9); } Test public void testCommissionAtBoundary() { assertEquals(220.0, salary.commission(salary.totalPrice()), 1e-9); } Test public void testOutOfRangeShouldThrow() { Salary bad new Salary(70, 81, 90); assertThrows(IllegalArgumentException.class, bad::validate); } }三点说明。Before里初始化被测对象是为了让每个测试方法拿到干净实例避免用例之间互相污染After里置空是习惯动作对简单对象意义不大但涉及文件流、数据库连接时必须写。assertEquals的三个参数依次是期望值、实际值、容差第三个数不能省——浮点比较省掉容差是另一类经典翻车第 4 章展开。4. 边界值用例落到断言上26 条输入组合怎么变成一张参数化表报告最值钱的部分是那张 26 行的用例表每一行都有 ID、三类部件数量、期望销售额、期望佣金和注释。问题在于它的测试代码只写了locks10, stocks20, barrels30一条表格和代码完全脱节。这一章讲怎么把表原样搬进代码。4.1 边界点的可构造性为什么 1025 比 1001 更值得测纯数学上的边界值测试会说「1000 的两侧取 999 和 1001」。这个模型里行不通。因为三类部件的单价是 45、30、25最大公约数是 5任何组合的销售额必然是 5 的整数倍999、1001、1801 这些数字根本构造不出来。硬要写进期望值只会得到一条永远失败的用例。正确做法是先算出「可达金额的最小步长」再在边界上取最近的可达点。1000 的上一档是 1005用1×45 2×30 8×25 45 60 200 305不行得凑。报告里给的是 102510,10,11450 300 275 1025和 97510,10,9450 300 225 975这两个点距 1000 分别是 25 和 −25是当时能凑出的最近距离。1800 那条线同理用了 177518,17,18和 182518,18,19。我把这张表里最有代表性的几行挑出来核对一遍方便抄的时候对答案部件 A部件 B部件 C销售额佣金用例定位11110010有效最小值55550050第一档中点1010101000100第一档上边界1010111025103.75刚越过第一档边界1414141400160第二档中点1818181800220第二档上边界1818191825225刚越过第二档边界4848484800820第三档中点70809078001420有效最大值708190越界错误仅越 B 的上限08090越界错误仅越 A 的下限按「每个边界取上、中、下三点每档补一个中点再给三条单变量越界」的规则这张表从 26 行压到 11 行左右边界覆盖率一点没掉反而因为是按规则生成未来参数一改还能自动扩展。4.2 用参数化测试把 26 行表格搬进代码JUnit 5 的ParameterizedTest配CsvSource是最省事的方案一行 CSV 就是一条用例package main; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; public class SalaryParameterizedTest { /** 有效用例四个入参依次是 A/B/C/期望佣金销售额由代码内部算不写死 */ ParameterizedTest(name [{index}] A{0} B{1} C{2} 期望佣金{3}) CsvSource({ 1, 1, 1, 10.00, // 有效最小值 5, 5, 5, 50.00, // 第一档中点 10, 10, 10, 100.00, // 第一档上边界 10, 10, 11, 103.75, // 刚越过第一档 14, 14, 14, 160.00, // 第二档中点 18, 18, 18, 220.00, // 第二档上边界 18, 18, 19, 225.00, // 刚越过第二档 48, 48, 48, 820.00, // 第三档中点 70, 80, 90, 1420.00 // 有效最大值 }) void testCommission(int a, int b, int c, double expected) { Salary s new Salary(a, b, c); double total s.totalPrice(); assertEquals(expected, s.commission(total), 1e-6); // 容差见 4.3 } /** 无效用例单变量越界应当抛异常 */ ParameterizedTest(name [{index}] A{0} B{1} C{2} 应越界) CsvSource({ 70, 81, 90, // 仅 B 越上限 0, 80, 90, // 仅 A 越下限 70, 80, 91 // 仅 C 越上限 }) void testOutOfRange(int a, int b, int c) { Salary s new Salary(a, b, c); assertThrows(IllegalArgumentException.class, s::validate); } }参数说明几处。name [{index}] ...让运行面板里直接显示这一行的入参失败时不用数第几行。CsvSource默认会去掉每个值前后的空白所以 1, 1, 1, 10.00里的空格不用管。有效用例里期望佣金写死、销售额不写死是为了让断言只盯佣金——销售额算错会连带佣金错报错信息里能一眼看出是哪个环节出的问题如果两个都写死失败时就得多算一遍。越界用例单独一个方法原因是失败语义不同有效用例失败说明计算逻辑错无效用例失败说明校验逻辑漏了。混在一个方法里跑红之后第一眼判断不出问题在哪。4.3 浮点断言与容差103.75 这类金额怎么写才不飘这是最容易让人怀疑人生的地方。(1025 - 1000) * 0.15在 double 下不是 3.75而是 3.7499999999999996加上 100 之后是103.74999999999999。如果断言写成assertEquals(103.75, s.commission(total)); // 编译不过JUnit 5 无此重载JUnit 4 下则直接失败在 JUnit 4 里assertEquals(double, double)已被标记为废弃实际走的是精确比较这条用例必红。这不是代码写错了是二进制浮点表示 0.15 的固有误差。三种处理方式按场景选加容差。assertEquals(103.75, actual, 1e-6)第六位小数以内都算对。佣金金额业务上精确到分1e-6 的容差绰绰有余0.000001美元不会有人来找你。转整数分。把佣金乘 100 之后Math.round成long断言整数相等。适合对精度有合规要求的场景。用BigDecimal。被测类内部金额直接用BigDecimal存费率用字符串构造new BigDecimal(0.15)全程不经过 double。改造量最大但能根治。我一般选第一种理由很简单被测类本身返回double测试端用BigDecimal只会在被测类边界上引入一次转换误差掩盖不了源头问题。要根治得连被测类一起改那是重构任务不属于补测试的范围。5. 避坑与排查五个让测试「假绿」的细节绿条不等于有效测试。下面五条都是从这类实验报告里反复见到的真实问题按「现象 → 原因 → 解决」写对号入座即可。5.1 断言与用例层面的三个坑坑一用例表 26 行测试代码只有 1 条。现象是覆盖率报告里commission()的第二、第三档分支全红但整个测试类跑起来是绿的。原因是设计和实现脱节用例在 Word 里设计代码里只挑了最好算的那一条写。解决方式就是 4.2 的参数化把表格原样搬进CsvSource一条方法吃一整档改一个数字就多一条用例。坑二期望值和被测值挤在一个布尔里。现象是失败时只报一行AssertionError看不出是总价错还是佣金错。原始的equals(double etotalPrice, double ecommission, Object object)就是这种写法两次比较用串起来短路之后后半段根本没执行。原因在于把「两个独立断言」合并成了「一个复合布尔」丢掉了断言的定位能力。解决是拆开assertEquals总价一条、佣金一条两条各自报错一眼定位。顺带一提方法名用equals且带三个参数这是重载而非重写Object.equals容易让后来的维护者误以为是标准相等判断改个名能省很多解释成本。坑三错误路径只返回 −1测试只能断言「等于 −1」。现象是越界用例通过了但业务上「A 越界」和「B 越界」返回的都是 −1测试区分不出来将来有人把 A 的上限从 70 改成 60这条用例照样绿。原因是方法用「打印 返回特殊值」表达错误而特殊值和正常值共享同一个返回类型语义被抹平了。解决是改成抛异常不同变量抛不同消息的IllegalArgumentException测试端用assertThrows加消息匹配越界原因一变就能立刻发现。5.2 环境与执行层面的两个坑坑四继承TestCase又写Before初始化方法从不执行。现象是断点打在setUp()里从来进不去测试方法里的成员变量是 null触发NullPointerException。原因是 JUnit 4 的运行器检测到类继承自junit.framework.TestCase会走 JUnit 3 兼容路径JUnit38ClassRunner只认test前缀的方法名和setUp()/tearDown()这两个固定名字Before、After、Test全部被忽略。解决是二选一要么彻底用 JUnit 4去掉extends TestCase方法上全用注解要么彻底用 JUnit 3把所有方法名前缀改成test删掉注解。混着写是最坏的选择因为它能跑只是行为不是你预期的那个。坑五NoClassDefFoundError: org/junit/Test或者No tests found。前者是依赖没进 classpath后者是目录没标记。分开说。NoClassDefFoundError出现时先看pom.xml里scope是不是写成了provided——provided在测试阶段理论可见但如果父 POM 里有dependencyManagement覆盖了版本实际解析出来的可能是空。用mvn dependency:tree -Dscopetest打出来看一眼最直接。No tests found出现时先确认src/test/java目录在Project Structure里是绿色的 Test Sources再看包名和物理路径是否一致最后确认测试类名不是Test开头或结尾之外的怪名字——IDEA 的默认扫描规则是*Test、Test*、*Tests、*TestCase这几种叫SalaryCheck就跑不起来。提示JUnit 4 与 JUnit 5 的断言类不同包org.junit.Assert和org.junit.jupiter.api.Assertions混用会编译失败。迁移时整个测试类一起换别一个文件里两种 import 并存。6. 把 26 条用例收敛到 9 条等价类合并与分支覆盖的验证方法写完参数化表之后下一步不是继续加用例而是砍。报告的结尾自己也反思了这点「应尽量减少测试用例数目尽可能找到一个和很多测试用例等价的一个用例」。这句话是对的但没说怎么砍。我的做法是三步。第一步按档位分组。第一档 100 到 1000 之间取「最小值、中点、上边界」三个点第二档 1000 到 1800 同样三个点第三档 1800 到 7800 取「下边界刚过、中点、最大值」三个点。九条覆盖三档的全部等价类。第二步把「刚过边界」的点做成参数化里的偏移量而不是写死金额。因为单价 45/30/25 的最大公约数是 5可达金额只能落在 5 的倍数上边界偏移只能取 5 的整数倍这一点写成注释比写成代码更省事。第三步跑一次带分支覆盖的测试。IDEA 里Run SalaryTest with Coverage或者 Maven 里配 JaCoCo 出报告看commission()的三个分支和totalPrice()的两个分支是否都是绿色。收敛后的九条用例应该能把分支覆盖率顶到 100%如果某一个分支红了说明该档位缺一个代表点补回去而不是继续加随机用例。收敛前收敛后覆盖率变化26 条有效用例9 条分支覆盖 100%不变2 条越界用例3 条条件覆盖从 2/3 提升到 3/31 个测试方法2 个参数化方法失败定位从「整类红」变成「按行报」最后一步验证别省把commission里的0.15临时改成0.16重新跑。九条用例里应该有第二档和第三档的用例变红第一档保持绿。如果全绿说明期望值是照着实现抄的不是照着业务规则独立算的——这种测试最危险它只能防止代码被改坏不能防止代码一开始就写错。从那以后我每次把实验报告里的用例表往测试类里搬之前都先用一段小脚本把可达金额枚举一遍确认边界点真的构造得出来再动手写CsvSource# 枚举所有合法组合的销售额确认边界附近到底有哪些可达值 reachable sorted({45 * a 30 * b 25 * c for a in range(1, 71) for b in range(1, 81) for c in range(1, 91)}) print(reachable[0], reachable[-1]) # 100 7800 print([x for x in reachable if 990 x 1010]) # 1000 附近的可达点这套动作做过两三次之后就成了肌肉记忆写测试时手比脑子快。希望帮到你。本文还有配套的精品资源点击获取