之前在《如何說服你的同事使用TDD》中介紹了為什么要使用TDD(測試驅(qū)動開發(fā)),以及如何使用TDD寫代碼。文章發(fā)表后,有同學(xué)在評論區(qū)中表示文章寫得不錯,但是舉得例子太過脫離實際了,能不能舉一個在實際工作中的例子呀。這篇文章,就來分享一下在Spring Boot中,如何使用TDD寫出功能健壯、代碼整潔的高質(zhì)量接口。
我將用一個簡單的案例,向你展示:
- 什么是“接口文檔->測試用例->產(chǎn)品代碼”的TDD開發(fā)流程
- 在Spring Boot中,怎樣同時使用集成測試和單元測試,保證測試的覆蓋面
- 使用Spring Boot測試框架的一些優(yōu)秀實踐
- 為什么要使用TDD
接口文檔
我們要實現(xiàn)的接口,功能非常簡單,就是能夠?qū)γ舾凶盅圻M行檢查的發(fā)帖功能,不允許發(fā)帶有“shit”、“fxxk”之類字眼的帖子,嗯,我們是一個文明的社區(qū)!
接口文檔如下:
接口說明
發(fā)布帖子,同時對敏感字眼進行校驗
URL
/v2.0/posts
HTTP請求方式
POST
請求體
參數(shù): content(帖子的內(nèi)容,String)
響應(yīng)
200 創(chuàng)建成功,返回成功創(chuàng)建的帖子信息
400 創(chuàng)建失敗,帖子中包含敏感字眼
示例1
請求體
{
"content": "hello world!"
}
響應(yīng)
200
{
"id": 1,
"content": "hello world!",
"username": "sexy code",
"createDate": 1515312619351
}
示例2
請求體
{
"content": "hello shit!"
}
響應(yīng)
{
"errorCode": 100001,
"errorInfo": "post contains sensitive info"
}
測試策略
如果不采用TDD,那么下一步就是拿著接口文檔開發(fā)接口了,但是這很不TDD。TDD要求我們先寫測試用例。
你或許會認為不寫測試用例,同樣可以寫出實現(xiàn)功能的接口。別急,測試用例帶給你的好處遠遠不止正確性。
看完上面那份接口文檔,我們很自然的想到有下面兩個測試用例:
- 發(fā)布內(nèi)容合規(guī)的帖子,成功發(fā)布,返回200和對應(yīng)的數(shù)據(jù)
- 發(fā)布含有敏感字眼的帖子,發(fā)布失敗,返回400和錯誤提示
上面這兩個測試用例,都是從模擬客戶端請求,到后臺業(yè)務(wù)層和數(shù)據(jù)庫層操作,再到返回響應(yīng)的端到端測試,因此屬于集成測試。
集成測試要求我們啟動Spring Boot的容器,因此運行起來會比較慢。通常情況下,集成測試只覆蓋基本場景,更細致的測試,可以交給單元測試。
比如在這個場景中,我們可以針對判斷內(nèi)容中是否含有敏感信息的這個功能,進行單元測試,這也就要求我們把這個功能,抽取成一個方法,這樣才方便我們寫測試用例。由于單元測試不需要啟用Spring Boot容器,因此測試用例運行起來將非常迅速。
TDD在不知不覺中提高了我們的代碼質(zhì)量。它讓我們從測試用例的角度出發(fā),思考如何寫出方便測試的代碼,方便測試的代碼,往往是符合單一職責(zé)的。
集成測試
制定好測試策略之后,下面開始寫第一個測試用例。
一個測試用例通常包括以下三個步驟:
- 創(chuàng)建環(huán)境,初始化數(shù)據(jù)
- 執(zhí)行操作
- 驗證操作結(jié)果
對于我們這個發(fā)帖的接口,那就是:
- 創(chuàng)建Spring Boot容器
- 向發(fā)帖的接口,發(fā)送Post請求
- 根據(jù)返回的帖子id,去數(shù)據(jù)庫查詢,看查到的數(shù)據(jù),是不是和發(fā)送的數(shù)據(jù)一致
使用Spring Boot提供的測試框架,可以很輕松的將上面這個過程寫成代碼(本文的所有代碼,可到Github下載,歡迎加星):
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class PostControllerV2ITTest {
public static final String POST_CONTENT_VALID = "post content test";
public static final String POST_URL = "/v2.0/posts";
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private PostRepository postRepository;
@Test
public void testCreatePost_returnSuccess() throws Exception {
ResultActions resultActions = sendCreatePostRequest(POST_CONTENT_VALID);
checkCreateValidPostResult(resultActions, POST_CONTENT_VALID);
}
...
}
PostControllerV2ITTest類上的幾個注解,@RunWith、@SpringBootTest等,是Spring Boot提供的用于創(chuàng)建集成測試環(huán)境的注解,本文重點在于TDD,因此這幾個注解的具體用途和原理就不一一贅述了,有興趣的同學(xué)可以查看Spring Boot官方文檔中關(guān)于測試框架的介紹。
代碼中發(fā)送請求的函數(shù)sendCreatePostRequest和檢查請求結(jié)果的函數(shù)checkCreateValidPostResult分別如下:
sendCreatePostRequest:
private ResultActions sendCreatePostRequest(String postContent) throws Exception {
PostCreateDTO postCreateDTO = new PostCreateDTO(postContent);
return mockMvc.perform(post(POST_URL)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(postCreateDTO)));
}
checkCreateValidPostResult:
private void checkCreateValidPostResult(ResultActions resultActions, String expectedContent) throws Exception {
resultActions.andExpect(status().isCreated());
Post postFromRsp = transferResponse2PostEntity(resultActions);
Post postFromDB = postRepository.findOne(postFromRsp.getId());
assertNotNull(postFromDB);
assertEquals(expectedContent, postFromDB.getContent());
}
private Post transferResponse2PostEntity(ResultActions resultActions) throws java.io.IOException {
String response = resultActions.andReturn().getResponse().getContentAsString();
return objectMapper.readValue(response, Post.class);
}
寫完測試用例,編輯器會用飄紅提醒你,你還沒創(chuàng)建PostRepository、Post、PostCreateDTO這些類。嗯,別急,這就創(chuàng)建。
PostRepository,使用Spring Data,可以輕松寫出一個自帶增刪改查功能的DAO:
public interface PostRepository extends CrudRepository<Post, Long> {
}
Post,其實就是數(shù)據(jù)庫中的存儲結(jié)構(gòu),用Java Entity的形式表示出來:
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
private String content;
private String username;
private Date createDate;
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Date getCreateDate() {
return createDate;
}
public void setCreateDate(Date createDate) {
this.createDate = createDate;
}
}
PostCreateDTO,發(fā)帖接口的請求體:
public class PostCreateDTO {
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public PostCreateDTO(String content) {
this.content = content;
}
public PostCreateDTO() {
}
}
創(chuàng)建完這三個類之后,測試用例可以編譯通過了,執(zhí)行它,由于我們還沒有寫接口,嗯,測試用例理所當(dāng)然、意料之中地失敗了:
預(yù)期201,實際404,因為我們還沒提供接口。
那下面自然就是寫接口啦,終于可以寫產(chǎn)品代碼了!
PostController,只負責(zé)定義接口路徑,邏輯全部交給Service:
@RestController
@RequestMapping("/v2.0/posts")
public class PostControllerV2 {
@Autowired
private PostService postService;
@RequestMapping(value="", method= RequestMethod.POST)
public ResponseEntity createPost(@RequestBody PostCreateDTO postCreateDTO) {
return postService.createPost(postCreateDTO);
}
}
PostService,業(yè)務(wù)層操作,將PostCreateDTO轉(zhuǎn)成Post,然后調(diào)用postRepository,將數(shù)據(jù)保存到數(shù)據(jù)庫中:
@Service
public class PostService {
@Autowired
private PostRepository postRepository;
@Autowired
private UserService userService;
public ResponseEntity createPost(PostCreateDTO postCreateDTO) {
Post postCreateResult = savePost2DB(postCreateDTO);
return ResponseEntity.status(HttpStatus.CREATED).body(postCreateResult);
}
private Post savePost2DB(PostCreateDTO postCreateDTO) {
Post post = new Post();
post.setCreateDate(new Date());
post.setContent(postCreateDTO.getContent());
post.setUsername(userService.queryCurrentUserName());
return postRepository.save(post);
}
}
PostService中用到了另一個Service,UserService,用于獲取當(dāng)前登錄用戶,當(dāng)然這里并沒有真的去從session中獲取用戶信息:
@Service
public class UserService {
public String queryCurrentUserName() {
return "sexy code";
}
}
完工,運行下測試用例,通過后,我們繼續(xù)寫下一個集成測試用例——敏感字段校驗。
第二個用例依然遵循測試用例“三部曲”,創(chuàng)建環(huán)境->創(chuàng)建帶有敏感信息的帖子->檢查響應(yīng)是不是400、檢查數(shù)據(jù)庫中是不是沒有數(shù)據(jù)。這里只貼上新增的代碼。
PostControllerV2ITTest:
public static final String POST_CONTENT_SENSITIVE = "post content test fuck";
...
@Test
public void testCreatePost_withSensitiveInfo_returnBadRequest() throws Exception {
ResultActions resultActions = sendCreatePostRequest(POST_CONTENT_SENSITIVE);
checkCreateSensitivePostResult(resultActions);
}
...
private void checkCreateSensitivePostResult(ResultActions resultActions) throws Exception {
resultActions.andExpect(status().isBadRequest());
long count = postRepository.count();
assertEquals(0, count);
}
運行新的測試用例,自然又是理所當(dāng)然的失敗。繼續(xù)寫產(chǎn)品代碼。由于我們遵循良好的分層結(jié)構(gòu),Controller不需要做任何修改,只需給PostService加上判斷敏感字段的邏輯即可,PostService:
...
public ResponseEntity createPost(PostCreateDTO postCreateDTO) {
if(isPostContainsSensitiveInfo(postCreateDTO.getContent())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorInfo(SENSITIVE_INFO_ERROR_CODE, POST_CONTAINS_SENSITIVE_INFO));
}
Post postCreateResult = savePost2DB(postCreateDTO);
return ResponseEntity.status(HttpStatus.CREATED).body(postCreateResult);
}
private boolean isPostContainsSensitiveInfo(String content) {
// TODO: change to throw exception and use global exception handler to return response
if(content.contains("shit") || content.contains("fuck")) {
return true;
}
return false;
}
...
這里的isPostContainsSensitiveInfo就是我們用來判斷敏感字段的方法,我們將整個判斷邏輯抽取出來,方便后面的單元測試。
值得注意的是,這個方法更好的做法是在判斷為含有敏感信息時,拋出異常,而不是返回true這種標(biāo)志(參見《Effective Java》第九章異常中提出的原則),不過由于我還沒給整個Spring Boot項目加上全局異常處理器,因此這里暫時先使用返回boolean的方式來處理,后面會寫一篇文章來分享如何在Spring Boot中把異常轉(zhuǎn)換為http狀態(tài)碼。
寫完產(chǎn)品代碼,再來運行測試用例,通過。
單元測試
現(xiàn)在我們的代碼已經(jīng)可以滿足上面兩個集成測試,可以說基礎(chǔ)場景的功能我們已經(jīng)實現(xiàn)了。但是我們的測試覆蓋率并不全。
舉個簡單的例子,"shit"和"fxxk"都是敏感信息,但是上面我們只測試了"fxxk"的場景,可是專門給"shit"這個場景寫一個集成測試又未免太過興師動眾,這時候我們就可以使用單元測試,來對功能進行更細致并且更快速的測試。由于isPostContainsSensitiveInfo是private方法,因此我們在測試時用到了反射。
PostServiceUnitTest:
public class PostServiceUnitTest {
@Test
public void testMethod_IsPostContainsSensitiveInfo() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Class<PostService> postServiceClass = PostService.class;
Method method = postServiceClass.getDeclaredMethod("isPostContainsSensitiveInfo", String.class);
method.setAccessible(true);
PostService postService = new PostService();
checkWithContent(method, postService, "hi and fuck", true);
checkWithContent(method, postService, "hello world", false);
checkWithContent(method, postService, "hello shit", true);
}
private void checkWithContent(Method method, PostService postService, String content, boolean expected) throws IllegalAccessException, InvocationTargetException {
boolean isSensitive = (Boolean)method.invoke(postService, content);
assertEquals(expected, isSensitive);
}
}
顯然,這是一個非常簡單的Junit,不需要啟用Spring Boot容器,運行起來自然也是相當(dāng)迅速,在我的機器上,執(zhí)行一次集成測試要花費15秒,其中絕大多數(shù)時間都是花在初始化容器上,而執(zhí)行一個單元測試只需要1秒。
防止測試用例之間相互影響
寫測試用例有一個原則,那就是各個用例之間不能夠相互影響,而我在testCreatePost_returnSuccess用例中給數(shù)據(jù)庫插入了數(shù)據(jù),卻沒有在testCreatePost_withSensitiveInfo_returnBadRequest用例開始之前對數(shù)據(jù)庫進行清空,這樣testCreatePost_returnSuccess用例中插入的數(shù)據(jù)就會帶到下一個用例中去,更不幸的是,我們在testCreatePost_withSensitiveInfo_returnBadRequest用例中還加入了如下數(shù)據(jù)庫count的校驗:
...
long count = postRepository.count();
assertEquals(0, count);
...
因此,只要testCreatePost_returnSucces用例在testCreatePost_withSensitiveInfo_returnBadRequest之前執(zhí)行,那么testCreatePost_withSensitiveInfo_returnBadRequest就會失敗。
我們來驗證一下,為了實現(xiàn)上面所講的測試用例的執(zhí)行順序,我給PostControllerV2ITTest加入了@FixMethodOrder(MethodSorters.NAME_ASCENDING)注解:
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class PostControllerV2ITTest
執(zhí)行測試用例,果然,testCreatePost_withSensitiveInfo_returnBadRequest失敗了:
預(yù)期0,結(jié)果1,因為我們在用例開始前沒有清空數(shù)據(jù)庫,導(dǎo)致用例之間相互影響。要解決這個問題,很簡單,只需要寫個@Before注解的函數(shù),并在函數(shù)中清空表中的數(shù)據(jù):
@Before
public void setup() {
postRepository.deleteAll();
}
@Before是Junit提供的注解,每個測試用例在執(zhí)行前,都會執(zhí)行被@Before注解的函數(shù)。
更多
這篇文章只是舉了一個我認為的,足夠簡單,卻又足夠說明問題的例子,在實際開發(fā)中,自然會遇到更多的場景,比如:
- 你們項目加入了鑒權(quán),每個請求過來都會被攔截,導(dǎo)致你在測試用例中發(fā)出的請求都會返回401,怎么辦?你可以使用standalonesetup的方式,只加載你需要的Bean,這樣就不會引入鑒權(quán)框架;你也可以使用Mock,把鑒權(quán)的函數(shù)Mock掉;當(dāng)然你也可以Mock其他的函數(shù),反正只要制造你已經(jīng)登錄的假象就好了;或許你還有其他奇技淫巧...
- 你不想每次都把請求返回的結(jié)果轉(zhuǎn)成Java Bean,然后一個個字段去校驗,你希望直接校驗json字符串?沒問題,Spring Boot支持你這樣做: Auto-configured JSON tests
- 你寫了一個很復(fù)雜的Dao操作,想要對它進行單元測試?這也沒問題: Auto-configured Data JPA tests
Spring Boot為我們寫好測試用例、用好TDD提供了非常方便的框架,我們只需盡情去寫測試用例,盡情去TDD就好了。
再談TDD
這篇文章雖然是在談如何在Spring Boot中使用TDD寫高質(zhì)量的接口,但是從這樣一個例子中,我們也看到了TDD的很多好處:
讓你開發(fā)時充滿成就感:你寫代碼就是為了讓原本fail的測試用例通過,這讓你寫代碼時更加具有目標(biāo),同時也讓你的代碼好壞具有可以量化的指標(biāo)。
促進整潔的代碼:正如之前提到的,TDD讓我們從測試用例的角度出發(fā),思考如何寫出方便測試的代碼,方便測試的代碼,往往是符合單一職責(zé)的。
提高開發(fā)的效率:我身邊很多不寫測試用例的同事,每次一修改代碼,就把代碼編譯成class文件放到環(huán)境上,然后重啟、測試,這對于小項目來說尚可接受,但是對于一個大的項目,重啟往往需要花費很多時間,而且在我接觸到的一個Docker容器化的項目中,還不支持用單個class文件替換的方式去打補丁,每次替換都需要替換整個服務(wù)的代碼,嗯,然后每次替換、驗證、發(fā)現(xiàn)新Bug,再修改、替換、驗證... 這樣開發(fā)的效率自然不高。但是如果你已經(jīng)在本地環(huán)境上寫了充分的測試用例,那么代碼一把布上去,一把驗證通過,也就是家常便飯了的事了。
-
提高了測試用例的代碼覆蓋率:這幾乎無需解釋,先寫測試用例,再寫產(chǎn)品代碼,和先寫產(chǎn)品代碼,后來由于某種政治任務(wù)的壓迫,再來補測試用例,前者寫出來的測試用例質(zhì)量一定更高,測試的覆蓋率也一定更大。而代碼覆蓋率的提高,將帶給我們下面兩個個超級好處:
- 方便重構(gòu):有多少次你看到一份寫的很爛的代碼,卻又不敢重構(gòu),生怕把原有的功能搞壞?有了高覆蓋率的測試用例,你就不再擔(dān)心這個了,重構(gòu)后,只需要跑一遍用例,就知道你的重構(gòu)有沒有影響原先的功能。
- 測試即文檔:測試用例是最好的文檔,文檔會撒謊、注釋會撒謊,但是代碼不會。
寫完這篇文章,結(jié)合之前那篇《如何說服你的同事使用TDD》,嗯,這下我真的非常有信心,可以說服你們使用TDD,說服你們?nèi)フf服你們同事,使用TDD了。
參考
- Spring Boot Testing
- spring-guides/gs-testing-web
- How do I test a private function or a class that has private methods, fields or inner classes?
- junit-team/junit4 - test-execution-order
- Are Spring's MockMvc used for unit testing or integration testing?
- Unit and Integration Tests in Spring Boot - DZone Integration
- Injecting Mockito mocks into a Spring bean
- 《Effective Java》
- 《程序員的職業(yè)素養(yǎng)》
- 《代碼整潔之道》
- 《重構(gòu)》