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)测试运行效果

更多推荐





所有评论(0)