1 此事已有定論
Robert C.Martin在他的程式員的職業素養一書中明确提出:
關于TDD,也就是測試驅動開發
此事已有定論,無須争議
筆者對此深以為然,但這并不是信口雌黃的結論,也不是因為誰說了就認定他是對的,這是基于筆者自己在TDD上的一些實踐的經驗得出來的結論。而且筆者關于TDD的一些細節,可能也與Robert C.Martin的看法并不一緻,這一點後續筆者會再在專門闡述TDD的文章中再來說明。但整體上筆者對TDD是深信不疑的。
2 我與TDD
這幾年,我在工作上的重心其實并不在于後端開發,而更多的是在移動端與基于TypeScript與React的前端及桌面端的一些開發上面。
很可惜的是,我剛開始做Android時,屬于初次入門做移動端,還沒有這種要實施TDD的心态,而後又負責iOS,但是接手一個現成的代碼,并不是從頭開始,是以也壓根沒有想過實施TDD。
而2020我在做基于TypeScript與React桌面端的開發時,雖然成功把一個領域驅動思想的風格應用到這個項目中,但沒有實施TDD,雖然知道前端有jest這個測試架構,但考慮到時間及因為第一次嘗試使用前端技術棧,對技術的掌握的成熟度等因素,也并未将TDD實施到這上面。
但有幸的是,過去兩年,分别在19年公司的一個項目及20年自己的一個業餘項目中嘗試完整的應用了TDD的做法,是以也基于此得出了一些心得。也堅定了自己對TDD的信念。
2.1 TDD實踐項目經驗
2.1.1 2019年的TDD實踐
19年時,當時在公司曾經有一段時間負責過一個技術中台的項目,因為這個項目并不大,當時公司是讓筆者一個人負責這個項目的後端開發。那個時候筆者剛剛從移動端開發中出來,有些時間沒搞過後端開發了。是以在開發時,也考慮過該用什麼樣的技術及怎麼來做。後面還是選擇了Spring Boot來完成這個項目,因為畢竟Spring Boot的穩定性及可靠性都是可以信任的。
由于當時是筆者一個人負責,在技術上自由操作的範圍較大,也不用考慮其它同僚或團隊的人的接受程度 ,是以第一次嘗試完整的将TDD應用這個項目。這是一個好的開始。取得了不錯的效果。

