既然是常用的功能,那么spring必然已經有了最佳實踐。在進行最佳實踐前,我們來簡單匯制下時序圖:

考慮到該功能實現的復雜性,我們在此使用敏捷開發(agile development)的方法,先開發分頁功能,再開發綜合查詢功能。
# CurdRepository
無論經過多少次轉發,最終實現數據分頁查詢的必然是倉庫層。StudentRepository繼承了CurdRepository,進行spring為其自動實現了一些基本的增改查刪的功能。我們打開CurdRepository來簡單瀏覽一下這個文件:
CurdRepository
```
package org.springframework.data.repository;
import java.util.Optional;
@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S var1); ?
<S extends T> Iterable<S> saveAll(Iterable<S> var1); ?
Optional<T> findById(ID var1); ?
boolean existsById(ID var1); ?
Iterable<T> findAll(); ?
Iterable<T> findAllById(Iterable<ID> var1); ?
long count(); ?
void deleteById(ID var1); ?
void delete(T var1); ?
void deleteAll(Iterable<? extends T> var1); ?
void deleteAll(); ?
}
```
* ? 新增/更新功能
* ? 查詢功能
* ? 刪除功能
通過查看我們發現其提供的查詢功能中并沒有找到我們需要的分頁功能。的確是這樣,在spring中CurdRepository只提供了基本的增改查刪功能,如果想實現更復雜的分頁功能,則需要繼承其它的接口。
# PagingAndSortingRepository
spring為我們提供了`org.springframework.data.repository.PagingAndSortingRepository;`來滿足對分頁功能的需求,要想使用此接口給我們帶來的功能,只需要繼承該接口即可.
repository/StudentRepository.java
```
package com.mengyunzhi.springBootStudy.repository;
import com.mengyunzhi.springBootStudy.entity.Student;
import org.springframework.data.repository.PagingAndSortingRepository; ①
/**
* 學生
*/
public interface StudentRepository extends PagingAndSortingRepository<Student, Long>② {
}
```
* ① 使用前先引入
* ② 和CrudRepository相同,繼承該接口時,需要指定實體類型及實體的主健類型
此時,我們應該有個疑問:在歷史的代碼中,我們是通過間接調用CrudRepository的save方法來完成的數據新增功能。而當前修改了繼承的接口,那么以前代碼中間接調用CrudRepository.save方法還可以正常工作嗎?為此,我們借助idea來看一下當前接口的繼承關系:

依圖所示,StudentRepository繼承了PagingAndSortingRepository,PagingAndSortingRepository又繼承了CrudRepository。因而我們在歷史的代碼中書寫的學生保存的相關功能性代碼仍然可用。在調用studentRepository的save方法時,它會按照繼承的原則:此類沒有則轉向父類、父類沒有則轉向父父類,依此累推,最終仍然會調用到CrudRepository的save方法。
PagingAndSortingRepository中有兩個方法:
```
@NoRepositoryBean
public interface PagingAndSortingRepository<T, ID> extends CrudRepository<T, ID> {
Iterable<T> findAll(Sort var1); ?
Page<T> findAll(Pageable var1); ?
}
```
* ? 接收的參數類型為**排序**,返回值為**迭代器**,**迭代器**可以認為是數組的一種,與數組不同的是:我們獲取數組中的子項時,不能夠再使用索引的方法,而只能使用其它特定的方法。
* ? 接收的參數類型為\*\*(可)分頁\*\*,返回值為**含有總頁數及當前頁數組的特定類型**。Page類型除包含總頁數、當前頁數據外,還包含了第幾頁、每頁大小、總條數、排序規則、是否首頁、是否尾頁、是否還有下一頁、是否還有上一頁等其它的與分頁相關的信息。
# 獲取分頁數據
要想獲取分頁數據,首先需要獲取一個實現了Pageable接口的對象,該對象可使用`Pageable pageable = PageRequest.of(page, size)`來初始化。比如我們想獲取每頁10條情況下,第1頁的數據則可以使用如下的方法:
repository/StudentRepositoryTest.java(請新建)
```
package com.mengyunzhi.springBootStudy.repository;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page; ①
import org.springframework.data.domain.PageRequest; ②
import org.springframework.data.domain.Pageable; ③
import org.springframework.test.context.junit4.SpringRunner;
@SpringBootTest
@RunWith(SpringRunner.class)
public class StudentRepositoryTest {
@Autowired
StudentRepository studentRepository;
@Test
public void page() {
Pageable pageable = PageRequest.of(0, 10); ?
Page<Student> studentPage = studentRepository.findAll(pageable); ?
return; ?
}
}
```
* ①②引入特定的類,由于有多個重名的類,所以在此處需要注意該類的位置。
* ? 初始化第0頁、每頁10條的分頁查詢條件
* ? 查詢分頁數據
* ? 加個冗余的return用于debug程序
接著我們在此處打個斷點:

然后用debug模式啟動該單元測試

并展開studentPage如下:

上圖所示,返回值Page中含有:
* 數據總條數0
* 當前面數據content
* 分頁信息pageable
* 當前為第0頁
* 每頁10條數據
將如上對象直接返回給前臺,完全可以滿足我們的當前需求。
## 數據測試
接下來,我們在測試中加入測試數據,再次debug看看實際的返回值
```
@Autowired
KlassRepository klassRepository;
@Test
public void page() {
Klass klass = new Klass();
klass.setName("testKlass");
this.klassRepository.save(klass);
for (int i = 0; i < 100; i++) {
Student student = new Student();
student.setName(RandomString.make(4));
student.setSno(RandomString.make(6));
student.setKlass(klass);
this.studentRepository.save(student);
}
Pageable pageable = PageRequest.of(2, 15);
Page<Student> studentPage = studentRepository.findAll(pageable);
return;
}
```
再次debug中斷查看:

查看content:

如此,我們便有了實現數據分頁功能的基礎。
# M層
當我們第一次使用某個功能的時候(還處于解決技術障礙中),我們的首頁目標是借助于debug來弄清楚該功能的具體使用方法,傳入值與返回的類型等,單元測試應該放在后面進行補充。TDD測試驅動開發僅限于我們對某個功能的實現不存在技術上的障礙時。在此,我們先完成M層的功能部分,再對應進行測試代碼的編寫.
service/StudentService.java
```
/**
* 查詢分頁信息
*
* @param pageable 分頁條件
* @return 分頁數據
*/
Page<Student> findAll(Pageable pageable);
```
service/StudentServiceImpl.java
```
@Override
public Page<Student> findAll(Pageable pageable) {
return this.studentRepository.findAll(pageable);
}
```
## 單元測試
按前面的經驗, 整理單元測試代碼如下:
service/StudentServiceImplTest.java
```
/**
* 分頁查詢
* 1. 模擬輸入、輸出、調用studentRepository
* 2. 調用測試方法
* 3. 斷言輸入與輸出與模擬值相符
*/
@Test
public void findAll() {
Pageable mockInPageable = PageRequest.of(1, 20); ①
List<Student> mockStudents = Arrays.asList(new Student()); ②
Page<Student> mockOutStudentPage = new PageImpl<Student>(
mockStudents,
PageRequest.of(1, 20),
21); ?
Mockito.when(this.studentRepository.findAll(Mockito.any(Pageable.class)))
.thenReturn(mockOutStudentPage); ④
Page<Student> studentPage = this.studentService.findAll(mockInPageable); ⑤
Assertions.assertThat(studentPage).isEqualTo(mockOutStudentPage); ⑥
ArgumentCaptor<Pageable> pageableArgumentCaptor = ArgumentCaptor.forClass(Pageable.class);
Mockito.verify(this.studentRepository).findAll(pageableArgumentCaptor.capture());
Assertions.assertThat(pageableArgumentCaptor.getValue()).isEqualTo(mockInPageable); ⑦
```
* ① 模擬輸入
* ② 初始化返回分頁信息的本頁數據部分
* ③ 使用 本頁數據、 分頁情況、總條件來初始化模擬返回值Page<Student>
* ④ 模擬studentRepository.findAll方法的返回值
* ⑤ 調用被測試方法
* ⑥ 斷言返回值
* ⑦ 斷言傳入參數

