正式動手寫代碼前先畫一個時序圖,來理清調用動象、調用方法名、參數類型以及返回值幾個重要的因素。

有了時序圖在編碼時就清晰了很多,這與寫報告基本類似:先寫目錄,再補充內容。
# 初始化
按時序圖的反方向我們進行代碼初始化
## 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);
}
```
加入該注解后我們繼續測試:

測試通過說明我們在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();
```
啟動單元測試我們看看都打印了什么:

其中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文檔上下文

* ? 以LinkedHashMap(用鏈表的形式存儲鍵、值對的數據結構)

* ? 此注用`1`而不是`1L`
* ① 斷言返回的值即是我們前面設置過的值
* ? 進行強制轉換(如果studentHashMap.get("klass")不符合LinkedHashMap,則會報錯)
> 將字符串轉換為對象的方法很多,教程的方法是基于spring自帶的JosnPath完成的,這不是最簡單的方式也不是最終我們將應用的形式,但做為學習的過渡階段,還是需要對其進行簡單的了解。
單元測試通過:

此時,如果我們在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'來運行整個項目的所有單元測試,以確認我們剛剛的開發未對歷史的功能造成影響。

測試結果:

結果顯示共運行了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) | \- | |
- 序言
- 第一章:Hello World
- 第一節:Angular準備工作
- 1 Node.js
- 2 npm
- 3 WebStorm
- 第二節:Hello Angular
- 第三節:Spring Boot準備工作
- 1 JDK
- 2 MAVEN
- 3 IDEA
- 第四節:Hello Spring Boot
- 1 Spring Initializr
- 2 Hello Spring Boot!
- 3 maven國內源配置
- 4 package與import
- 第五節:Hello Spring Boot + Angular
- 1 依賴注入【前】
- 2 HttpClient獲取數據【前】
- 3 數據綁定【前】
- 4 回調函數【選學】
- 第二章 教師管理
- 第一節 數據庫初始化
- 第二節 CRUD之R查數據
- 1 原型初始化【前】
- 2 連接數據庫【后】
- 3 使用JDBC讀取數據【后】
- 4 前后臺對接
- 5 ng-if【前】
- 6 日期管道【前】
- 第三節 CRUD之C增數據
- 1 新建組件并映射路由【前】
- 2 模板驅動表單【前】
- 3 httpClient post請求【前】
- 4 保存數據【后】
- 5 組件間調用【前】
- 第四節 CRUD之U改數據
- 1 路由參數【前】
- 2 請求映射【后】
- 3 前后臺對接【前】
- 4 更新數據【前】
- 5 更新某個教師【后】
- 6 路由器鏈接【前】
- 7 觀察者模式【前】
- 第五節 CRUD之D刪數據
- 1 綁定到用戶輸入事件【前】
- 2 刪除某個教師【后】
- 第六節 代碼重構
- 1 文件夾化【前】
- 2 優化交互體驗【前】
- 3 相對與絕對地址【前】
- 第三章 班級管理
- 第一節 JPA初始化數據表
- 第二節 班級列表
- 1 新建模塊【前】
- 2 初識單元測試【前】
- 3 初始化原型【前】
- 4 面向對象【前】
- 5 測試HTTP請求【前】
- 6 測試INPUT【前】
- 7 測試BUTTON【前】
- 8 @RequestParam【后】
- 9 Repository【后】
- 10 前后臺對接【前】
- 第三節 新增班級
- 1 初始化【前】
- 2 響應式表單【前】
- 3 測試POST請求【前】
- 4 JPA插入數據【后】
- 5 單元測試【后】
- 6 惰性加載【前】
- 7 對接【前】
- 第四節 編輯班級
- 1 FormGroup【前】
- 2 x、[x]、{{x}}與(x)【前】
- 3 模擬路由服務【前】
- 4 測試間諜spy【前】
- 5 使用JPA更新數據【后】
- 6 分層開發【后】
- 7 前后臺對接
- 8 深入imports【前】
- 9 深入exports【前】
- 第五節 選擇教師組件
- 1 初始化【前】
- 2 動態數據綁定【前】
- 3 初識泛型
- 4 @Output()【前】
- 5 @Input()【前】
- 6 再識單元測試【前】
- 7 其它問題
- 第六節 刪除班級
- 1 TDD【前】
- 2 TDD【后】
- 3 前后臺對接
- 第四章 學生管理
- 第一節 引入Bootstrap【前】
- 第二節 NAV導航組件【前】
- 1 初始化
- 2 Bootstrap格式化
- 3 RouterLinkActive
- 第三節 footer組件【前】
- 第四節 歡迎界面【前】
- 第五節 新增學生
- 1 初始化【前】
- 2 選擇班級組件【前】
- 3 復用選擇組件【前】
- 4 完善功能【前】
- 5 MVC【前】
- 6 非NULL校驗【后】
- 7 唯一性校驗【后】
- 8 @PrePersist【后】
- 9 CM層開發【后】
- 10 集成測試
- 第六節 學生列表
- 1 分頁【后】
- 2 HashMap與LinkedHashMap
- 3 初識綜合查詢【后】
- 4 綜合查詢進階【后】
- 5 小試綜合查詢【后】
- 6 初始化【前】
- 7 M層【前】
- 8 單元測試與分頁【前】
- 9 單選與多選【前】
- 10 集成測試
- 第七節 編輯學生
- 1 初始化【前】
- 2 嵌套組件測試【前】
- 3 功能開發【前】
- 4 JsonPath【后】
- 5 spyOn【后】
- 6 集成測試
- 7 @Input 異步傳值【前】
- 8 值傳遞與引入傳遞
- 9 @PreUpdate【后】
- 10 表單驗證【前】
- 第八節 刪除學生
- 1 CSS選擇器【前】
- 2 confirm【前】
- 3 功能開發與測試【后】
- 4 集成測試
- 5 定制提示框【前】
- 6 引入圖標庫【前】
- 第九節 集成測試
- 第五章 登錄與注銷
- 第一節:普通登錄
- 1 原型【前】
- 2 功能設計【前】
- 3 功能設計【后】
- 4 應用登錄組件【前】
- 5 注銷【前】
- 6 保留登錄狀態【前】
- 第二節:你是誰
- 1 過濾器【后】
- 2 令牌機制【后】
- 3 裝飾器模式【后】
- 4 攔截器【前】
- 5 RxJS操作符【前】
- 6 用戶登錄與注銷【后】
- 7 個人中心【前】
- 8 攔截器【后】
- 9 集成測試
- 10 單例模式
- 第六章 課程管理
- 第一節 新增課程
- 1 初始化【前】
- 2 嵌套組件測試【前】
- 3 async管道【前】
- 4 優雅的測試【前】
- 5 功能開發【前】
- 6 實體監聽器【后】
- 7 @ManyToMany【后】
- 8 集成測試【前】
- 9 異步驗證器【前】
- 10 詳解CORS【前】
- 第二節 課程列表
- 第三節 果斷
- 1 初始化【前】
- 2 分頁組件【前】
- 2 分頁組件【前】
- 3 綜合查詢【前】
- 4 綜合查詢【后】
- 4 綜合查詢【后】
- 第節 班級列表
- 第節 教師列表
- 第節 編輯課程
- TODO返回機制【前】
- 4 彈出框組件【前】
- 5 多路由出口【前】
- 第節 刪除課程
- 第七章 權限管理
- 第一節 AOP
- 總結
- 開發規范
- 備用