如圖所示,筆者在19年項目中單元測試覆寫率約為78.8%
2.1.2 2020年的TDD實踐
20年時,由于需要為自己的家人開發一個系統實際應用到公司業務上,是以對品質更加尤為關注,在19年的經驗之上,再次将TDD連同領域驅動設計理念一并應用到這個項目。整體感覺還是非常好的。
而且這一次,自己對各方面的品質要求更高。
2.2 實踐TDD的一些心得
雖然項目不多,每年隻搞了一個,但也已經對我的程式設計理念産生了重大的影響,至此為止,我已深信TDD的作用是非常有效,而且也是一個優秀的程式員必須也應該去做到的。
接下來說一些自己的心得
2.2.1 TDD是加快編碼的唯一方式
其實做為程式員的我們遇到的一個最大問題,就是技術的一個最大沖突點。
這個沖突點就是:事是我們在做,但很多時候做決策的并不是我們。
相信大家都會或多或少的遇到一些場景,比如客戶,上司,或項目經理,要不就是産品經理,我把這些人統稱為技術門外漢,這群人并不了解技術,但又時時刻刻能替我們做決策。當然,關于産品形态或其它方面由他們來做決策無可厚非,但很多時候一個在于技術上需要多久這個他們認為他們懂但實質上并沒有太多概念的也能為我們做決策。
> 這個功能,2周就可以完成了,也必須完成
雖然程式員感覺實質上2周不能完成,但通常這種時候,程式員大多說不上話。技術不可能比銷售,對客戶的承諾或其它任何可以說的上的理由重要。這是程式員職業生涯中始終要面對并且無法逃避的一個困境。
而大多在這種時候,很多程式員下意識的決策就是
犧牲代碼不可見的品質
以加快
代碼可見的品質
的進度。
也隻有我們技術人員能了解,同樣一個看起來功能運作起來差不多的兩份代碼,在不可見的品質方面能相差巨大,之是以很多項目到後面越來越難以推進的關鍵原因也就是不可見的品質上的不斷犧牲以至于越來越難以維系所導緻的。
而TDD是唯一可以解決和改善這個問題的方式,但可惜的是,我發現國内大部分程式員壓根不來這一套,很多程式員自己都認同一個觀點:
> 編寫單元測試,會延長功能完成所需要的時間
雖然我認為這些程式員很可能壓根沒有實施過,是僅憑感覺這麼說的。但在這種理念下,連程式員自己都這麼認為,那更不可能讓那群技術門外漢來認同這個理念,是以單元測試這個事壓根從前到後無人在意。
但實際上,從筆者的實際經驗來看,這是個壓根不成立的結論。事實上,筆者發現,沒有比
編寫單元測試
更好的方式來加快代碼的開發。而且筆者認為一個優秀的程式員隻需要少數時間,就能适應并且快速熟悉單元測試的工作。
當然,這篇文章并不是詳細闡述TDD的,是以這個點到此為止,筆者後續會就TDD再來專門闡述為什麼TDD會加快代碼開發。
2.2.2 保持單元測試足夠小并且快
一個項目或産品,完整的測試包括很多元度,包括單元測試,內建測試,專業的黑白盒測試,性能壓力測試等。那在這其中,單元測試的作用很明顯,它是程式員自己驗證自己代碼的一種方式,它需要區分開來其它幾種測試,要保持足夠小而且快。
如果我們項目或産品比喻成建房子,那單元測試的作用就是保證每一塊磚的品質,這就是單元測試的作用。用單元測試來保證每一塊磚的品質,才有可能有後面的好的房子的可能性。
是以,單元重試的重點是關注你寫的每一個邏輯的正确性。用代碼來說就是保證你寫的每一個方法邏輯上的正确性。如果代碼中每一個方法的邏輯性都正确,才有可能有後面的把這些方法整合起來的品質保證可言,否則就如同房子建立在不可靠的磚上面,期望這種房子具有穩固性,簡單是天方夜談。
2.2.3 善用工具或技術架構
事實上,在編碼的一些技術選型中,我通常會把基于這種技術的單元測試是否容易編寫做為一個重要考量。
比如,在Java後端開發中,我通常會喜歡用JPA而不是Mybatis或其它JDBC等技術,雖然這些可能在性能上會稍有優勢,但從可維護性,以及支援單元測試的友善性上來說,顯然JPA更好。
我通常都會使用H2記憶體資料庫做為單元測試的标準資料庫,它的一個最大優點在于可以在任何環境,任何時間運作,而不需要一個類似MySQL的服務在那支援,而且我可以設定它每次執行一個單元測試資料庫都是全新的這種場景來測試。這樣可以盡量減少其它幹擾的情況下來測試自己的方法邏輯上的正确性。
> 如果你認為這種測試不能反應實際情況,實際上很可能是有很多資料的,那我就再闡述一次,測試包含很多元度,單元測試并不關注你擔心的這個次元上的事情。
還有一個重要的工具就是sonarqube,如同我上面兩個圖所示,這是一個很好的開源軟體,
在靈活軟體開發的理念中,結對程式設計是一個很重要的點,當然這個點基本我認為在國内不太可能實施,這種模式會讓上司覺得用2個技術人員做1個人的事,很難想像我們國内的決策者會認同這種搞法。
是以,我認為國内有兩種可替代的方案:
- 使用代碼審查來替代結對程式設計
- 使用Sonar這種自動化的工具
第一種在國内的很多環境下也不太好使。是以我基本隻考慮第2種,就是把自己的代碼放到Sonar上去跑,讓它來告訴我哪裡寫的不好,單元測試覆寫率是多少,哪些代碼沒有覆寫到等。雖然它的很多規則是死的,并不靈活,但至少也能在一定程度上檢測自己的代碼,特别是在單元測試上提醒自己是否做的足夠。
是以,如果你要應用TDD,一定需要這樣的工具。
2.2.4 學會使用Mock或樁
單元測試中還有一個非常重要的點,就是要學會Mock或樁,不同的語言上對這個的稱呼并不一緻,但大緻意思就是模拟一個實作的概念。很多時候,我們的代碼依賴一些第三方或我們在這個測試中不關心另一個次元的東西的實際運作情況,在單元測試的場景中,我們需要覆寫如下場景:
- 假設一個第三方功能傳回正常下,我們的代碼邏輯如何
- 又假設一個第三方功能傳回錯誤的情況下,我們的代碼邏輯如何
這種場景下,我們就需要Mock技術了。通常各種語言都會有類似的架構,你隻需去找就可以了。在後端Java系中,最著名的也就是筆者用到的,就是Mockito了。
void testDisableDocument(){
String json = "{\"name\":\"AAA.mp3\",\"mediaId\":\"AAA\"}";
ResponseEntity<baseresponse<documentdto>> responseEntity = restTemplate.exchange(baseUrl() + "/v1/documents",HttpMethod.POST,createHttpEntityFromString(json),new ParameterizedTypeReference<>() {});
Assertions.assertTrue(responseEntity.getStatusCode().is2xxSuccessful());
Assertions.assertTrue(Objects.requireNonNull(responseEntity.getBody()).getResult().getId() > 0);
//這是一個MOCK,假設目前使用者是超級管理者使用者,則可以禁用文檔
Mockito.when(applicationAuth.isSuper()).thenReturn(true);
ResponseEntity<baseresponse> deleteResponseEntity = restTemplate.exchange(baseUrl() + "/v1/documents/" + responseEntity.getBody().getResult().getId(), HttpMethod.DELETE, createEmptyHttpEntity(), new ParameterizedTypeReference<>() {});
Assertions.assertTrue(deleteResponseEntity.getStatusCode().is2xxSuccessful());
Assertions.assertTrue(Objects.requireNonNull(deleteResponseEntity.getBody()).isResultSuccess());
//這又是一個Mock,假設目前使用者不是超級管理者,則應該不能彬文檔
Mockito.when(applicationAuth.isSuper()).thenReturn(false);
deleteResponseEntity = restTemplate.exchange(baseUrl() + "/v1/documents/" + responseEntity.getBody().getResult().getId(), HttpMethod.DELETE, createEmptyHttpEntity(), new ParameterizedTypeReference<>() {});
Assertions.assertTrue(deleteResponseEntity.getStatusCode().is2xxSuccessful());
Assertions.assertFalse(Objects.requireNonNull(deleteResponseEntity.getBody()).isResultSuccess());
}
如上述代碼所示,靈活的使用Mock會讓你的單元測試更純粹,隻關注目前測試代碼的邏輯的正确性與否,屏蔽其它相關邏輯的影響。
它的另一個非常大的優勢是使單元測試非常小及純粹,如果沒有類似的Mock架構支撐,運作這個單元測試,我需要一個完整的權限體系的代碼跑起來支撐,這是一個非常麻煩的事,而且會讓單元測試變得很重而且不可控。
2.2.5 單元測試需要考慮正常及異常路徑
在早期一些時候,我寫單元測試基本隻寫正常路徑。什麼叫正常路徑?就是嘩嘩嘩一路運作下去,結果正常。比如新增一個使用者,最終新增成功。這就叫正常路徑。
後面我意識到了這樣的問題,這樣的覆寫率其實非常少,是以我就開始嘗試把不正常的路徑添加上去。這樣會得出更好的單元測試。
>
本代碼摘自筆者的myddd-vertx架構,基于Vert.x與Kotlin的響應式領域驅動實作
fun testRefreshToken(testContext: VertxTestContext){
executeWithTryCatch(testContext){
GlobalScope.launch {
//不正常的路徑,空使用者肯定不允許重新整理Token
try {
oAuth2Auth.refresh(null).await()
testContext.failNow("空使用者不能重新整理TOKEN")
}catch (e:Exception){
testContext.verify { Assertions.assertNotNull(e) }
}
//不正常的路徑,使用者不正确,不允許重新整理Token
try {
oAuth2Auth.refresh(OAuth2UserDTO()).await()
testContext.failNow("不正确的User不能重新整理TOKEN")
}catch (e:Exception){
testContext.verify { Assertions.assertNotNull(e) }
}
val createdClient = createClient().createClient().await()
val user = oAuth2Auth.authenticate(JsonObject().put("clientId",createdClient.clientId)
.put("clientSecret",createdClient.clientSecret)).await()
//正常路徑,使用正确的使用者可以重新整理Token
val token = oAuth2Auth.refresh(user).await()
testContext.verify {
Assertions.assertNotNull(token)
}
//不正常的路徑,refreshToken不對,不能重新整理Token
try {
val oauthUser = user as OAuth2UserDTO
oauthUser.tokenDTO?.refreshToken = UUID.randomUUID().toString()
oAuth2Auth.refresh(oauthUser).await()
testContext.failNow("不正确的refreshToken不能重新整理Token")
}catch (e:Exception){
testContext.verify { Assertions.assertNotNull(e) }
}
testContext.completeNow()
}
}
}
如上代碼所示,編寫單元測試需要考慮不同條件。
3 讓TDD驅動我的編碼
得益于幾個項目的實際經驗,并且效果較好,是以我現在對TDD非常認同。
是以,2021年開始,在TDD方面,我給自己的約定是:
自己的項目不能少于
80%
的覆寫率,而如果是公司的,則根據實際自己能控制的程度來決定。
以下展現我正在完善中的myddd-vertx,基于Vert.x與Kotlin的響應式領域驅動實作的相關資料.