如何在Spring Boot中使用TDD寫出高質(zhì)量的接口

之前在《如何說服你的同事使用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)功能的接口。別急,測試用例帶給你的好處遠遠不止正確性。

看完上面那份接口文檔,我們很自然的想到有下面兩個測試用例:

  1. 發(fā)布內(nèi)容合規(guī)的帖子,成功發(fā)布,返回200和對應(yīng)的數(shù)據(jù)
  2. 發(fā)布含有敏感字眼的帖子,發(fā)布失敗,返回400和錯誤提示

上面這兩個測試用例,都是從模擬客戶端請求,到后臺業(yè)務(wù)層和數(shù)據(jù)庫層操作,再到返回響應(yīng)的端到端測試,因此屬于集成測試
集成測試要求我們啟動Spring Boot的容器,因此運行起來會比較慢。通常情況下,集成測試只覆蓋基本場景,更細致的測試,可以交給單元測試
比如在這個場景中,我們可以針對判斷內(nèi)容中是否含有敏感信息的這個功能,進行單元測試,這也就要求我們把這個功能,抽取成一個方法,這樣才方便我們寫測試用例。由于單元測試不需要啟用Spring Boot容器,因此測試用例運行起來將非常迅速。

TDD在不知不覺中提高了我們的代碼質(zhì)量。它讓我們從測試用例的角度出發(fā),思考如何寫出方便測試的代碼,方便測試的代碼,往往是符合單一職責(zé)的。

集成測試

制定好測試策略之后,下面開始寫第一個測試用例。

一個測試用例通常包括以下三個步驟:

  1. 創(chuàng)建環(huán)境,初始化數(shù)據(jù)
  2. 執(zhí)行操作
  3. 驗證操作結(jié)果

對于我們這個發(fā)帖的接口,那就是:

  1. 創(chuàng)建Spring Boot容器
  2. 向發(fā)帖的接口,發(fā)送Post請求
  3. 根據(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ā)中,自然會遇到更多的場景,比如:

  1. 你們項目加入了鑒權(quán),每個請求過來都會被攔截,導(dǎo)致你在測試用例中發(fā)出的請求都會返回401,怎么辦?你可以使用standalonesetup的方式,只加載你需要的Bean,這樣就不會引入鑒權(quán)框架;你也可以使用Mock,把鑒權(quán)的函數(shù)Mock掉;當(dāng)然你也可以Mock其他的函數(shù),反正只要制造你已經(jīng)登錄的假象就好了;或許你還有其他奇技淫巧...
  2. 你不想每次都把請求返回的結(jié)果轉(zhuǎn)成Java Bean,然后一個個字段去校驗,你希望直接校驗json字符串?沒問題,Spring Boot支持你這樣做: Auto-configured JSON tests
  3. 你寫了一個很復(fù)雜的Dao操作,想要對它進行單元測試?這也沒問題: Auto-configured Data JPA tests

Spring Boot為我們寫好測試用例、用好TDD提供了非常方便的框架,我們只需盡情去寫測試用例,盡情去TDD就好了。

再談TDD

這篇文章雖然是在談如何在Spring Boot中使用TDD寫高質(zhì)量的接口,但是從這樣一個例子中,我們也看到了TDD的很多好處:

  1. 讓你開發(fā)時充滿成就感:你寫代碼就是為了讓原本fail的測試用例通過,這讓你寫代碼時更加具有目標(biāo),同時也讓你的代碼好壞具有可以量化的指標(biāo)。

  2. 促進整潔的代碼:正如之前提到的,TDD讓我們從測試用例的角度出發(fā),思考如何寫出方便測試的代碼,方便測試的代碼,往往是符合單一職責(zé)的。

  3. 提高開發(fā)的效率:我身邊很多不寫測試用例的同事,每次一修改代碼,就把代碼編譯成class文件放到環(huán)境上,然后重啟、測試,這對于小項目來說尚可接受,但是對于一個大的項目,重啟往往需要花費很多時間,而且在我接觸到的一個Docker容器化的項目中,還不支持用單個class文件替換的方式去打補丁,每次替換都需要替換整個服務(wù)的代碼,嗯,然后每次替換、驗證、發(fā)現(xiàn)新Bug,再修改、替換、驗證... 這樣開發(fā)的效率自然不高。但是如果你已經(jīng)在本地環(huán)境上寫了充分的測試用例,那么代碼一把布上去,一把驗證通過,也就是家常便飯了的事了。

  4. 提高了測試用例的代碼覆蓋率:這幾乎無需解釋,先寫測試用例,再寫產(chǎn)品代碼,和先寫產(chǎn)品代碼,后來由于某種政治任務(wù)的壓迫,再來補測試用例,前者寫出來的測試用例質(zhì)量一定更高,測試的覆蓋率也一定更大。而代碼覆蓋率的提高,將帶給我們下面兩個個超級好處:

    • 方便重構(gòu):有多少次你看到一份寫的很爛的代碼,卻又不敢重構(gòu),生怕把原有的功能搞壞?有了高覆蓋率的測試用例,你就不再擔(dān)心這個了,重構(gòu)后,只需要跑一遍用例,就知道你的重構(gòu)有沒有影響原先的功能。
    • 測試即文檔:測試用例是最好的文檔,文檔會撒謊、注釋會撒謊,但是代碼不會。

寫完這篇文章,結(jié)合之前那篇《如何說服你的同事使用TDD》,嗯,這下我真的非常有信心,可以說服你們使用TDD,說服你們?nèi)フf服你們同事,使用TDD了。

參考

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,963評論 6 542
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,348評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,083評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,706評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 72,442評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,802評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,795評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,983評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,542評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,287評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,486評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,030評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,710評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,116評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,412評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,224評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 48,462評論 2 378

推薦閱讀更多精彩內(nèi)容