Junit5+Mockito实现已投票事件的测试策略
一.引言1.业务场景在某类活动报名中邀请投票是一个很常见的功能用户根据某个邀请进行投票决定是否参与或选择某个选项然而看似简单操作的背后却隐藏着层层业务约束比如在我的项目中此投票规则只有已报报名的用户才能投票且同一用户不能重复投票一旦处理不当轻则数据错乱重则引发用户投诉。以我们的vote(String id,String voter)方法为例它至少需要应对以下测试验证1.参数校验id(邀请ID)和voter(投票人)可能为null,空字符串或者空白字符串测试的时候是否能够拒绝而不是抛出空指针异常NullPointerException2.存在性校验如果传入id在数据库里不存在服务是否返回明确的业务异常InvitationException3.资格校验投票人必须位于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. 只有已报名的用户才能投票 SetString 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(投票人)明确外部依赖需要MockfindEnrollers(报名列表)findVote(是否已投)。参数校验输入条件有效等价类无效等价类Id值非空且存在的邀请IDNull,空字符串“”空白字符串“ ”不存在的Idvoter值非空且已报名且未投票的用户Null,空字符串“”空白字符串“ ”业务约束依赖id有效的前提下约束条件有效等价类无效等价类邀请是否存在FindInvitationById返回非nullfindInvitationById返回null报名集合findEnrollers返回null且包含voter的集合返回null,返回空集合返回的集合不包含voter是否已投票findVote返回false(未投票)返回true(已投票)2识别等价类 对每个属性划分有效与无效等价类1.有效等价类已报名未投票——抛出InvitationException编号条件有效等价类期望结果1Id合法且邀请存在Id非空且findInvitationById返回非null Invitation——2voter合法且已报名Voter非空且在findEnroller返回的集合中——3未投票过findvote返回false成功调用persistVote(id,voter)2.无效等价类编号条件无效等价类期望结果1Id为nullIdnull抛出InvitationException(400)2id为空字符串Id“”抛出InvitationException(400)3id为空白字符串Id“ ”抛出InvitationException(400)4邀请不存在FindInvitationById返回null抛出InvitationException(404)5voter为nullVoternull抛出InvitationException(400)6voter为空字符串Voter“”抛出InvitationException(400)7voter为空白字符串Voter“ ”抛出InvitationException(400)8报名集合为nullFindEnroller返回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())校验逻辑编号边界值说明1Null下边界空值2“”空字符串长度为03“ ”长度为1的空白字符串刚好时空白的最小长度4“a”长度为1的非空字符串刚好有效的最小长度5正常长度的合法ID如“inv1”合法值的典型代表逻辑null、、 分别代表“空引用”、“空长度”、“纯空白”三种不同的“无效”形态确保参数校验的全面性而a则验证了“只要不是空白哪怕只有 1 个字符也能通过”的有效边界。2.参数voter(投票人)——字符串类型voter的边界逻辑与id完全一致同样围绕isBlank()校验展开编号边界值说明1Null下边界空值2“”空字符串长度为03“ ”长度为1的空白字符串4“u”长度为1的非空字符串刚好有效的最小长度5正常长度的合法用户名如“hangman”合法值的典型代表3.业务约束边界除了直接的参数校验vote()方法还依赖于InvitationRepository的三个方法返回结果。这些依赖的返回值存在多种“边缘状态”需要单独识别编号边界值说明1findInvitationByI2d返回null邀请恰好不存在存在性的边界2FindInvitationById返回有效Invitation邀请刚好存在存在性的有效侧3FindEnrollers返回null报名集合为null4findEnrollers返回Set.of()(空集合size0)报名集合刚好为空无人报名的边界5FindEnrollers返回包含voter的集合size1刚好只有该用户1人报名包含关系的最小边界6FindEnrollers返回不包含voter的集合用户刚好不在报名集合中不包含的边界7findVote返回false(未投票)投票状态的有效边界刚好未投8FindVote返回true(已投票)投票状态的无效边界刚好已投过逻辑业务约束边界的选取完全映射了代码中的 3 个关键判断点 ——invitation null、enrollers null || !enrollers.contains(voter)、findVote(...) true。每个判断点的“真/假”临界值都必须覆盖才能保证分支覆盖率的完整性。4.边界值选取总结输入/条件下边界无效侧临界值IdNull-“”-“ ”“a”(最有效)voterNull-“”-“ ”“u”(最短有效串)邀请存在性Null(不存在)非null Invitation(存在)报名集合Null-空集合size0Size1且含voterVoter是否报名集合不含voter集合含voter(size1)是否已投票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测试运行效果