Junit单元测试实战:佣金计算器边界值分析与测试模板
简介:这份实验报告面向软件测试初学者与高校学生,围绕Junit单元测试的安装配置与实例应用展开,帮助读者从零理解单元测试框架在实际项目中的落地方式。文档以经典的「佣兵问题」为案例,完整呈现了枪机、枪托、枪管销售佣金计算的Java实现,并给出边界值测试方法的分析过程与多组测试用例,涵盖销售额与佣金的分段计算逻辑。资源包共1个docx文件,约1.09MB,内容包含问题描述、完整代码、测试用例设计与测试结果,结构紧凑、便于直接参考。目前已有4895人学习下载,适合正在学习软件测试课程或需要完成Junit实验报告的学生对照练习,也可作为理解边界值分析与断言校验的入门范例。
1. 从一份实验报告说起:Junit 单元测试到底怎么落地
很多人第一次接触 Junit 单元测试,是在一份实验报告里看到一个佣金计算器:枪机 45 美元、枪托 30 美元、枪管 25 美元,按销售额分段抽佣,然后要求你写测试用例、跑通断言。听起来像个课堂作业,但它其实是一个相当典型的“业务规则密集、边界条件多、手工验证容易翻车”的场景。这份《Junit单元测试的实验报告.docx》把需求、被测代码、边界值用例表、IDEA 里 Junit 的搭建步骤、测试代码和运行结果串成了一条完整链路,适合正在学软件测试、需要交实验报告,或者想把 Junit 真正用进日常开发的人。它不教你写 Hello World,而是直接给你一个能跑、能改、能扩的测试样板。
2. 被测系统拆解:佣金计算器的业务规则与代码结构
2.1 需求背后的三个硬约束
这个佣金程序看着简单,实际上藏了三层约束,任何一层没处理好,测试就会变成玄学。第一层是数量限额:枪机 1 到 70、枪托 1 到 80、枪管 1 到 90,超出范围直接判输入错误。第二层是完整性要求:销售商每月至少要售出一支完整步枪,也就是说三类零件不能全为零,代码里用locks>=1 && stocks>=1 && barrels>=1来卡这个条件。第三层是分段计佣:1000 美元以内(含)抽 10%,1000 到 1800 美元(含)的部分抽 15%,超过 1800 美元的部分抽 20%。
这三层约束叠在一起,意味着测试不能只测“正常算对”,还要测“越界拒绝”和“边界值精确命中”。实验报告里那张 27 条用例的表,本质上就是在给这三层约束做交叉覆盖。我一般会先把业务规则翻译成判定条件,再决定用边界值还是等价类,而不是上来就随手写几个数字。
2.2 Salary 类的字段与构造逻辑
被测类叫Salary,放在main包下,字段全是私有,价格写死在字段初始化里:
public class Salary { private int lockPrice = 45; // 枪机单价 private int stockPrice = 30; // 枪托单价 private int barrelPrice = 25; // 枪管单价 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; } }构造器只做赋值,不做校验,校验被推迟到totalPrice()里。这种写法在实验场景里够用,但放到真实项目里要小心:对象一旦构造出来就处于“可能非法”的状态,调用方如果先调commission()再调totalPrice(),拿到的就是默认值 0 算出来的佣金。参数名clocks和字段locks差一个字母,也是这份代码里一个容易看漏的小坑,读的时候别把枪机数量看成了时钟。
2.3 totalPrice 与 commission 的联动关系
totalPrice()先校验数量范围,合法才累加销售额,非法直接返回 -1;commission()则依赖totalPrice字段是否大于 0 来判断能不能算佣金。两者是隐式耦合的:commission()不会自己重新算销售额,它读的是上一次totalPrice()留下的结果。
public double totalPrice() { 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; } } public double commission() { if (totalPrice > 0) { if (totalPrice > 1800) { commission = (totalPrice - 1800) * 0.2 + 800 * 0.15 + 1000 * 0.1; } else if (totalPrice > 1000) { commission = (totalPrice - 1000) * 0.15 + 1000 * 0.1; } else { commission = totalPrice * 0.1; } return commission; } else { return -1; } }分段公式里800 * 0.15和1000 * 0.1是固定档位金额,不是变量,改税率的时候这两处要同步改,否则算出来的佣金会对不上。这也是后面写测试时最值得盯的地方:边界点 1000 和 1800 附近,公式切换是否平滑,直接决定用例能不能抓到 bug。
3. 测试工程搭建:在 IDEA 里把 Junit 跑起来
3.1 插件与依赖的两步准备
实验报告里给的搭建路径是先在 Settings 的 Plugins 里搜 Junit 安装,再去 Project Structure 里给模块添加对应版本的 Junit 库。这套流程在旧版 IDEA 里很常见,现在更省事的做法是直接建 Maven 工程,在pom.xml里引依赖,版本由构建工具管,换机器也不用重新配库。
<dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>test</scope> </dependency>scope设成test表示这个依赖只在测试编译和运行期可见,不会打进最终产物。如果坚持用 IDEA 自带方式添加库,记得选和 JDK 匹配的版本,Junit 4 需要 Hamcrest 核心包配合,缺了会在assertThat上编译报错。
3.2 用 Ctrl+Shift+T 生成测试骨架
在Salary.java编辑区按 Ctrl+Shift+T,IDEA 会弹出创建测试的对话框,勾选要生成的方法,选好 Junit 4,确认后就在test目录下生成对应包路径的测试类。这一步的价值不只是省手写,而是保证测试类和被测类包名一致,后面跑单测时类路径不会错位。
生成的骨架里通常带@Before和@After,分别对应每个测试方法执行前后的准备与清理。这个佣金例子没有外部资源要释放,两个方法留空即可,但保留它们是个好习惯,等以后被测类开始读文件、连数据库,直接往里填就行。
3.3 测试类的继承与注解混用问题
实验报告里的测试类写成了public class SalaryTest extends TestCase,同时又用了@Test、@Before、@After这些 Junit 4 注解。这是 Junit 3 和 Junit 4 两种风格的混搭:extends TestCase是 Junit 3 的路子,靠方法名test开头来识别测试;Junit 4 则靠注解识别。混着写一般能跑,但容易出现“方法没被识别成测试”的情况,尤其是方法名不以 test 开头又只靠继承的时候。
我一般会二选一:要么纯 Junit 4,不继承 TestCase,只用注解;要么纯 Junit 3。混用带来的排查成本,远大于省下的那点改动量。
4. 核心测试代码逐段讲:断言、边界值与用例设计
4.1 equals 辅助方法为什么长得这么怪
被测类里有个equals(double etotalPrice, double ecommission, Object object),它不是重写Object.equals,而是一个三参数的自定义比较方法:传入期望销售额、期望佣金和一个对象,内部把对象强转成Salary后重新算一遍,再和期望值比。
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; }这个方法有两个隐患。一是它和Object.equals(Object)签名不同,属于重载而非重写,集合类里不会用到它;二是内部又调了一次totalPrice()和commission(),等于把计算逻辑重复执行,如果被测方法有副作用(比如打印、写库),测试里就会多出一次。实验报告里用它来做断言,本质上是把“期望值比较”塞进了被测类,测试代码反而变薄了。真实项目里更推荐把期望值写在测试方法里,用assertEquals直接比,别让被测类承担断言的职责。
4.2 一条测试方法的完整写法
报告里的测试方法把输入写死成 10、20、30,然后断言销售额 1800、佣金 220:
@Test public void testTotalSalary() { int locks = 10; int stocks = 20; int barrels = 30; Salary salary = new Salary(locks, stocks, barrels); Assert.assertTrue(salary.equals(1800, 220, salary)); }10×45 + 20×30 + 30×25 = 450 + 600 + 750 = 1800,正好落在第二档边界上;佣金 = 1000×0.1 + 800×0.15 = 100 + 120 = 220。这条用例选得很准,它同时命中了“销售额等于 1800”和“分段公式切换点”两个边界。参数说明一下:locks是枪机数,上限 70;stocks是枪托数,上限 80;barrels是枪管数,上限 90;三个都必须是正整数,否则totalPrice()返回 -1。
不过assertTrue(salary.equals(...))的失败信息很弱,只告诉你“期望 true 实际 false”,不告诉你差在哪。改成下面这样,失败时能直接看到实际值:
@Test public void testBoundary1800() { Salary salary = new Salary(10, 20, 30); assertEquals(1800.0, salary.totalPrice(), 0.001); assertEquals(220.0, salary.commission(), 0.001); }第三个参数0.001是浮点比较的容差,double 运算有精度误差,直接==比较容易在末位翻车,这是写金额相关断言时的血泪经验。
4.3 边界值用例表怎么读、怎么补
报告里那张 27 条用例的表,按销售额分了几个簇:最小值附近(100、125、130、145)、500 中点、1000 边界上下、1400 中点、1800 边界上下、4800 中点、7800 最大值附近,外加越界输入。这个分簇思路是对的,边界值测试的核心就是盯住“刚好等于、刚好小于、刚好大于”三类点。
但表里有个地方值得注意:用例 26 和 27 是越界输入,期望结果是“输入错误”,可totalPrice()返回的是 -1,而commission()在totalPrice不大于 0 时也返回 -1。测试越界场景时,断言要针对 -1 写,而不是针对某个金额写,否则用例本身就会失败。补用例时我一般会再加一条“三类零件全为 0”的,验证完整性约束是否真的拦住。
4.4 用参数化测试压缩用例数量
报告结尾自己也提到,边界值用例太多,应该找等价用例减少数量。Junit 4 的参数化测试正好干这件事:把输入和期望值抽成数据表,一个方法跑完所有组合。
@RunWith(Parameterized.class) public class SalaryParamTest { private int locks, stocks, barrels; private double expectedTotal, expectedCommission; public SalaryParamTest(int l, int s, int b, double t, double c) { this.locks = l; this.stocks = s; this.barrels = b; this.expectedTotal = t; this.expectedCommission = c; } @Parameterized.Parameters public static Collection<Object[]> data() { return Arrays.asList(new Object[][]{ {1, 1, 1, 100, 10}, // 最小值 {10, 10, 10, 1000, 100}, // 第一档边界 {10, 20, 30, 1800, 220}, // 第二档边界 {70, 80, 90, 7800, 1420} // 最大值 }); } @Test public void testCommission() { Salary salary = new Salary(locks, stocks, barrels); assertEquals(expectedTotal, salary.totalPrice(), 0.001); assertEquals(expectedCommission, salary.commission(), 0.001); } }@RunWith(Parameterized.class)告诉 Junit 用参数化运行器;data()返回的每个Object[]是一组参数,顺序要和构造器一致;@Parameterized.Parameters标注数据源方法。这样四条数据一次跑完,报告里 27 条用例可以压缩成十来组有代表性的数据,维护成本低很多。
5. 避坑与排查:跑 Junit 时最容易翻车的几件事
5.1 测试方法没被执行,绿条一片但什么都没测
现象是点运行后全部通过,但断点根本没进测试方法。原因通常是测试类没被识别:要么方法名不以 test 开头又没加@Test,要么测试类放在了src/main而不是src/test下。解决方式是检查目录结构,确认测试类在 test 源根里,方法上补@Test,再重新跑一次看执行计数是否对得上。
5.2 浮点断言偶发失败
现象是同一条用例今天过明天不过,或者换个 JDK 就红。原因是 double 直接==比较,1800.0000000001 和 1800.0 被判不等。解决方式是所有金额断言都带容差,用assertEquals(expected, actual, 0.001),别用assertTrue(a == b)。
5.3 越界用例断言写错方向
现象是越界输入那条用例一直红。原因是totalPrice()对非法输入返回 -1,而用例里期望的是某个金额。解决方式是把越界用例的期望值明确写成 -1,并且单独断言commission()也返回 -1,别和正常金额用例混在一个断言里。
5.4 构造器参数名和字段名看混
现象是测试数据填错,算出来的销售额对不上。原因是构造器参数写的是clocks,字段是locks,读代码时容易把枪机数量当成时钟。解决方式是测试里用带注释的字面量,或者干脆把参数名改成和字段一致,减少阅读负担。
5.5 依赖版本和 JDK 不匹配
现象是编译报NoClassDefFoundError或 Hamcrest 相关类找不到。原因是 Junit 4 需要 Hamcrest 核心包,手动加库时只加了 junit 没加 hamcrest。解决方式是改用 Maven 引依赖,让构建工具把传递依赖带进来,或者手动把 hamcrest-core 一起加到模块库中。
6. 二次开发与扩展:把实验代码改成能复用的测试模板
这份实验报告的价值不在那 27 条用例本身,而在它给了一个可复制的骨架:被测类、边界值分析、测试类、断言、运行结果。真要拿去做二次开发,我建议先做三件事。第一,把Salary里的校验从totalPrice()抽出来,做成独立的validate()方法,让构造完就能知道对象是否合法,测试也能单独测校验逻辑,不用每次都绕到金额计算。第二,把分段佣金公式里的档位金额和税率提成常量,测试里用常量做期望值,改税率时只改一处。第三,把equals那个三参数方法删掉,断言全部回到测试类里,用assertEquals加容差,失败信息才看得懂。
扩展方向上,可以给这个模板加两层。一层是参数化测试,把边界值表直接喂给@Parameterized,用例数量降下来但覆盖不降;另一层是异常路径测试,用@Test(expected = IllegalArgumentException.class)或者assertThrows验证非法输入是否按预期抛错,而不是靠返回 -1 这种隐式约定。如果后面要接 CI,把测试类放进src/test/java,用mvn test跑,失败时看 surefire 报告里的实际值和期望值,比在 IDEA 里点绿条靠谱。
| 改造点 | 改前 | 改后 | 收益 |
|---|---|---|---|
| 校验位置 | 藏在 totalPrice 里 | 独立 validate 方法 | 可单独测试,对象状态明确 |
| 断言方式 | 被测类内 equals | 测试类 assertEquals 带容差 | 失败信息可读 |
| 用例组织 | 27 条平铺 | 参数化数据表 | 维护成本低 |
| 非法输入 | 返回 -1 | 抛异常或返回明确错误码 | 调用方不易漏判 |
有个细节我踩过:把校验抽出去之后,commission()里totalPrice > 0的判断要跟着改,否则合法但销售额恰好为 0 的场景会被误判。改完一定把边界用例重跑一遍,尤其是 1000 和 1800 两个切换点。从那以后我每次动计费逻辑,都强制先把边界值用例跑一遍再提交,希望帮到你。
本文还有配套的精品资源,点击获取