## 什麼是TDD ``` 我 : Test-Driven Development 測試驅動開發 某講師 : 你這只是翻譯 ``` 在深入了解TDD之前希望讀者對於Clean Code 以及 Clean Architecture先有基礎的概念。 日常中一定發生過以下的情境 :::success 我們準備重構(Refactor)自己已經寫好的程式碼,因為Clean老師說你的程式碼中有壞味道(Bad Smell)。為了滿足Clean老師的要求,你把已經完成的程式碼改了又改,最後簡單做幾個功能測試便提交(Commit)了。 過一段時間負責QA的人拿著程式碼跑來告訴你,這個功能壞了。經過沒日沒夜檢查之後發現是上一次重構遺漏掉一個小地方,於是我們再度修改程式碼並簡單做了功能測試之後又提交了。 隨著系統越來越複雜,BUG可以躲藏的地方也越來越多,維護週期也越來越長,最後再也沒有人能維護這個案子了。 ::: ### TDD (Test-Driven Development) 與 DDD (Domain-Driven Design) :::danger 系統變得複雜、維護就會變難, 維護越難、開發者就越不敢更動原本的架構 於是乎系統就變得更複雜,這簡直是個死循環 ::: 突然一天,客戶要求系統加一個新功能,你看著萬般複雜的程式碼,面有難色地估了下工時。 最後告訴客戶 : 系統太複雜了,要加新功能有困難,不如我們重做一個? 於是團隊決定"導入" DDD,把程式碼放在他應在的地方,該抽介面的地方也抽介面。整個系統看起來可愛了多,至少我們在尋找某段程式碼變容易了。 講到這裡,不知道有沒有人發現,即使我們導入DDD,凌亂的程式碼依然凌亂,我們依然不得不重構這些程式碼。 於是又回到了一開始的情況,開發者不斷在重構->改壞->修復中循環。 當然我們也可以選擇不重構,但終有一天,凌亂的程式碼會影響到架構也變凌亂。於是就有人開始懷疑,為何導入DDD之後系統依然難以維護、DDD沒有用...諸如此類言論。 ## 先說結論 ``` 一定得重構!!! 一定得重構!!! 一定得重構!!! ``` 既然我們無論如何得重構,什麼時候重構? 開發前?  ~~白癡嗎 連Code都沒有要重構什麼~~ 開發中?  你確定你知道重構的定義? 提交後?  迷之聲 : 這個能跑就不要動 顯然重構必須得在開發完做,而且是在提交到生產環境前就要做。 ### 程式改壞了怎麼辦 :::warning 昨天明明還好好的                       By Json 2023.??.?? ::: 程式沒改就會壞了,有改更會壞,所以需要測試。 以往我們習慣完成A功能之後就簡單對A功能做一下測試,之後又完成B功能就測試B功能,假設A功能跟B功能可能共用X元件,你為了滿足B功能對X元件做了某種程度的擴充或修改。 熟門熟路的人都知道,這種情況都得做回歸測試,於是A功能五個Case加上B功能四個Case總共九個測試。每次修改都得做九次測試,第一、二次開發人員可能還會自己做,測試到第三次之後發現自己的時間越來越不夠用,這類測試就很順其自然的丟給QA人員去做了。 於是過幾天,QA人員又找上門的故事再度重演。 講了這麼多,結論就是要測試,而且要將測試自動化。 以Java為例 : 可以使用Junit工具 下圖的案例只花了15秒的時間便完成了9個Case的測試 (由於大部分時間都花在連線Spanner,一般專案花的時間會更短) ![image](https://hackmd.io/_uploads/SymWncK3p.png) 當測試只需要花15秒,測試就可以天天做,或是Ctrl+S存檔之後順手跑一個測試,QA人員也可以騰出手來去做更高階的測試,因為有測試保護,開發人員也可以更放心的重構程式碼......反正重構完之後跑個測試也不過區區15秒。 ## 測試金字塔 提到測試就不得不提到測試金字塔,越底層的含義是被執行的次數越多次,換言之,假使我們今天說要測試,最優先被執行的應該是單元測試,次優先則是整合測試或是功能測試,最後才是E2E的端對端測試。 ![image](https://hackmd.io/_uploads/HkjiHsKhT.png) ## Test-Driven Development 測試驅動開發 前面講了這麼多廢話,終於輪到TDD登場了,顧名思義TDD開發模式是一種以測試為主體的開發模式,標榜==好測就會好用==。 TDD遵循著以下的流程進行開發 : * 以Use Case為基礎撰寫測試 * 撰寫程式碼使測試通過 * 重構 ![image](https://hackmd.io/_uploads/Skaisfih6.png) 此外,測試通過的情形被稱為==綠燈==,沒有通過則是==紅燈==。 ### Mockito 測試框架 Spring Boot有內建的測試用框架,常用到的功能有 : * 模擬MVC環境,測試API請求回應是否正常 * 可以用MockBean替代掉Spring IoC容器內的Bean 還有很多功能不一一贅述,有興趣的人可以自己去找文章看。 ### TDD 開發案例 假設今天要開發一個登入功能,UseCase如下 :::info * 輸入正確帳號密碼要可以登入成功 * 帳號錯誤要提醒用戶去註冊 * 密碼錯誤不可以登入 ::: * 以Use Case為基礎撰寫測試 程式碼如下 : ``` @SpringBootTest(classes = BackEndTempleteApplication.class) @AutoConfigureMockMvc public class LoginTest { private final ObjectMapper objectMapper = new ObjectMapper(); @Autowired LoginService svc; @Autowired MockMvc mvc; @MockBean UserRepository userRepo; @Test void user_account_and_password_is_right() throws Exception { var postRequest = MockMvcRequestBuilders.post("/login/userLogin") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(new UserModel("username", "password"))); Mockito.when(userRepo.findByNameAndPassword(any(String.class),any(String.class))) .thenReturn(List.of(new User("name","password",new Role()) .setId(UUID.randomUUID()))); mvc.perform(postRequest) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()); } @Test void user_account_is_not_exist() throws Exception { var postRequest = MockMvcRequestBuilders.post("/login/userLogin") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(new UserModel("username", "password"))); Mockito.when(userRepo.findByNameAndPassword(any(String.class),any(String.class))) .thenReturn(List.of()); mvc.perform(postRequest) .andExpect(status().isOk()) .andExpect(jsonPath("$.message",containsString("exist"))); } @Test void user_password_is_wrong() throws Exception { var postRequest = MockMvcRequestBuilders.post("/login/userLogin") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(new UserModel("username", "wrongpassword"))); Mockito.when(userRepo.findByNameAndPassword(any(String.class), any(String.class))) .thenReturn(List.of(new User("name", "password", new Role()) .setId(UUID.randomUUID()))); mvc.perform(postRequest) .andExpect(status().isOk()) .andExpect(jsonPath("$.message",containsString("wrong"))); } } ``` 看起來有點凌亂,等等我們會找機會再來重構這段測試 ![image](https://hackmd.io/_uploads/SkPDPeihp.png) 此時處於==紅燈==的狀態,可以開始撰寫程式碼。 如果這裡有人是綠燈...代表測試有問題 * 撰寫程式碼使測試通過 ``` @Override public LoginDetailData userLogin(UserModel user) throws Exception { var list = userRepo.findByName(user.getUsername()); if(list.size() != 1) throw new UserNotExistException(); var realUser = list.get(0); if(user.getPassword().equals(realUser.getPassword())) { var returnData = new LoginDetailData(); returnData.setId(realUser.getId()); returnData.setName(realUser.getName()); returnData.setEmail(realUser.getMail()); returnData.setRole(realUser.getRole()); returnData.setHomePage(realUser.getRole().getHomePage()); returnData.setFunctions(realUser.getRole().getFunctions() .stream() .map(f -> f.getValue()) .collect(Collectors.toSet())); return returnData; else throw new PasswordWrongException(); } ``` 簡單編寫一下登入的業務邏輯 * 如果業務邏輯稍微複雜,在寫程式碼的途中可能經由測試發現當初測試寫錯。 * 切記,程式碼跟單元測試不能同時修改,一次只能改一邊。 ![image](https://hackmd.io/_uploads/H10-vbon6.png) ==綠燈就Commit==,然後開始重構。 * 重構 根據一次改一邊原則,我決定先重構單元測試 : ``` @Test void user_account_and_password_is_right() throws Exception { let_username_is_exist(); mvc.perform(postLogin("username", "password")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()); } @Test void user_account_is_not_exist() throws Exception { let_username_is_not_exist(); mvc.perform(postLogin("username", "password")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message",containsString("exist"))); } @Test void user_password_is_wrong() throws Exception { let_username_is_exist(); mvc.perform(postLogin("username", "wrongpassword")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message",containsString("wrong"))); } ``` 把一些重覆的程式碼抽出來做成一個Method,然後取個容易懂的名字。 ![image](https://hackmd.io/_uploads/S1hrqWi3T.png) 一樣,==綠燈就Commit==。 然後換重構程式碼 : ``` @Override public LoginDetailData userLogin(UserModel user) throws Exception { var realUser = getRealUser(user.getUsername()); if(user.isValid(realUser.getPassword())) return new LoginDetailData(realUser); else throw new PasswordWrongException(); } ``` ![image](https://hackmd.io/_uploads/ryRGWzi2p.png) ==綠燈就Commit==。 如此一來就完成了一個TDD的Cycle了,感謝各位的閱讀。