什麼是TDD

我 : Test-Driven Development 測試驅動開發
某講師 : 你這只是翻譯

在深入了解TDD之前希望讀者對於Clean Code 以及 Clean Architecture先有基礎的概念。

日常中一定發生過以下的情境

我們準備重構(Refactor)自己已經寫好的程式碼,因為Clean老師說你的程式碼中有壞味道(Bad Smell)。為了滿足Clean老師的要求,你把已經完成的程式碼改了又改,最後簡單做幾個功能測試便提交(Commit)了。
過一段時間負責QA的人拿著程式碼跑來告訴你,這個功能壞了。經過沒日沒夜檢查之後發現是上一次重構遺漏掉一個小地方,於是我們再度修改程式碼並簡單做了功能測試之後又提交了。
隨著系統越來越複雜,BUG可以躲藏的地方也越來越多,維護週期也越來越長,最後再也沒有人能維護這個案子了。

TDD (Test-Driven Development) 與 DDD (Domain-Driven Design)

系統變得複雜、維護就會變難,
維護越難、開發者就越不敢更動原本的架構
於是乎系統就變得更複雜,這簡直是個死循環

突然一天,客戶要求系統加一個新功能,你看著萬般複雜的程式碼,面有難色地估了下工時。
最後告訴客戶 : 系統太複雜了,要加新功能有困難,不如我們重做一個?

於是團隊決定"導入" DDD,把程式碼放在他應在的地方,該抽介面的地方也抽介面。整個系統看起來可愛了多,至少我們在尋找某段程式碼變容易了。

講到這裡,不知道有沒有人發現,即使我們導入DDD,凌亂的程式碼依然凌亂,我們依然不得不重構這些程式碼。
於是又回到了一開始的情況,開發者不斷在重構->改壞->修復中循環。

當然我們也可以選擇不重構,但終有一天,凌亂的程式碼會影響到架構也變凌亂。於是就有人開始懷疑,為何導入DDD之後系統依然難以維護、DDD沒有用諸如此類言論。

先說結論

一定得重構!!!
一定得重構!!!
一定得重構!!!

既然我們無論如何得重構,什麼時候重構?
開發前?  白癡嗎 連Code都沒有要重構什麼
開發中?  你確定你知道重構的定義?
提交後?  迷之聲 : 這個能跑就不要動

顯然重構必須得在開發完做,而且是在提交到生產環境前就要做。

程式改壞了怎麼辦

昨天明明還好好的                       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 Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

當測試只需要花15秒,測試就可以天天做,或是Ctrl+S存檔之後順手跑一個測試,QA人員也可以騰出手來去做更高階的測試,因為有測試保護,開發人員也可以更放心的重構程式碼反正重構完之後跑個測試也不過區區15秒。

測試金字塔

提到測試就不得不提到測試金字塔,越底層的含義是被執行的次數越多次,換言之,假使我們今天說要測試,最優先被執行的應該是單元測試,次優先則是整合測試或是功能測試,最後才是E2E的端對端測試。

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Test-Driven Development 測試驅動開發

前面講了這麼多廢話,終於輪到TDD登場了,顧名思義TDD開發模式是一種以測試為主體的開發模式,標榜好測就會好用
TDD遵循著以下的流程進行開發 :

  • 以Use Case為基礎撰寫測試
  • 撰寫程式碼使測試通過
  • 重構

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

此外,測試通過的情形被稱為綠燈,沒有通過則是紅燈

Mockito 測試框架

Spring Boot有內建的測試用框架,常用到的功能有 :

  • 模擬MVC環境,測試API請求回應是否正常
  • 可以用MockBean替代掉Spring IoC容器內的Bean

還有很多功能不一一贅述,有興趣的人可以自己去找文章看。

TDD 開發案例

假設今天要開發一個登入功能,UseCase如下

  • 輸入正確帳號密碼要可以登入成功
  • 帳號錯誤要提醒用戶去註冊
  • 密碼錯誤不可以登入
  • 以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 Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

此時處於紅燈的狀態,可以開始撰寫程式碼。
如果這裡有人是綠燈代表測試有問題

  • 撰寫程式碼使測試通過
@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 Not Showing Possible Reasons
    • The image was uploaded to a note which you don't have access to
    • The note which the image was originally uploaded to has been deleted
    Learn More →

綠燈就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

一樣,綠燈就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

綠燈就Commit

如此一來就完成了一個TDD的Cycle了,感謝各位的閱讀。