正式动手写代码前先画一个时序图,来理清调用动象、调用方法名、参数类型以及返回值几个重要的因素。 ![](https://img.kancloud.cn/d9/79/d97990e40f98c6cd6642276c39c78569_544x191.png) 有了时序图在编码时就清晰了很多,这与写报告基本类似:先写目录,再补充内容。 # 初始化 按时序图的反方向我们进行代码初始化 ## M层 接口:service/StudentService.java ``` package com.mengyunzhi.springBootStudy.service; import com.mengyunzhi.springBootStudy.entity.Student; /** * 学生 */ public interface StudentService { /** * 保存 * @param student 保存前的学生 * @return 保存后的学生 */ Student save(Student student); } ``` 实现类:service/StudentServiceImpl.java ``` package com.mengyunzhi.springBootStudy.service; import com.mengyunzhi.springBootStudy.entity.Student; import org.springframework.stereotype.Service; @Service public class StudentServiceImpl implements StudentService { @Override public Student save(Student student) { return null; } } ``` ## C层 在controller包中新建StudentController.java控制器 ``` package com.mengyunzhi.springBootStudy.controller; import com.mengyunzhi.springBootStudy.entity.Student; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * 学生控制器 */ @RestController @RequestMapping("Student") public class StudentController { public Student save() { return null; } } ``` ## 总结 代码在初始化时,我们可以相对随意一些,把一些自己想到的写上即可。不必要求必须有功能,甚至于写错了都没有关系。因为按TDD的开发理论,有了初始化的代码后,我们下一步便是写测试用例,最后依照测试用例来完成功能代码的开发。 # 功能 按TDD的理论,我们分别对C层、M层进行测试开发。 ## C层 TDD = Test-driven development 测试驱动开发。开发步骤大体为:① 初始化 ② 单元测试代码 ③ 功能代码。 ### 单元测试 首先我们使用idea自动生成测试文件,并初始化如下: controller/StudentControllerTest.java ``` package com.mengyunzhi.springBootStudy.controller; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; @SpringBootTest @RunWith(SpringRunner.class) @AutoConfigureMockMvc public class StudentControllerTest { @Autowired private MockMvc mockMvc; @Test public void save() { } } ``` 接下来结合接口规范分步完成C层的单元测试。接口定义如下: ``` POST /Student ``` #### 参数 Parameters | type | name | Description | Schema | | --- | --- | --- | --- | | **Body** | **学生** <br> *requried* | 学生信息 | Student | #### 返回值 Responses | HTTP Code | Description | Schema | | --- | --- | --- | | **201** | Created | 学生信息 | ##### 班级信息 | name | type | description | | --- | --- | --- | | name <br> *requried➊* | string(2-20)➋ | 学生名称 | | sno <br> *requried unique➌* | string(6) | 学号 | | klass <br> *requried* | {id: Long} | 班级 | 无论测试什么方法,测试的思路都离不开**输入**、**计算**与**输出**。C层的测试也同样如此: ### 输入 在C层中,输入分别对应了**请求方法**、**请求地址**与**传入参数**,我们依次对其进行测试。 ``` @Test public void save() throws Exception { String url = "/Student"; ① JSONObject studentJsonObject = new JSONObject(); ② JSONObject klassJsonObject = new JSONObject(); ③ studentJsonObject.put("sno", "学号测试"); ④ studentJsonObject.put("name", "姓名测试"); ④ klassJsonObject.put("id", -1); ⑤ studentJsonObject.put("klass", klassJsonObject); ⑥ MvcResult mvcResult = this.mockMvc.perform( MockMvcRequestBuilders.post(url)⑦ .content(studentJsonObject.toString()) .contentType(MediaType.APPLICATION_JSON_UTF8) ).andExpect(MockMvcResultMatchers.status().is(201)) .andReturn(); } ``` * ① 请求地址 * ② 新建学生json对象,该对象可以使用toString()方法方便的转为json字符串 * ③ 新建班级json对象 * ④ 设置学生实体属性的值 * ⑤ 设置班级ID * ⑥ 将班级json对象关联至学生json对象上 * ⑦ 发起POST请求 下面,我们启动单元测试并结合单元测试的错误提示来修正相应的功能代码。 #### 404 ``` java.lang.AssertionError: Response status Expected :201 Actual :404 ``` 错误404说明使用POST方法请求的Klass路径没有找到,我们来到C层代码,修正如下: ``` @PostMapping ★ public Student save() { return null; } ``` 再测试 #### 200 ``` java.lang.AssertionError: Response status Expected :201 Actual :200 ``` 期望返回201,却返回了200,说明我们忘记定义返回的状态码了。 ``` @PostMapping @ResponseStatus(HttpStatus.CREATED) public Student save() { return null; } ``` 到此,我们完成输入中的请求地址、请求方法以及返回状态码的测试。下面结合**计算**测试来对C层中获取的值是否符合预期进行测试。 ### 数据转发测试 C层的在数据层面的作用为:接收数据、校验数据以及数据转发。在此我们分别对接收数据及数据转发进行测试(C层的校验数据后面添加)。我们无法直接对C层的数据进行测试,在此需要依赖一个Mock的M层来协助测试数据接收与转发是否成功。 #### 功能代码 首次接触这样的测试用了减小学习的难度,我们先把C层中核心的代码完成: controller/StudentController.java ``` @RestController @RequestMapping("Student") public class StudentController { @Autowired StudentService studentService; ① @PostMapping @ResponseStatus(HttpStatus.CREATED) public Student save(Student student②) { return studentService.save(student); ③ } } ``` * ① 自动装配 * ② 设置接收参数及参数的类型 * ③ 调用服务层的相关方法 而我们测试的重点是: * [ ] 在③中调用save方法时传入的student变量,是否与我们前台传入的值相对应 * [ ] 调用③后的返回值是否成功的被前台接收,如果成功接收,那么接收的值是否正确。 下面,我们围绕上述两个测试重点展开测试。 #### Mockito.when 要完成前面的测试任务则需要解决以下两个问题: * 当C层调用studentStervice.save方法时,我们必须能获取该方法中传入的值。 * 我们必须能指定studentStervice.save的返回值。 在Mock中我们如下指定返回值 contorller/StudentControllerTest.java ``` import org.slf4j.Logger; import org.slf4j.LoggerFactory; private static Logger logger = LoggerFactory.getLogger(StudentControllerTest.class); ① ... @MockBean private StudentService studentService; ... @Test public void save() throws Exception { ... logger.info("准备服务层替身被调用后的返回数据"); Student returnStudent = new Student(); ② Mockito.when( ➊ studentService.save( Mockito.any(Student.class➋))) .thenReturn(returnStudent➌); ... } ``` * ① 启用日志 * ② 初始化返回值 * ➊ 当调用studentService.save方法 * ➋ 并且接收的参数的值的类型为Student时 * ➌ 返回returnStudent #### ArgumentCaptor<T> 而获取输入参数的值,则需要借助于ArgumentCaptor<T>,该类需要设置一个泛型,表示:你指定什么类型,我就能获取什么类型的变量值。 contorller/StudentControllerTest.java ``` logger.info("新建参数捕获器"); ArgumentCaptor<Student> studentArgumentCaptor = ArgumentCaptor.forClass(Student.class); ➊ Mockito.verify(studentService).save(studentArgumentCaptor.capture()); ➋ Student passedStudent = studentArgumentCaptor.getValue(); ``` * ➊ 初始化一个可以捕获Student类型变量的捕获器 * ➋ 当调用studentService.save方法时,使用studentArgumentCaptor.capture()来捕获参数的值 * ➌ 获取捕获的值 ### 完整测试代码 最终代码如下: ``` @Test public void save() throws Exception { logger.info("准备输入数据"); String url = "/Student"; JSONObject studentJsonObject = new JSONObject(); JSONObject klassJsonObject = new JSONObject(); studentJsonObject.put("sno", "学号测试"); studentJsonObject.put("name", "姓名测试"); klassJsonObject.put("id", -1); studentJsonObject.put("klass", klassJsonObject); logger.info("准备服务层替身被调用后的返回数据"); Student returnStudent = new Student(); Mockito.when( studentService.save( Mockito.any(Student.class))) .thenReturn(returnStudent); logger.info("发起请求"); MvcResult mvcResult = this.mockMvc.perform( MockMvcRequestBuilders.post(url) .content(studentJsonObject.toString()) .contentType(MediaType.APPLICATION_JSON_UTF8) ).andExpect(MockMvcResultMatchers.status().is(201)) .andReturn(); logger.info("新建参数捕获器"); ArgumentCaptor<Student> studentArgumentCaptor = ArgumentCaptor.forClass(Student.class); Mockito.verify(studentService).save(studentArgumentCaptor.capture()); Student passedStudent = studentArgumentCaptor.getValue(); } ``` #### 输入断言 接下来,我们来使用**断言**确保C层的代码是正确的: ``` ... Mockito.verify(studentService).save(studentArgumentCaptor.capture()); Student passedStudent = studentArgumentCaptor.getValue(); logger.info("断言捕获的对与我们前面传入的值的相同"); Assertions.assertThat(passedStudent.getSno()).isEqualTo("学号测试"); ① Assertions.assertThat(passedStudent.getName()).isEqualTo("姓名测试"); ② Assertions.assertThat(passedStudent.getId()).isNull(); ③ Assertions.assertThat(passedStudent.getKlass().getId()).isEqualTo(-1L); ④ } ``` * ① 断言学号与POST请求值相同 * ② 断言姓名与POST请求值相同 * ③ 断言未接收到ID * ④ 断言班级ID与POST请求值相同 最后我们运行测试,并根据测试来补充C层代码,最终达到测试通过的目的。 ``` org.junit.ComparisonFailure: Expected :"学号测试" Actual :null ``` 单元测试提醒我们,接收到的学号的值为null,我们回到C层来检查此错误产生的原因。通过检查我们发现原来在C层的参数中,我们忘记使用@RequestBody注解了。 controller/StudentController.java ``` public Student save(@RequestBody✚ Student student) { return studentService.save(student); } ``` 加入该注解后我们继续测试: ![](https://img.kancloud.cn/d9/35/d935bcc98d1fa8d7fdbba19c0d75a240_682x250.png) 测试通过说明我们在C层中成功的接收了POST请求的值。 #### 输出断言 为了更好的测试输出,我们需要在输出的对象上定义一些特定的数据: controller/StudentControllerTest.java ``` logger.info("准备服务层替身被调用后的返回数据"); Student returnStudent = new Student(); returnStudent.setId(1L); ✚ returnStudent.setSno("测试返回学号"); ✚ returnStudent.setName("测试返回姓名"); ✚ returnStudent.setKlass(new Klass()); ✚ returnStudent.getKlass().setId(1L); ✚ Mockito.when( studentService.save( Mockito.any(Student.class))) .thenReturn(returnStudent); ``` 然后我们在断言前先在控制台上打印下这个返回值: ``` ).andExpect(MockMvcResultMatchers.status().is(201)) .andDo(MockMvcResultHandlers.print()) ✚ .andReturn(); ``` 启动单元测试我们看看都打印了什么: ![](https://img.kancloud.cn/04/d2/04d25433bfd7d97347d2a79d7df55856_1015x373.png) 其中body字段,即是我们需要的返回值 ``` Body = {"id":1,"name":"测试返回姓名","sno":"测试返回学号","klass":{"id":1,"teacher":null,"name":null}} ``` 用肉眼观察的确是返回了我们规定好的返回值 ,但这并不可靠,下面我们用代码来获取这个返回值,并进行适当的断言。 ``` logger.info("断言捕获的对与我们前面传入的值的相同"); ... logger.info("获取返回的值"); String stringReturn = mvcResult.getResponse().getContentAsString(); ➊ DocumentContext documentContext = JsonPath.parse(stringReturn); ➋ LinkedHashMap studentHashMap = documentContext.json(); ➌ Assertions.assertThat(studentHashMap.get("id")).isEqualTo(1); ①➍ Assertions.assertThat(studentHashMap.get("sno")).isEqualTo("测试返回学号"); ① Assertions.assertThat(studentHashMap.get("name")).isEqualTo("测试返回姓名"); ① LinkedHashMap klassHashMap = (LinkedHashMap)➎ studentHashMap.get("klass"); Assertions.assertThat(klassHashMap.get("id")).isEqualTo(1); ① ``` * ➊ 获取body字段(返回值)的字符串值 * ➋ 转换为DocumentContext文档上下文 ![](https://img.kancloud.cn/f5/7c/f57c4b8eebeca44047454175c55332d4_1148x281.png) * ➌ 以LinkedHashMap(用链表的形式存储键、值对的数据结构) ![](https://img.kancloud.cn/ad/06/ad06cefbbb3e23b570ea3b4d7db1cf45_670x243.png) * ➍ 此注用`1`而不是`1L` * ① 断言返回的值即是我们前面设置过的值 * ➎ 进行强制转换(如果studentHashMap.get("klass")不符合LinkedHashMap,则会报错) > 将字符串转换为对象的方法很多,教程的方法是基于spring自带的JosnPath完成的,这不是最简单的方式也不是最终我们将应用的形式,但做为学习的过渡阶段,还是需要对其进行简单的了解。 单元测试通过: ![](https://img.kancloud.cn/98/e6/98e6aff655af5fde13a6bfb19ae69005_431x139.png) 此时,如果我们在C层中忘记定义返回值,或是返回的值并非调用studentService.save方法而获取的,则会得到异常错误。 ### 对接M层测试 在本例中,M层的功能仅仅是将数据转发给数据仓库层,所以其功能及测试代码均较简单. service/StudentServiceImpl.java ``` @Service public class StudentServiceImpl implements StudentService { @Autowired StudentRepository studentRepository; @Override public Student save(Student student) { this.studentRepository.save(student); return student; } } ``` service/StudentServiceImplTest.java ``` ... @MockBean StudentRepository studentRepository; ① @Autowired StudentService studentService; ② ... @Test public void save() Student passStudent = new Student(); ③ Student mockReturnStudent = new Student(); ③ Mockito.when(studentRepository.save(Mockito.any(Student.class))) .thenReturn(mockReturnStudent); ④ Student returnStudent = this.studentService.save(passStudent); ⑤ ArgumentCaptor<Student> studentArgumentCaptor = ArgumentCaptor.forClass(Student.class); ⑥ Mockito.verify(studentRepository).save(studentArgumentCaptor.capture()); ⑦ Assertions.assertThat(studentArgumentCaptor.getValue()).isEqualTo(passStudent); ⑧ Assertions.assertThat(returnStudent).isEqualTo(mockReturnStudent); ⑨ } ``` * ① MOCK调用方法 * ② 注入测试服务 * ③ 初始化传入值,模拟返回值 * ④ 设置返回值 * ⑤ 调用被测试方法 * ⑥ 定义参数捕获器 * ⑦ 断言调用了studentRepository的save方法,并捕获其调用过程中传入的参数 * ⑧ 断言我们传入studentService值即是studentService传入studentRepository的值 * ⑨ 断言studentRepository返回studentService的值,即是studentService返回给我们的值 # 总结 在整个开发过程中,单元测试伴随其中。在生产环境中也是这样,编写单元测试代码的工作量也会比编写功能代码的工作量要高的多。保守来讲我们测试10行功能代码,大概需要20行测试代码的支持。初步接触单元测试可能会有抵触的心理,这个可能理解,笔者在进行一些自用小项目的开发时,也会时不时抛开单元测试。但如果我们面临的是团队开发、面临的是大项目开发,单元测试便显得非常有必要了。有了单元测试,我们在重构自己的代码时,再也不需要畏首畏尾了;有了单元测试,我们再也不怕小白加入团队与我们共同开发了;有了单元测试,我们补西樯的时候,再也不怕会不小心拆到东樯了;有了单元测试,我们在BUG修正的时候,再也不用遇到修好1个修坏10个的情况了。 最后,让我们找到Test文件夹并点击右键,然后选择Run 'All Tests'来运行整个项目的所有单元测试,以确认我们刚刚的开发未对历史的功能造成影响。 ![](https://img.kancloud.cn/41/47/4147d5c1e21de71d751cf5681569ad09_482x315.png) 测试结果: ![](https://img.kancloud.cn/04/ef/04ef0be6552838348aff95cf21cbb401_894x318.png) 结果显示共运行了14个单元测试,但失败了1个,失败的为StudentcontrollerTest.save方法,我们左侧列表中的方法并查看报错内容及报错的位置: ``` java.lang.AssertionError: Expected :0 Actual :1 <Click to see difference> ... at com.mengyunzhi.springBootStudy.controller.KlassControllerTest.save(KlassControllerTest.java:93) ... ``` 出错的原因是由于我们在测试3.6.2小节的时候,将KlassService由原来真实的服务变更为MockBean引起的。由于在调用模拟的KlassService的save方法时,并没有执行真正的数据新增操作(这是正确的),所以当我们使用this.klassRepository进行findAll查找时仍然还是找到0条记录。g下面,我们按照正确的思路,结合MockBean来修正原来的save测试。 controller/KlassControllerTest.java ``` @Test public void save() throws Exception { ... this.mockMvc.perform(postRequest) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().is(201)); ArgumentCaptor<Klass> klassArgumentCaptor = ArgumentCaptor.forClass(Klass.class); Mockito.verify(klassService).save(klassArgumentCaptor.capture()); Klass passKlass = klassArgumentCaptor.getValue(); Assertions.assertThat(passKlass.getName()).isEqualTo("测试单元测试班级"); Assertions.assertThat(passKlass.getTeacher().getId()).isEqualTo(teacher.getId()); } ``` 修正该方法后,单元测试全部通过,我们便可以认为当前的变更未对任何历史代码产生影响 ,所以可以放心的提交代码了。 > 在团队开发中,如果你不想其它成员不小心修改了你的代码或是影响了你负责代码的功能,那么请使用严谨的单元测试吧。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.9](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.9) | \- | | Mockito | | | | [https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html](https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html) | \- | |