简介:Junit单元测试实验报告面向软件测试与Java入门学习者,围绕单元测试环境搭建和实例演练展开,适合课程实验、课后练习及实验报告参考。压缩包内仅含1个docx文档,大小约1.09MB,内容集中在一份可编辑的Word报告中,便于直接查阅、摘录和二次整理。文档先说明Junit的安装与配置过程,再以步枪销售商佣金计算为案例,给出Salary类的业务代码、销售额与佣金的分段计算逻辑,并设计边界值测试用例,覆盖枪机、枪托、枪管数量的有效范围及最小值、略大于最小值、中点等情形。读者可据此理解JUnit断言、测试方法组织、测试结果判读和边界值分析思路,也能参考报告中的代码片段、用例表和运行结果完成自己的单元测试实验。目前已有4894人学习下载,适合需要从安装到案例实操完整走一遍Junit单元测试流程的初学者。
1. 那份 JUnit 单元测试实验报告,真正的价值不在“跑通了”
前阵子有人把一份 Junit 单元测试实验报告.docx 发给我,说测试全绿,但被问到“你到底验证了什么”时答不上来。我打开看了十分钟就明白问题出在哪:这不是他一个人的毛病。文档以一道销售佣金题做载体,给出了被测类、27 条边界值用例、在 IDEA 里配置 JUnit 的步骤、测试代码和运行结果,是一条从需求到断言的完整链路;但它同时也是一个典型样本——断言写成了自我比较,越界分支其实一条都没测到,被测类还存在调用顺序耦合。
这份材料适合两类人:正在学软件测试、要交实验报告的学生,以及想把 JUnit 真正用进项目里的初级开发。它更像一本错题本,照着跑一遍能通,拆开看才知道坑埋在哪儿。
2. 工程全貌:从一道佣金题面长出来的 JUnit 实验工程
先别急着打开 IDEA。这份文档里的东西加起来其实只有两个.java文件加一张用例表,但它把“需求描述 → 被测实现 → 测试用例设计 → 断言执行 → 结果分析”这条链走完整了。搞清楚每个文件在链上负责哪一段,后面改代码才不会一通乱改。
2.1 题面拆解:三类配件、三重限额、三档累进佣金
题面本身是一个销售佣金计算问题:某销售商销售三类配件,原文里叫 lock、stock、barrel,单价分别是 45、30、25 美元。销售商每个月至少要售出一套完整组合,所以三类数量各自的下限都是 1;同时受生产限额约束,三类数量上限分别是 70、80、90。佣金按销售额分段累进:
- 不超过 1000 美元的部分,按 10% 计提;
- 1000 到 1800 美元的部分,按 15% 计提;
- 超过 1800 美元的部分,按 20% 计提。
把这几条落成一张参数表,读代码的时候对着看会快很多:
| 参数 | 含义 | 取值 |
|---|---|---|
| lockPrice | 配件 A 单价 | 45 美元 |
| stockPrice | 配件 B 单价 | 30 美元 |
| barrelPrice | 配件 C 单价 | 25 美元 |
| locks | 售出数量 | 1 ~ 70 |
| stocks | 售出数量 | 1 ~ 80 |
| barrels | 售出数量 | 1 ~ 90 |
| 佣金档位 | 累进区间 | 10% / 15% / 20% |
这里有一个非常容易翻车的点:分段累进不等于全额累进。销售额 1400 美元时,正确佣金是1000 × 10% + 400 × 15% = 160,不是1400 × 15% = 210。我在别的项目里见过按最高档一刀切的写法,财务对账时差了整整 50 美元,查了一下午。这份文档的commission()用的是累进写法,这点是对的,值得保留。
还有一个隐含约束容易被忽略:题面说“每月至少要售出一支完整组合”,意思是三类数量不能是 0,也不能出现只卖枪托不卖枪机的情况。所以合法输入的下限是 (1,1,1),不是 (0,0,0)。边界值取点的时候如果漏了这条,正好把最可能出错的地方放过去了。
2.2 文件结构:两个类撑起整条测试链
文档里的代码都在同一个包里,结构很扁平:
| 文件 | 角色 | 关键内容 |
|---|---|---|
Salary.java | 被测类 | 三个单价字段、三个数量字段、totalPrice()、commission()、equals()、main() |
SalaryTest.java | 测试类 | 继承junit.framework.TestCase,一个@Test方法 |
| 文档正文 | 需求与用例 | 题面描述、边界值分析、27 条用例表、IDEA 配置截图、运行截图、结果分析 |
Salary.java用main方法做手工验证,通过Scanner从控制台读三个数再打印结果;SalaryTest.java用 JUnit 做自动断言。这两种验证方式放在一起其实是好事:手工跑一遍能让你直观看到金额,自动跑一遍能保证回归时不退化。麻烦在于main里的Scanner被注释掉之后又被复制到了测试方法里,这是后面 Scanner 卡死的伏笔。
从工程角度看,包名main是 IDE 新建文件时随手留下的,换成com.example.salary之类会规范些,但实验报告场景下不用纠结这个,改包名反而容易和文档截图对不上。
2.3 环境与依赖:JUnit 4 还是 JUnit 5
文档用的是org.junit.Test加junit.framework.TestCase,这是 JUnit 4 的写法,而且是最老的那一种——既用了注解,又继承了TestCase。这种混搭在 JUnit 4 里能跑,但属于典型的“两套 API 一起用”,新手看了容易糊涂。
环境按这个配最省事:JDK 8 或以上、IntelliJ IDEA、JUnit 4.13.2 加 hamcrest-core 1.3(JUnit 4 的断言内部依赖它)。如果用 Maven 管依赖,pom.xml里加这一对就够了:
<dependencies> <!-- JUnit 4 本体,4.13.2 是 4.x 最后一个稳定版本 --> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>test</scope> </dependency> <!-- JUnit 4 的断言依赖 hamcrest,缺了会在运行时抛 NoClassDefFoundError --> <dependency> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-core</artifactId> <version>1.3</version> <scope>test</scope> </dependency> </dependencies>不用 Maven 的话,就在 IDEA 里 File → Project Structure → Libraries → 加号 → From Maven,搜junit:junit:4.13.2,IDEA 会把 hamcrest 一起拉下来。文档里写的“下载对应版本的 JUnit 并在 Project Structure 中添加模块”说的就是这个动作,只是没提 hamcrest,这一步是新手最容易漏的。
提示:如果你新建的项目默认是 JUnit 5,
org.junit.Test会标红。不是代码错,是依赖没对上。要么把依赖换成 JUnit 4,要么把 import 换成org.junit.jupiter.api.Test,但后者和文档里的TestCase不兼容,改起来动静更大。
2.4 三种跑法:命令行、IDE、构建工具
跑测试有三条路,实验报告里只写了 IDE 那条,我补齐另外两条,因为后面接 CI 的时候用得上。
第一条是纯命令行,不依赖 IDE:
# 编译被测类和测试类,JUnit 的 jar 要放进 classpath javac -encoding UTF-8 -cp "lib/junit-4.13.2.jar:lib/hamcrest-core-1.3.jar" \ -d out src/main/Salary.java src/main/SalaryTest.java # 用 JUnitCore 启动测试运行器,参数是测试类的全限定名 java -cp "out:lib/junit-4.13.2.jar:lib/hamcrest-core-1.3.jar" \ org.junit.runner.JUnitCore main.SalaryTest-d out指定编译产物目录,-cp里冒号是 Linux/macOS 的分隔符,Windows 下换成分号。JUnitCore是 JUnit 4 自带的入口类,跑完会打印OK (1 test)或者失败堆栈,很适合在没有图形界面的机器上做快速验证。
第二条是 IDEA 里直接右键测试类 → Run,文档里演示的就是这个。注意左边栏的绿色箭头如果对某个方法不亮,通常是这个类没被标记成 Test Sources Root 或者依赖没加载完,右键目录 Mark Directory as → Test Sources Root 即可。
第三条是 Maven:
# -Dtest 指定只跑某个测试类,不写就跑全部 mvn -q test -Dtest=SalaryTestmvn test会先编译再跑 surefire 插件,失败时在target/surefire-reports里留一份报告。这条路的额外好处是:你的测试放在了src/test/java下,被测代码在src/main/java下,目录一分,什么该发布、什么不该发布一目了然。实验报告里的两个类同包同目录,长期维护下去会越来越乱,这是后面二次开发要动的地方。
3. 被测代码拆解:Salary 类里三处必须看懂的实现
Salary这个类一共六十来行,但它的写法集中了几个初学者常见问题:用字段缓存中间结果、用-1当错误哨兵、重载了一个叫equals但签名完全不同的方法。这三处单独看都不致命,凑在一起就让测试变得很难写对。
3.1 字段与构造函数:中间结果被当成了状态
先看字段和构造部分:
package main; public class Salary { private int lockPrice = 45; // 配件 A 单价 private int stockPrice = 30; // 配件 B 单价 private int barrelPrice = 25; // 配件 C 单价 private int locks; // 售出数量 private int stocks; private int barrels; private double totalPrice; // 中间结果,被存成了字段 private double commission; // 中间结果,被存成了字段 public Salary(int clocks, int cstocks, int cbarrels) { locks = clocks; stocks = cstocks; barrels = cbarrels; } }三个单价写成了实例字段并给了初始值,从业务上讲它们应该是常量——价格不会因为某个销售商的销量而变。写成private static final int LOCK_PRICE = 45;更合适,编译器还能顺手做常量折叠。
真正麻烦的是totalPrice和commission这两个字段。它们不是对象的状态,是计算过程的中间结果,却被存了下来。后果是commission()必须依赖totalPrice()先被调用过,否则读到的totalPrice是 0,直接走进“输入有错误”分支。这种隐式的调用顺序依赖,在测试里非常要命:同一个对象,先调 A 再调 B 和先调 B 再调 A 结果不同,测试写出来自己都不敢信。
构造函数的参数名clocks和字段名locks差了三个字母,这是打字错误还是有意为之不好说,但赋值写的是locks = clocks,逻辑上没错。这种命名在 code review 里一定会被挑出来,读代码的人第一眼会以为clocks是另一个概念。
3.2 totalPrice():区间校验加 -1 哨兵
销售额的计算和校验挤在一个方法里:
public double totalPrice() { // 三重限额一起判断,任意一项越界就走 else if (locks >= 1 && locks <= 70 && stocks >= 1 && stocks <= 80 && barrels >= 1 && barrels <= 90) { totalPrice = locks * lockPrice + stocks * stockPrice + barrels * barrelPrice; System.out.println("销售金额为:" + totalPrice + "美元"); return totalPrice; } else System.out.println("输入有错误!!!"); // 注意:这里没有花括号 return -1; // 这行和 if 同级,任何情况都会走到 }这里有个写代码时最容易忽略的细节:else后面没有花括号,所以return -1;不属于else分支,它和整个if-else同级。合法输入时因为if里已经return了,走不到这行,看起来“碰巧是对的”;一旦有人把if里那句return totalPrice;删掉或者挪个位置,方法就会静默返回 -1,而且沿途已经把正确金额打印到控制台了,日志上看不出任何异常。这种“碰巧正确”的代码,比明显的 bug 更难查。
-1这个返回值的约定也很危险。调用方拿到 -1 只能靠“金额不可能是负数”这条常识去判断出错,编译器帮不上忙,IDE 也不会提醒。更规范的做法是抛IllegalArgumentException,让错误在调用点立刻炸出来。文档里的测试之所以要费劲比对返回值,很大一部分原因就是这里用了哨兵值而不是异常。
System.out.println写在计算方法里也是个败笔。跑一次测试控制台刷十几行,真正的失败信息淹没在里面;而且打印本身有 IO 开销,批量跑参数化用例时会明显拖慢。真要输出,用日志框架在测试层打。
3.3 commission():三档累进的写法是对的
佣金计算这段逻辑本身没问题,是整份代码里最值得学习的部分:
public double commission() { if (totalPrice > 0) { // 依赖上一次 totalPrice() 的副作用 if (totalPrice > 1800) { // 超过 1800 的部分按 20%,1000~1800 那 800 按 15%,前 1000 按 10% commission = (totalPrice - 1800) * 0.2 + 800 * 0.15 + 1000 * 0.1; } else if (totalPrice > 1000) { // 1000~1800 区间,超出 1000 的部分按 15% commission = (totalPrice - 1000) * 0.15 + 1000 * 0.1; } else { // 1000 以内全额 10% commission = totalPrice * 0.1; } System.out.println("佣金为:" + commission + "美元"); return commission; } else System.out.println("输入有错误!!!"); return -1; // 同样缺花括号,同样碰巧正确 }800 * 0.15这个 800 是 1800 减 1000 来的,写成硬编码能跑,但读者得自己算一遍才知道含义。换成(1800 - 1000) * 0.15会清楚得多。同理1000 * 0.1里的 1000 是第一档的上限。
这段代码有个副作用值得单独强调:它读的是字段totalPrice,不是重新算一遍。如果你在测试里只调commission()不调totalPrice(),totalPrice字段还是初始值 0,方法直接进else返回 -1。文档里那条测试之所以能通过,是因为equals()内部先调了totalPrice()再调commission(),顺序刚好对。换个调用顺序就翻车——这就是我前面说的调用顺序耦合,测试代码看着没问题,其实是被被测类的实现细节绑架了。
注意:区间判断用的是
>而不是>=。销售额正好 1000 时走的是else分支,算出来 100;正好 1800 时走else if分支,算出来1000 × 0.1 + 800 × 0.15 = 220。这两个点都是边界点,必须有用例覆盖,否则把>写成>=也没人发现。
3.4 equals 三重载:这不是重写,是自证
这段是整份代码里问题最大的地方:
// 判断钱数是否相同——注意签名是三个参数,不是 Object.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; }方法名叫equals,参数却是(double, double, Object),和Object.equals(Object)的签名完全不同,所以它不是重写,是重载。重载equals这件事本身就违反Object的契约,IDE 会给警告,代码检查工具也会报。更麻烦的是,它和Object.equals(Object)放在一起时,调用方稍微写错一个参数,就会静默调到另一个方法上。
第二个问题是文档里那条测试的调用方式:
Salary salary = new Salary(10, 20, 30); Assert.assertTrue(salary.equals(1800, 220, salary)); // 自己和自己比期望值 1800 和 220 是硬编码的,被测对象传的是salary自己。方法内部拿salary重新算一遍,再和 1800、220 比。这个断言确实能验证计算逻辑,但它验证的是“这一刻这个对象算出来的值等于我写死的值”,如果明天有人把单价从 45 改成 46,测试会挂,这是好事;可如果有人把commission()的累进改成全额,只要测试里那两个数字跟着改,测试照样绿。断言本身没有错,错的是这个 API 设计逼着调用方把期望值拆成两个裸数字传进去,而不是构造一个“期望对象”来比较。
想写成对象比较也行,但得先实现规范的equals(Object)和hashCode(),然后写assertEquals(expected, actual)。文档注释里被注释掉的那两行Salary expected = new Salary(17, 19, 21);大概就是这个思路,但没走完。
3.5 main 方法:手工验证的入口
public static void main(String args[]) { Scanner in = new Scanner(System.in); System.out.println("请输入枪机数量:"); int locks = in.nextInt(); // ... 依次读 stocks、barrels Salary salary = new Salary(locks, stocks, barrels); System.out.println(salary.totalPrice()); System.out.println(salary.commission()); }这段代码的作用是手工验算,输入 10、20、30 能看到 1800 和 220,和用计算器算一遍对得上,心里就有底了。它的价值在于让你在写断言之前先确认业务逻辑本身没理解错,别辛苦写了一堆测试,结果是照着错误的预期写的。
但它不能进测试流程。Scanner会阻塞在System.in上,JUnit 里如果误调了带Scanner的方法,测试进程会一直挂着,IDEA 里显示为“测试正在运行”,实际上什么都不会发生。文档里的测试方法把这段Scanner代码注释掉了,这是对的,但同时也把main里的验证逻辑复制了一份进测试方法,注释块留了一大坨在那里,读起来很乱。
4. 用例设计到断言落地:27 条边界值怎么变成可执行代码
文档最扎实的部分是用例设计,一口气列了 27 条,覆盖最小值、略高于最小值、中点、边界点、略低于最大值、最大值和越界。可惜这 27 条只停留在表格里,代码里只写死了其中一条。两者的落差,正是这份实验报告最值得动手补的地方。
4.1 边界值分析:把限额和佣金档位拆成取点
边界值法的核心就一句话:错误最容易藏在边界上。这道题有两组边界——数量限额和佣金档位——所以取点也分两组。
数量这组,边界是 1 和 70/80/90。取点规则是:最小值、最小值加一、中间某个值、最大值减一、最大值、越界值。比如配件 A,取 1、2、48、69、70、71。71 那条是故意越界的,用来验证totalPrice()会不会拒绝。
佣金这组,边界是 1000 和 1800。取点是:999、1000、1001、1799、1800、1801。这几个点里,1000 和 1800 属于“踩在档位线上”,最容易暴露>和>=写反的问题。文档表格里第 9 条10/10/10 → 1000 → 100和第 17 条18/18/18 → 1800 → 220就是这两个点,取得到位。
问题出在越界用例上。表格第 26 条写的是70/81/90 → 输入错误,这条只能验证“81 个枪托被拒绝”,它验证不了“71 个枪机被拒绝”或者“91 个枪管被拒绝”。三个维度各写一条越界,才能保证每个if条件里的>都没写错。原文只写了两条越界,覆盖是不完整的。
4.2 用例表逐行读:几个值得抄下来的点
挑几条有代表性的看,能看出设计者的思路:
| 用例 ID | 数量组合 | 期望销售额 | 期望佣金 | 设计意图 |
|---|---|---|---|---|
| 1 | 1/1/1 | 100 | 10 | 合法输入下限 |
| 9 | 10/10/10 | 1000 | 100 | 第一档上边界 |
| 17 | 18/18/18 | 1800 | 220 | 第二档上边界 |
| 25 | 70/80/90 | 7800 | 1420 | 三项限额同时取最大 |
| 26 | 70/81/90 | 输入错误 | 输入错误 | 单项越界 |
第 25 条是整张表里最有价值的一条:三个维度同时取最大值,销售额 7800,佣金1000×0.1 + 800×0.15 + 6000×0.2 = 1420。这条同时验证了乘法、加法、累进三段,一条顶三条。第 22 到 24 条是“略小于最大值”的三个变体(70/80/89、70/79/90、69/80/90),每个都只动一个维度,能定位到底是哪个维度的上限写错了,这种拆法很讲究。
第 5 条5/5/5 → 500 → 50是区间中点,它的价值在于验证第一档的内部一致性——如果commission()里把 0.1 写成 0.15,第 1 条和第 9 条可能都还能凑巧过,但第 5 条一定挂。
比较可惜的是缺了“非法下限”的用例,(0,1,1)、(1,0,1)、(1,1,0) 这三条一条都没有。而代码里的判断是locks >= 1,如果哪天有人把>=写成>,现有用例一条都发现不了。补上这三条,下限才算测住。
4.3 从 TestCase 到 JUnit 4:把混搭的写法理干净
文档里的测试类是这样开头的:
public class SalaryTest extends TestCase { // 继承 TestCase,JUnit 3 的风格 @Before public void setUp() throws Exception { } @After public void tearDown() throws Exception { } @Test // 又用了 JUnit 4 的注解 public void testTotalSalary() { ... } }extends TestCase是 JUnit 3 的写法,那时候靠方法名以test开头来识别测试;@Test是 JUnit 4 的写法,靠注解识别。两者混用不会报错,但会带来两个实际麻烦:一是类名一旦不以test开头、方法名又改过,JUnit 3 的运行器会漏跑;二是setUp/tearDown被@Before/@After各标了一遍,理论上会执行两遍,具体看走的是哪个运行器。
干净的做法是二选一。既然用了注解,就把extends TestCase去掉,import junit.framework.TestCase也删掉,setUp改成@Before标注的普通方法即可。这么一改,IDEA 的测试运行面板会立刻清爽——测试类上方显示Tests passed: 1,而不是含糊的JUnit 3。
4.4 用参数化测试一次跑完 27 条
手写 27 个@Test方法能跑,但重复度太高,改一个期望值得改 27 处。JUnit 4 的@Parameterized就是干这个的:把用例数据抽成二维数组,测试方法只写一遍。
package main; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import java.util.Arrays; import java.util.Collection; import static org.junit.Assert.assertEquals; @RunWith(Parameterized.class) // 换成参数化运行器 public class SalaryCommissionTest { // 每一行就是用例表里的一条:locks, stocks, barrels, 期望销售额, 期望佣金 @Parameterized.Parameters(name = "用例{index}: {0}/{1}/{2} -> {3}, {4}") public static Collection<Object[]> data() { return Arrays.asList(new Object[][]{ {1, 1, 1, 100.0, 10.0}, // 合法下限 {5, 5, 5, 500.0, 50.0}, // 第一档中点 {10, 10, 10, 1000.0, 100.0}, // 第一档上边界 {10, 10, 11, 1025.0, 103.75}, // 略高于第一档边界 {14, 14, 14, 1400.0, 160.0}, // 第二档中点,验证累进而非全额 {18, 18, 18, 1800.0, 220.0}, // 第二档上边界 {18, 18, 19, 1825.0, 225.0}, // 略高于第二档边界 {70, 80, 90, 7800.0, 1420.0}, // 三项限额同时取最大 {70, 80, 89, 7775.0, 1415.0}, // 只动 barrel }); } private final int locks, stocks, barrels; private final double expectedTotal, expectedCommission; // 构造函数的参数顺序和 data() 里每行的顺序一一对应 public SalaryCommissionTest(int locks, int stocks, int barrels, double expectedTotal, double expectedCommission) { this.locks = locks; this.stocks = stocks; this.barrels = barrels; this.expectedTotal = expectedTotal; this.expectedCommission = expectedCommission; } @Test public void shouldComputeTotalAndCommission() { Salary salary = new Salary(locks, stocks, barrels); // 先算销售额,再算佣金——顺序仍然重要,因为 commission() 读的是字段 assertEquals(expectedTotal, salary.totalPrice(), 0.001); assertEquals(expectedCommission, salary.commission(), 0.001); } }@RunWith(Parameterized.class)把默认运行器换成参数化的,@Parameters标注的静态方法返回用例集合,JUnit 会为每一行创建一个测试类实例并跑一遍@Test方法。name属性是 IDEA 里显示的名字模板,{index}是从 0 开始的序号,{0}、{1}依次对应每行数据,跑失败时看名字就知道是哪条挂了,比光看test1、test2强太多。
assertEquals的第三个参数0.001是浮点比较的容差。double做乘法会有精度误差,103.75可能算成103.75000000000001,不传容差的话测试会因为这点误差挂掉。这个容差值不能随便写,业务上金额精确到分,0.001 足够区分“真的算错”和“浮点抖动”。
expectedTotal和expectedCommission是硬编码在用例表里的,看起来和文档里的表格一样,但差别在于:现在这张表是可执行的。谁把单价改了、档位改了、>改成>=了,跑一遍立刻见分晓。
5. 避坑清单:断言全绿、Scanner 卡死与覆盖率虚高
跑通过一次不等于测对了。下面这五条是我照着文档复现时真实踩到的,每条都按现象、原因、解决三步记下来,你复现的时候可以对照排查。
5.1 现象:测试全绿,但业务其实没验证到
现象:mvn test显示Tests run: 1, Failures: 0,绿色对勾。手动把commission()里的0.15改成0.25,再跑一遍,还是绿。
原因:断言写的是salary.equals(1800, 220, salary),被测对象传的是自己。方法内部重新算了一遍,只要 1800 和 220 这两个期望值恰好是合法的销量组合能算出来的,断言就成立。改0.15为0.25时,salary是 (10,20,30),销售额 1800,走的是else if (totalPrice > 1000)分支,(1800-1000)*0.15 + 1000*0.1变成(1800-1000)*0.25 + 1000*0.1 = 300,和 220 不等,理论上应该挂。
解决:我实测时发现它没挂,是因为commission()之前先调了totalPrice(),而equals()内部又调了一次totalPrice(),字段被重新赋值,但逻辑没错。真正让断言失效的是另一个场景——把if (totalPrice > 1800)改成if (totalPrice >= 1800),(10,20,30) 算出来是 1800,本该走第二档,改完走第一档,佣金变成1800*0.1 = 180,和 220 不等,这次挂了。所以断言确实有效,只是覆盖太窄。(10,20,30) 这一条只能验证一个点,别的点改了它发现不了。解决办法是老老实实上参数化,用 4.4 节那套覆盖 9 个以上关键点。
5.2 现象:越界输入让测试抛异常而不是返回 -1
现象:写了new Salary(70, 81, 90),调用totalPrice(),期望返回 -1,实际拿到 -1,但断言写法是assertEquals(-1, salary.totalPrice()),编译器报ambiguous method call。
原因:assertEquals有(long, long)和(double, double)两个重载,-1是 int,salary.totalPrice()是 double,两个重载都能匹配,编译器不知道选哪个。
解决:把期望值写成-1.0,或者显式写assertEquals(-1.0, salary.totalPrice(), 0.001)。后者更稳,因为带容差的签名唯一。另外提醒一句:拿 -1 当错误码的设计在测试里很别扭,断言成功只能说明“它返回了 -1”,说明不了“它为什么返回 -1”。真正靠谱的做法是抛异常,然后用@Test(expected = IllegalArgumentException.class)或assertThrows断言异常类型,这样错误信息也能带出来。
5.3 现象:测试进程卡住,IDEA 一直显示运行中
现象:点了 Run,进度条一直转,控制台没有任何输出,等一分钟也不结束,只能手动 Stop。
原因:测试方法里Scanner in = new Scanner(System.in);没被注释干净,执行到in.nextInt()时阻塞在标准输入上。JUnit 的测试线程在等输入,而测试运行器又没有界面让你输入,只能死等。
解决:把测试方法里所有Scanner相关代码删掉,测试数据一律从构造函数或者参数化数据注入。如果确实需要模拟输入,用System.setIn(new ByteArrayInputStream("10\n20\n30\n".getBytes()))在@Before里替换标准输入,跑完在@After里恢复System.in。还有个隐藏的坑:Scanner用完不 close 会有资源泄漏警告,虽然测试场景不影响功能,但 neat 一点总没坏处。
提示:判断是不是这个原因很简单,看线程栈——卡住时点 IDEA 的 Dump Threads,如果主线程停在
java.io.FileInputStream.readBytes或Scanner.nextInt,那就是在等输入,八九不离十。
5.4 现象:换了调用顺序,佣金突然变成 -1
现象:同样一个Salary对象,先调commission()再调totalPrice(),佣金是 -1;反过来就是对的。
原因:commission()读的是字段totalPrice,这个字段只有totalPrice()方法执行时才会被赋值。没调用过totalPrice()时它是 0,if (totalPrice > 0)不成立,直接走else返回 -1。
解决:短期办法是在测试里强制先调totalPrice(),但这是把被测类的实现细节写进了测试,以后重构就废了。根本办法是改被测类:让commission()自己调totalPrice()拿结果,或者干脆去掉这两个字段,两个方法各自独立计算。我在 6.1 节给了一版改法。这种“字段缓存中间结果”的写法在业务代码里也很常见,图的是避免重复计算,但只要方法有副作用、有调用顺序要求,测试就会变得脆弱,值不值得看场景。
5.5 现象:junit.framework.TestCase标红,找不到符号
现象:import junit.framework.TestCase;报红,extends TestCase也报错,但@Test注解是好的。
原因:项目里只加了 JUnit 5 的依赖,或者junit:junit的 scope 配成了provided导致编译期可见、运行期不可见。org.junit.Test和junit.framework.TestCase分属 JUnit 4 的不同包,缺一个都会标红。
解决:确认pom.xml里有junit:junit:4.13.2且 scope 是test(默认就是),然后 IDEA 里 Maven 面板点一下 Reload。如果是手动加的 jar,检查 Project Structure → Modules → Dependencies 里有没有 junit-4.13.2.jar 和 hamcrest-core-1.3.jar 两个。缺 hamcrest 时编译能过,运行时抛NoClassDefFoundError: org/hamcrest/SelfDescribing,报错信息完全不提 JUnit,新手容易在这里卡很久。
6. 二次开发:把实验报告改成能长期跑的测试骨架
这份文档交作业够用,但想拿来练手或者塞进真实项目,得动三处:被测类的结构、断言的写法、验证的闭环。
6.1 先动被测类:把校验和计算分开
Salary最大的问题是状态耦合和哨兵返回值。改法是把校验抽成独立方法,计算改成纯函数:
public class Salary { private static final int LOCK_PRICE = 45; private static final int MAX_LOCK = 70; private final int locks, stocks, barrels; public Salary(int locks, int stocks, int barrels) { this.locks = locks; this.stocks = stocks; this.barrels = barrels; } // 校验单独暴露,测试可以直接断言异常信息 public void validate() { if (locks < 1 || locks > MAX_LOCK) { throw new IllegalArgumentException("配件A数量越界: " + locks); } // stocks / barrels 同理,各写一条 } public double totalPrice() { validate(); return locks * LOCK_PRICE + stocks * 30 + barrels * 25; } public double commission() { double total = totalPrice(); // 不再依赖字段,随调随算 if (total > 1800) return (total - 1800) * 0.2 + 800 * 0.15 + 1000 * 0.1; if (total > 1000) return (total - 1000) * 0.15 + 1000 * 0.1; return total * 0.1; } }改动点有三个:单价和上限变成常量,改价格只改一处;validate()独立出来,异常信息能说清是哪一项越界;commission()内部调totalPrice(),调用顺序不再影响结果。改完之后,之前那条“先调佣金变 -1”的坑自动消失。
6.2 用异常断言补上越界用例
原文的两条越界用例只能证明“它返回了错误”,改写后可以精确到异常类型和消息:
@Test public void shouldRejectOutOfRangeLock() { Salary salary = new Salary(71, 10, 10); IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, salary::totalPrice); assertTrue(ex.getMessage().contains("配件A数量越界")); }assertThrows是 JUnit 4.13 才有的,4.12 及以下得用@Test(expected = ...),后者拿不到异常对象,断言不了消息。这也是我建议依赖锁在 4.13.2 的原因之一。下限用例同样补三条 (0,1,1)、(1,0,1)、(1,1,0),把>=和>写反的情况堵上。
6.3 验证闭环:覆盖率、变异和 CI
改完跑一遍参数化测试,看 IDEA 的 Run with Coverage,行覆盖率应该能到 90% 以上。但覆盖率高不代表测得对——把所有断言删掉覆盖率还是 100%。想看断言有没有用,可以手动改一处业务代码(比如把 0.15 改成 0.16),跑测试,必须挂。这个动作叫变异测试,工具版有 PIT,手搓版就改一行,一分钟的事,但能立刻暴露“假绿”。
最后把mvn test挂到提交钩子或者流水线上,每次 push 自动跑。这一步不做,前面所有努力都会在三个月后还回去——测试没人跑,等于没写。
我现在的习惯是:拿到任何一份带测试的代码,先改一行业务逻辑,看测试挂不挂。挂了我才信它是测过的,不挂就从头补。这份实验报告我改到第三版才敢说“测试是真的在跑”。希望帮到你。
本文还有配套的精品资源,点击获取