# C層
C層的代碼也很簡單:
controller/StudentController.java
```
@GetMapping
public Page<Student> findAll(@RequestParam int page, @RequestParam int size) {
return this.studentService.findAll(PageRequest.of(page, size));
}
```
## 單元測試一
為了更清楚的了解真實情況的返回值,我們暫且將單元測試中StudentService的注解由@MockBean改為@Autowired,然后模擬添加一些數據,看看真實情況下會給我們返回什么樣的數據(注意:這違背了單元測試的原則。在單元測試中,我們的測試內容應該圍繞輸入與輸出展開。對于被測試方法在執行期間調用其它的方法的,應該使用MOCK來進行模擬)。
contoroller/StudentControllerTest.java
```
@Autowired ?
@MockBean ?
private StudentService studentService;
@Autowired
private KlassRepository klassRepository; ①
@Autowired
private StudentRepository studentRepository; ①
@Test
public void findAll() throws Exception {
logger.info("準備100條測試數據");
Klass klass = new Klass();
klass.setName("testKlass");
this.klassRepository.save(klass);
for (int i = 0; i < 100; i++) {
Student student = new Student();
student.setName(RandomString.make(4));
student.setSno(RandomString.make(6));
student.setKlass(klass);
this.studentRepository.save(student);
}
logger.info("每頁2條,請求第1頁數據");
String url = "/Student?page=49&size=2"; ②
this.mockMvc.perform(MockMvcRequestBuilders.get(url))
.andDo(MockMvcResultHandlers.print()) ③
.andExpect(MockMvcResultMatchers.status().isOk());
}
```
* ① 引入數據倉庫
* ② 將每頁大小、當前頁兩個查詢參數直接拼接到URL中
* ③ 在控制臺中打印返回的結果
啟動單元測試后在控制臺中得到如下返回信息:
```
Body = {"content":[{"id":99,"name":"FtJf","sno":"56IhJV","klass":{"id":1,"teacher":null,"name":"testKlass"}},{"id":100,"name":"WHpT","sno":"YVwSqA","klass":{"id":1,"teacher":null,"name":"testKlass"}}],"pageable":{"sort":{"sorted":false,"unsorted":true,"empty":true},"offset":98,"pageSize":2,"pageNumber":49,"paged":true,"unpaged":false},"totalPages":50,"totalElements":100,"last":true,"size":2,"number":49,"numberOfElements":2,"first":false,"sort":{"sorted":false,"unsorted":true,"empty":true},"empty":false}
```
對其進行格式化后如下:
```
{ ①
"content": [ ②③
{ ④
"id": 99,
"name": "FtJf",
"sno": "56IhJV",
"klass":
{
"id": 1,
"teacher": null,
"name": "testKlass"
}
},
{ ④
"id": 100, ⑤
"name": "WHpT", ⑤
"sno": "YVwSqA", ⑤
"klass": ⑤
{
"id": 1, ⑥
"teacher": null, ⑥
"name": "testKlass" ⑥
}
}],
"pageable": ②⑦
{
"sort":
{
"sorted": false,
"unsorted": true,
"empty": true
},
"offset": 98,
"pageSize": 2,
"pageNumber": 49,
"paged": true,
"unpaged": false
},
"totalPages": 50, ②
"totalElements": 100, ②
"last": true, ②
"size": 2, ②
"number": 49, ②
"numberOfElements": 2, ②
"first": false, ②
"sort": ②⑧
{
"sorted": false,
"unsorted": true,
"empty": true
},
"empty": false ②
}
```
* ① 返回值為一個對象 Page
* ② 對象①的各個屬性
* ③ 當前頁內容 Array<Student>
* ④ 數組中有兩個對象 Student
* ⑤ Student對象④的屬性
* ⑥ Klass對象⑤的屬性
* ⑦ 分頁條件信息
* ⑧ 排序條件信息
如上所示,spring不僅僅返回了當前頁的數據、分頁條件、總頁數、數據總數信息,還返回了是否尾頁、每頁大小、當前頁碼(0基)、當前頁數據條數、是否首頁、排序、當前數據是否為空信息。這些數據為前臺提供了良好的支持。
## 單元測試二
讓我們恢復剛剛的測試,繼續使用模擬的服務層來完成C層的測試。
```
@Autowired ?
@MockBean ?
private StudentService studentService;
@Autowired ?
private KlassRepository klassRepository; ?
@Autowired ?
private StudentRepository studentRepository; ?
@Test
public void findAll() throws Exception {
logger.info("初始化模擬返回數據");
List<Student> students = new ArrayList<>();
Klass klass = new Klass();
klass.setId(-2L);
for (long i = 0; i < 2; i++) {
Student student = new Student();
student.setId(-i - 1);
student.setSno(RandomString.make(6));
student.setName(RandomString.make(4));
student.setKlass(klass);
students.add(student);
}
logger.info("初始化分頁信息及設置模擬返回數據");
Page<Student> mockOutStudentPage = new PageImpl<Student>(
students,
PageRequest.of(1, 2),
4
);
Mockito.when(this.studentService.findAll(Mockito.any(Pageable.class)))
.thenReturn(mockOutStudentPage);
logger.info("以'每頁2條,請求第1頁'為參數發起請求,斷言返回狀態碼為200,并接收響應數據");
String url = "/Student";
MvcResult mvcResult = this.mockMvc.perform(
MockMvcRequestBuilders.get(url)
.param("page", "1")
.param("size", "2"))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andReturn();
logger.info("將返回值由string轉為json,并斷言接收到了分頁信息");
LinkedHashMap① returnJson = JsonPath.parse(mvcResult.getResponse().getContentAsString()).json();
Assertions.assertThat(returnJson.get("totalPages")).isEqualTo(2); // 總頁數
Assertions.assertThat(returnJson.get("totalElements")).isEqualTo(4); // 總條數
Assertions.assertThat(returnJson.get("size")).isEqualTo(2); // 每頁大小
Assertions.assertThat(returnJson.get("number")).isEqualTo(1); // 第幾頁(0基)
Assertions.assertThat(returnJson.get("numberOfElements")).isEqualTo(2); // 當前頁條數
//todo: 斷言獲取到了content,類型為數組 ?
return;
}
```
通過??標記可以看到,由于Mock的加入,在進行C層功能代碼的測試時,我們僅僅需要考慮C層直接調用的服務層StudentService.findAll的輸入輸出即可,而StudentService是否實現了其描述的功能以及如何實現的其描述的功能,我們完全不關心也不需要關心。
* ? 只所以在這里出現todo,是由于①LinkedHashMap這個容器可以裝入任意類型,所以我們無法通過returnJson.get("content")來獲取其content的數據類型。在第一次接觸時,我們需要debug來幫我查看content的數據類型然后繼續完成后續的操作。
為此,我們在此處打個斷點:

