我们在上个小节中刚刚讲了`规范`,在本节中开始前我们先为idea安装一个规范插件。打开idea,使用`ctrl+,`打开参数设置,然后输入plugins: ![](https://img.kancloud.cn/9e/81/9e8123fceb84b4d121f658f8232803f1_402x532.png) 在右侧的窗口中输入alibaba ![](https://img.kancloud.cn/a0/dd/a0ddbf6984ae510e326332ba396c7486_965x263.png) 点击install安装,完成后重启idea,此时一个代码规范约束的插件便被成功的安装了。以后当我们在书写一不太规范的代码时,该插件则会进行自动提示。 # M层 -- 多态 JAVA的多态性允许我们在StudentService中有了一个findAll方法的同时,再写一个同名findAll方法: ``` /** * 学生 */ public interface StudentService { ... /** * 查询分页信息 * * @param pageable 分页条件 * @return 分页数据 */ Page<Student> findAll(Pageable pageable); /** * 综合查询 * @param name containing 姓名 * @param sno beginWith 学号 * @param klassId equal 班级ID * @param pageable * @return */ Page<Student> findAll(String name, String sno, Long klassId, Pageable pageable); ➊ ``` * ➊ 方法名仍然为findAll,但由于参数不同,所以在其它的对象调用StudentService.findAll时,java是可以通过判断参数的数量、类型来区分我们具体是想调用哪个findAll方法。 > 一个findAll方法,有多种形态,称为面向对象的多态性 ## 实现类 在实现类中添加方法实现: ``` @Service public class StudentServiceImpl implements StudentService { ... @Override public Page<Student> findAll(String name, String sno, Long klassId, Pageable pageable) { Klass klass = new Klass(); ➊ klass.setId(klassId); ➊ return this.studentRepository.findAll(name, sno, klass, pageable); ➋ } } ``` * ➊ 根据klassId构造用Klass * ➋ 调用数据仓库,完成查询并返回 ## 单元测试 找开位于测试文件夹的StudentServiceImplTest并增加测试方法findAllSpecs。该测试的思路和以前的一样,我们只需要测试findAll(String name, String sno, Long klassId, Pageable pageable)是否将数据成功的转发给数据仓库层即可。至于数据仓库层是否成功的执行了查询功能,按分层理论这是数据仓库层需要关心的事情。 ``` import java.util.Arrays; ... @SpringBootTest @RunWith(SpringRunner.class) public class StudentServiceImplTest { private static Logger logger = LoggerFactory.getLogger(StudentServiceImplTest.class); @MockBean StudentRepository studentRepository; ... @Test public void findAllSpecs() { /* 参数初始化 */ String name = "hello"; String sno = "032282"; Long klassId = 1L; Pageable pageable = PageRequest.of(0, 2); List<Student> students = Arrays.asList(); Page<Student> mockStudentPage = new PageImpl<>(students, pageable, 100L) ➊; /* 设置模拟返回值 */ Mockito.when(this.studentRepository .findAll(Mockito.eq(name)➋, Mockito.eq(sno), Mockito.any(Klass.class), Mockito.eq(pageable))) .thenReturn(mockStudentPage) ➌; /* 调用测试方法,获取返回值并断言与预期相同 */ Page<Student> returnStudentPage = this.studentService.findAll(name, sno, klassId, pageable); Assertions.assertThat(returnStudentPage).isEqualTo(mockStudentPage); ➍ } } ``` * ➊ 调用PageImpl<>(当前页内容, 分页信息, 总条数)来构建返回值 * ➋ 当调用findAll的第一个参数与name相等(eq)时 * ➌ 当参数为符合:`参数1等于(eq)name对象(值:hello),参数2等于(eq)sno对象(值:032282) , 参数1为任意(any)的班级,参数4等于(eq)pageable对象(值:第0页每页2条)`规则时,调用findAll方法将模拟返回mockStudentPage对象。 * 预期返回了mockStudentPage对象。 由于预期返回了mockStudentPage对象,则说明在studentService中调用studentRepository.findAll时,传入的参数符合`参数1等于(eq)name对象(值:hello),参数2等于(eq)sno对象(值:032282) , 参数1为任意(any)的班级,参数4等于(eq)pageable对象(值:第0页每页2条)`规则。进而说明调用name, sno, pageable均是我们传入的值。而是否按我们的预期传入了klass还需要使用参数捕获器来获取: ``` public class StudentServiceImplTest { ... @Test public void findAllSpecs() { ... Assertions.assertThat(returnStudentPage).isEqualTo(mockStudentPage); /* 获取M层调用studentRepository的findAll方法时klass的参数值,并进行断言 */ ArgumentCaptor<Klass> klassArgumentCaptor = ArgumentCaptor.forClass(Klass.class); Mockito.verify(this.studentRepository).findAll(Mockito.eq➊(name), Mockito.eq(sno), klassArgumentCaptor.capture()①, Mockito.eq(pageable)); Assertions.assertThat(klassArgumentCaptor.getValue().getId()).isEqualTo(klassId); ② } } ``` * ➊ Mockito.eq与前面作用相同 * ① 把第三个参数替换为klassArgumentCaptor.capture()来获取参数的值 * ② 断言klass中id的值 ### 请思索 在刚刚的测试中,我们将测试代码做以下替换,同样可以顺利通过测试,你知道其中的原因吗? ``` Mockito.verify(this.studentRepository).findAll(Mockito.any(String.class), Mockito.any(String.class), klassArgumentCaptor.capture(), Mockito.any(Pageable.class)); Assertions.assertThat(klassArgumentCaptor.getValue().getId()).isEqualTo(klassId); ``` ## Null值处理 我们在进行方法调用的原则是:如果该参数没有标记为@NotNull,则表示其是可以接收并顺利处理null值的。而如果M层在findAll方法中如果接收的Pageable为null,则会在调用数据仓库层时发生异常,而这是我们不希望看到了。 ``` import javax.validation.constraints.NotNull; ... public interface StudentService { ... Page<Student> findAll(String name, String sno, Long klassId, @NotNull Pageable pageable); } ``` 实现类: ``` import org.springframework.util.Assert; import javax.validation.constraints.NotNull; ... public class StudentServiceImpl implements StudentService { ... @Override public Page<Student> findAll(String name, String sno, Long klassId, @NotNull Pageable pageable) { Assert.notNull(pageable, "Pageable不能为null"); ... } } ``` ### null值测试 ``` public class StudentServiceImplTest { ... @Test(expected = IllegalArgumentException.class) ① public void findAllSpecsNullValidate() { try { this.studentService.findAll(null, null, null, null); } catch (Exception e②) { Assertions.assertThat(e.getMessage()).isEqualTo("Pageable不能为null"); throw e; ③ } } } ``` * ① 该测试应该会抛出 IllegalArgumentException * ② 所有的异常都继承了Exception (java.lang.Exception属于java内置的类,使用时无需import),所以只要有异常抛出,并必然被此catch捕获到 * ③ 将获取到的异常向上抛出,并被 ① 获取 # 重写C层 打开StudentController -> findAll方法,在原代码的基础上加入参数name,sno以及klassId: ``` public class StudentController { ... @GetMapping public Page<Student> findAll( @RequestParam String name, ✚ @RequestParam String sno, ✚ @RequestParam Long klassId, ✚ @RequestParam int page, @RequestParam int size) { return this.studentService.findAll(PageRequest.of(page, size)); } ``` 修改调用方法: ``` public Page<Student> findAll( @RequestParam String name, @RequestParam String sno, @RequestParam Long klassId, @RequestParam int page, @RequestParam int size) { return this.studentService.findAll( name, ✚ sno, ✚ klassId, ✚ PageRequest.of(page, size)); } ``` ## 单元测试 前面我们一直在强调单元测试是对自己代码功能的保障,此时我们刚刚变动了StudentController的代码,那么我们校验下单元测试是否起到了应有的作用。我们打开单元测试文件StudentControllerTest,找到并找行findAll单元测试: 最终得到了以下错误提示: ``` java.lang.AssertionError: Status Expected :200 Actual :400 <Click to see difference> ``` 期待是200,最终返回了400。在控制台中找到错误提示行并进行点击,报错的行数如上: ``` public class StudentControllerTest { ... @Test public void findAll() throws Exception { ... MvcResult mvcResult = this.mockMvc.perform( MockMvcRequestBuilders.get(url) .param("page", "1") .param("size", "2")) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isOk()) ★ .andReturn(); ``` 说明返回的状态码为400,并不是我们期望的200。而400通常代表参数绑定错误,在我们当前的用例中:由于我们在C层的方法中声明需要接收参数name、sno及klassId,而单元测试在测试时,只传入了参数page与size。所以在参数的绑定过程中spring不清楚需要用什么值来绑定name、sno、klassId,进而发生了400错误。 > 在生产环境中,C层的单元测试报400错误是需要引起重视的,因为这意味着我们后台的请求接口的规范发生了变化,而前台如果想适应这个变化就必须同步进行修改。 单元测试报错的原因可以分为两种:第一种是功能的修正导致历史的单元测试代码不能适应新的功能需求;第二种是在增加、修正关联代码时对历史的功能造成了影响。如果情况属性第一种,则应该按新功能修正单元测试代码,如果情况属性第二种,则应该进行近一步的排查。我们此处的单元测试则属于第一种,解决的方法是修正单元测试代码: 在原请求的参数的基础上,我们加入以下三个参数: ``` public class StudentControllerTest { ... @Test public void findAll() throws Exception { ... MvcResult mvcResult = this.mockMvc.perform( MockMvcRequestBuilders.get(url) .param("name", "testName") ✚ .param("sno", "testSno") ✚ .param("klassId", "1") ✚ .param("page", "1") .param("size", "2")) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isOk()) .andReturn(); ``` 加全请求参数后,重新运行该单元测试,400的错误消息,得到了新的错误: ``` java.lang.IllegalArgumentException: json string can not be null or empty at com.jayway.jsonpath.internal.Utils.notEmpty(Utils.java:386) at com.jayway.jsonpath.internal.ParseContextImpl.parse(ParseContextImpl.java:36) at com.jayway.jsonpath.JsonPath.parse(JsonPath.java:599) at com.mengyunzhi.springbootstudy.controller.StudentControllerTest.findAll(StudentControllerTest.java:87) ★ at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ``` ★处对应的代码如下: ``` public class StudentControllerTest { ... @Test public void findAll() throws Exception { ... logger.info("将返回值由string转为json,并断言接收到了分页信息"); LinkedHashMap returnJson = JsonPath.parse(mvcResult.getResponse().getContentAsString()).json(); ★ Assertions.assertThat(returnJson.get("totalPages")).isEqualTo(2); // 总页数 ``` 该错误的出现说明了两点:1. 未发生400错误,说明参数绑定成功,证明刚刚修正的代码正确。2. 将返回值转换为JSON时发生错误,说明返回不再符合预期,按错误提示猜测试返回值为empty或null。 既然对返回数据产生了怀疑,则可以添加断点并启用debug模式来对问题进行调试,在错误的行上打个断点: ![](https://img.kancloud.cn/c5/73/c5731fddffc7e1eb2213239b9e21eb9b_1654x132.png) 使用debug模式运行测试 ![](https://img.kancloud.cn/e3/98/e398fc926ed2502c7e33f76c1258e434_596x207.png) 在控制台中查看返回值mvcResult: ![](https://img.kancloud.cn/ac/71/ac716cac988a037c8944dc6aaee10a84_885x273.png) 结果显示返回了空字符串,而不是我们预期的分页信息了。这是由于C层的代码在改变了调用M层的方法引起的,我们再来查看C层代面在引入综合查询前后的变化 ``` public class StudentController { ... @GetMapping public Page<Student> findAll( @RequestParam String name, ✚ ① @RequestParam String sno, ✚ ① @RequestParam Long klassId, ✚ ① @RequestParam int page, @RequestParam int size) { return this.studentService.findAll(PageRequest.of(page, size)); ✘ ② return this.studentService.findAll( name, sno, klassId, PageRequest.of(page, size)); ✚ ② } ``` * ① 代码变动、引起了400问题 * ② 代码变动,引起了返回空值的问题 在前面的测试代码中,我们测试的逻辑是:C层代码调用的是`this.studentService.findAll(Pageable)`方法。而修改后的逻辑变更为调用`this.studentService.findAll(Strng, String, Long, Pageable)`方法。此时单元测试的报错及时的提醒我们C层调用的逻辑发生了变化。如果逻辑是正确的则应该修正单元测试代码;如果逻辑是错误的,则应该修正单元测试代码。则修正单元测试代码如下: ``` public class StudentControllerTest { ... @Test public void findAll() throws Exception { ... Mockito.when(this.studentService.findAll(Mockito.any(Pageable.class))) .thenReturn(mockOutStudentPage); ✘ Mockito.when(this.studentService .findAll(Mockito.anyString()➊, Mockito.anyString(), Mockito.anyLong()➋, Mockito.any(Pageable.class))) .thenReturn(mockOutStudentPage); ✚ ... ``` * ➊ 请求的参数值类型为String时 * ➋ 请求的参数值类型为Long时 再次运行单元测试,测试通过。 ## 可选参数及空参数 按接口的设计,前台在与后台进行交互时,查询参数name、sno及klassId为optional(可选项),也就是说:我们允许用户在查询时不输入此参数。而刚刚在测试的过程中我们发现:如果不输入name sno或klassId则会得到一个400错误。在spring中,可以为@RequestParam设置`required=false`来解决该问题: ``` public class StudentController { ... @GetMapping public Page<Student> findAll( @RequestParam(required = false➊) String name, @RequestParam(required = false) String sno, @RequestParam(required = false) Long klassId, @RequestParam int page, @RequestParam int size) { return this.studentService.findAll( name, sno, klassId, PageRequest.of(page, size)); } ``` * ➊ 该参数是可选项(非required),当用户未传入该参数时,name的值设置为null。 重新运行单元测试通过,表示我们的此处的修改并未对原功能造成影响。 ### 单元测试 本着单元测试粒度最小的原则,我们StudentControllerTest中新建findAllRequestParam方法: ``` public class StudentControllerTest { ... /** * 请求参数测试 * @throws Exception */ @Test public void findAllRequestParam() throws Exception { String url = "/Student"; logger.info("只传入page size,不报错"); this.mockMvc.perform( MockMvcRequestBuilders.get(url) .param("page", "1") .param("size", "2")) .andExpect(MockMvcResultMatchers.status().isOk()); logger.info("不传page报错"); this.mockMvc.perform( MockMvcRequestBuilders.get(url) .param("size", "2")) .andExpect(MockMvcResultMatchers.status().is(HttpStatus.BAD_REQUEST.value())); logger.info("不传size报错"); this.mockMvc.perform( MockMvcRequestBuilders.get(url) .param("page", "1")) .andExpect(MockMvcResultMatchers.status().is(400)); } ``` # 小测试 在C层的单元测试findAll中我们虽然断言了`必然调用studentService.findAll方法`,但却没有对调用该方法时向个参数的传入值进行断言。也就是说如果C层的代码被不小心写成:`return this.studentService.findAll(sno, name, klassId, PageRequest.of(page, size))`的话,我们也无从察觉。请参数M层的测试补充该部分,以确保C层的转发是正确的。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.5](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.5) | - | | mockito anyString | [https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/ArgumentMatchers.html#anyString--](https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/ArgumentMatchers.html#anyString--) | 2 | | mockito anyLong | [https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/ArgumentMatchers.html#anyLong--](https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/ArgumentMatchers.html#anyLong--) | 2 | | mockito eq | [https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/ArgumentMatchers.html#eq-T-](https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/ArgumentMatchers.html#eq-T-) | 2 |