Junit5+Mockito实现已投票事件的测试策略
一.引言
1.业务场景
在某类活动报名中,邀请投票是一个很常见的功能,用户根据某个邀请进行投票,决定是否参与或选择某个选项,然而看似简单操作的背后却隐藏着层层业务约束,比如在我的项目中此投票规则只有已报报名的用户才能投票,且同一用户不能重复投票,一旦处理不当,轻则数据错乱,重则引发用户投诉。
以我们的vote(String id,String voter)方法为例,它至少需要应对以下测试验证:
1.参数校验:id(邀请ID)和voter(投票人)可能为null,空字符串或者空白字符串,测试的时候是否能够拒绝而不是抛出空指针异常(NullPointerException)
2.存在性校验:如果传入id在数据库里不存在,服务是否返回明确的业务异常(InvitationException)
3.资格校验:投票人必须位于findEnrollers(id)返回的报名表列表中,如果该方法返回null或者空集合,我们的逻辑是否能够处理。
4.幂等性校验: 如果findVote(id,voter)已返回true(表示已投票),业务必须拦截,防止重复计票
方案:本文将利用Junit5的参数化测试(@ParameterizedTest)和Mockito的Mock隔离,结合等价类划分——边界值设计高覆盖的测试用例
二.待测试代码展示
public void vote(String id, String voter) { // 1. 参数校验 if (id == null || id.isBlank()) { throw new InvitationException(400, "邀请ID不能为空"); } if (voter == null || voter.isBlank()) { throw new InvitationException(400, "投票人不能为空"); } // 2. 邀请必须存在 Invitation invitation = repository.findInvitationById(id); if (invitation == null) { throw new InvitationException(404, "邀请不存在"); } // 3. 只有已报名的用户才能投票 Set<String> enrollers = repository.findEnrollers(id); if (enrollers == null || !enrollers.contains(voter)) { throw new InvitationException(403, "该用户未报名此邀请,无法投票"); } // 4. 不能重复投票 if (repository.findVote(id, voter)) { throw new InvitationException(403, "已投票过"); } // 5. 持久化投票记录 repository.persistVote(id, voter); }三.测试设计
(1)确定输入域。分析被测方法投票功能参数的所有属性及其约束条件
明确参数:id(邀请ID) ,voter(投票人)
明确外部依赖(需要Mock):findEnrollers(报名列表),findVote(是否已投)。
参数校验
输入条件 | 有效等价类 | 无效等价类 |
Id值 | 非空且存在的邀请ID | Null,空字符串“”,空白字符串“ ”,不存在的Id |
voter值 | 非空且已报名且未投票的用户 | Null,空字符串“”,空白字符串“ ” |
业务约束(依赖id有效的前提下)
约束条件 | 有效等价类 | 无效等价类 |
邀请是否存在 | FindInvitationById返回非null | findInvitationById返回null |
报名集合findEnrollers | 返回null且包含voter的集合 | 返回null,返回空集合,返回的集合不包含voter |
是否已投票findVote | 返回false(未投票) | 返回true(已投票) |
(2)识别等价类 (对每个属性划分有效与无效等价类)
1.有效等价类(已报名+未投票)——>抛出InvitationException
编号 | 条件 | 有效等价类 | 期望结果 |
1 | Id合法且邀请存在 | Id非空且findInvitationById返回非null Invitation | —— |
2 | voter合法且已报名 | Voter非空且在findEnroller返回的集合中 | —— |
3 | 未投票过 | findvote返回false | 成功调用persistVote(id,voter) |
2.无效等价类
编号 | 条件 | 无效等价类 | 期望结果 |
1 | Id为null | Id=null | 抛出InvitationException(400) |
2 | id为空字符串 | Id=“” | 抛出InvitationException(400) |
3 | id为空白字符串 | Id=“ ” | 抛出InvitationException(400) |
4 | 邀请不存在 | FindInvitationById返回null | 抛出InvitationException(404) |
5 | voter为null | Voter=null | 抛出InvitationException(400) |
6 | voter为空字符串 | Voter=“” | 抛出InvitationException(400) |
7 | voter为空白字符串 | Voter=“ ” | 抛出InvitationException(400) |
8 | 报名集合为null | FindEnroller返回null | 抛出InvitationException(403) |
9 | 报名集合为空 | FindEnrollers返回空集合 | 抛出InvitationException(403) |
10 | 用户为报名 | FindEnrollers不包含voter | 抛出InvitationException(403) |
11 | 重复投票 | FindVote返回true | 抛出InvitationException(403) |
(3)边界值确认
在等价类划分的基础上,我们进一步识别出需要重点测试的边界条件。边界值分析的核心思想是:程序最容易在“临界点”出错——比如空值与长度为1之间、null与空集合之间、true与false之间。因此,我们针对每个输入参数和业务依赖,选取其“刚好越过边界”的关键值进行验证。
1.参数id(邀请ID)——字符串类型
对于字符串参数,边界主要体现在“是否为 null”、“是否为空串”、“是否为空白串”以及“最短有效值”这几个临界点。这些值直接对应代码中的if (id == null || id.isBlank())校验逻辑:
编号 | 边界值 | 说明 |
1 | Null | 下边界:空值 |
2 | “” | 空字符串(长度为0) |
3 | “ ” | 长度为1的空白字符串(刚好时空白的最小长度) |
4 | “a” | 长度为1的非空字符串(刚好有效的最小长度) |
5 | 正常长度的合法ID(如“inv1”) | 合法值的典型代表 |
逻辑:null、""、" "分别代表“空引用”、“空长度”、“纯空白”三种不同的“无效”形态,确保参数校验的全面性;而"a"则验证了“只要不是空白,哪怕只有 1 个字符也能通过”的有效边界。
2.参数voter(投票人)——字符串类型
voter的边界逻辑与id完全一致,同样围绕isBlank()校验展开:
编号 | 边界值 | 说明 |
1 | Null | 下边界:空值 |
2 | “” | 空字符串(长度为0) |
3 | “ ” | 长度为1的空白字符串 |
4 | “u” | 长度为1的非空字符串(刚好有效的最小长度) |
5 | 正常长度的合法用户名(如“hangman”) | 合法值的典型代表 |
3.业务约束边界
除了直接的参数校验,vote()方法还依赖于InvitationRepository的三个方法返回结果。这些依赖的返回值存在多种“边缘状态”,需要单独识别:
编号 | 边界值 | 说明 |
1 | findInvitationByI2d返回null | 邀请恰好不存在(存在性的边界) |
2 | FindInvitationById返回有效Invitation | 邀请刚好存在(存在性的有效侧) |
3 | FindEnrollers返回null | 报名集合为null |
4 | findEnrollers返回Set.of()(空集合,size=0) | 报名集合刚好为空(无人报名的边界) |
5 | FindEnrollers返回包含voter的集合(size=1) | 刚好只有该用户1人报名(包含关系的最小边界) |
6 | FindEnrollers返回不包含voter的集合 | 用户刚好不在报名集合中(不包含的边界) |
7 | findVote返回false(未投票) | 投票状态的有效边界(刚好未投) |
8 | FindVote返回true(已投票) | 投票状态的无效边界(刚好已投过) |
逻辑:业务约束边界的选取完全映射了代码中的 3 个关键判断点 ——invitation == null、enrollers == null || !enrollers.contains(voter)、findVote(...) == true。每个判断点的“真/假”临界值都必须覆盖,才能保证分支覆盖率的完整性。
4.边界值选取总结
输入/条件 | 下边界(无效侧) | 临界值 |
Id | Null->“”->“ ” | “a”(最有效) |
voter | Null->“”->“ ” | “u”(最短有效串) |
邀请存在性 | Null(不存在) | 非null Invitation(存在) |
报名集合 | Null->空集合(size=0) | Size=1且含voter |
Voter是否报名 | 集合不含voter | 集合含voter(size==1) |
是否已投票 | True(已投票,无效) | False(未投票,有效) |
(4)测试代码实现
1.测试环境初始化(@BeforeEach)
我们需要先构建被测服务的框架。因为InvitationService依赖了InvitationRepository,AttachmentRepository,MemInvitationRepository等数据层组件,我们利用Mockito的mock()方法生成代理对象,并通过构造函数注入到Service中。
2.无效等价类
在voteInvalidData()数据源中,定义了11钟无效场景
然后再想怎样才能让这11个用例在不同的异常下触发,运用了switch(description)动态路由
voteInvalidData()中的第一个参数(如"邀请ID为null")不仅仅是一个展示名称。在testVoteInvalid方法中,我们利用switch(description)将其转化为 Mock 行为的动态路由器。
当 JUnit 5 遍历数据源时,每一条Arguments都会触发一次testVoteInvalid的执行。进入方法体后,switch根据当前的description值,精准地为mockRepository配置对应的when...thenReturn行为——例如遇到"重复投票",就让findVote返回true;遇到"findEnrollers返回null",就让findEnrollers返回null。
这种设计的最大好处是:将“变化的部分”(不同场景下的 Mock 设置)与“不变的部分”(统一的异常断言)彻底解耦。未来如果产品经理新增一个“活动已结束不能投票”的约束,我们只需在数据源中追加一行Arguments.of("活动已结束", "inv-end", "userX"),并在switch中新增一个case即可——完全不需要修改已有的测试逻辑,符合开闭原则(OCP)。
3.有效等价类
在voteValidData()数据源中只列了一条数据,但它验证了完整的 Happy Path,确保业务逻辑顺利走完并调用了最终的保存方法。
核心是验证repository的persistVote被调用了一次,且参数正确
(5)测试运行效果