然后啟動debug,并在debug控制臺中找到returnJson:

得到具體的類型后,我們繼續完成測試:
```
import net.minidev.json.JSONArray; ★
...
@Test
public void findAll() throws Exception {
...
Assertions.assertThat(returnJson.get("numberOfElements")).isEqualTo(2); // 當前頁條數
logger.info("測試content");
JSONArray content = (JSONArray★) returnJson.get("content");
Assertions.assertThat(content.size()).isEqualTo(2); // 返回了2個學生
logger.info("測試返回的學生");
for (int① i = 0; i < 2; i++) {
LinkedHashMap studentHashMap = (LinkedHashMap) content.get(i); // 獲取第一個學生
Assertions.assertThat(studentHashMap.get("id")).isEqualTo(-i - 1);
Assertions.assertThat(studentHashMap.get("name").toString().length()).isEqualTo(4);
Assertions.assertThat(studentHashMap.get("sno").toString().length()).isEqualTo(6);
logger.info("測試返回學生所在的班級");
LinkedHashMap klassHashMap = (LinkedHashMap) studentHashMap.get("klass");
Assertions.assertThat(klassHashMap.get("id")).isEqualTo(-2);
Assertions.assertThat(klassHashMap.get("name")).isEqualTo("test klass name");
}
return;
}
```
* ★ 注意此處的類型為:net.minidev.json.JSONArray
* ① 此處是int,不是long。
>[success] 在C層的單元測試中,對每個前臺需要的測試都加入相應的斷言是非常有必要的。在生產項目中如果未對C層的輸出字段進行斷言,則必然發生在后臺的敏捷開發中造成前臺部分功能失效的問題。
# 總結
我們在本小節中花費了大量的精力來編寫單元測試。在編寫的過程中我們感受到:編寫單元測試的難度遠遠超出了編寫功能代碼的難度;編寫單元測試的時間遠遠的超出了編寫功能代碼的時間。而這,是非常有必要的。在軟件開發的所有的專業課中,軟件工程是在學習的時候最不容易引起重視但卻在實戰中起出保障軟件質量關鍵一環的核心課程。如果你不希望自己以后編寫的軟件在每次更新后都會發生或多或少的非預期錯誤,如果你不希望自己本已經編寫好的功能在其它團隊成員的協助開發下變得不可用,如果你想做一個對前臺負責的后臺開發工程師、如果你不想在新的版本上線后天天打噴嚏、如果你希望隨著需求的發展及新技術的普通而能夠放開手腳的重構代碼、如果你的目標是Engineer而不是Programmer,那么從現在起請注重**單元測試**!
# 參考文檔
| 名稱 | 鏈接 | 預計學習時長(分) |
| --- | --- | --- |
| 源碼地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.1](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.1) | - |
- 序言
- 第一章: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
- 總結
- 開發規范
- 備用