如何有效撰寫高品質單元測試

Z-xuan Hong
20 min readJan 30, 2021

--

參考書籍

當大家修改程式後,都是如何確認程式正常無誤呢?如果你的回答是動動小手點點看每個功能的話,相信這篇文章能為你帶來很大的幫助,幫助你正常上下班。

本篇文章將根據撰寫單元測試4年多的經驗來介紹單元測試,並且提出單元測試常見的問題與相對應的解決辦法,希望讓你了解:

  • 單元測試基本
  • 單元測試三大特性
  • 單元測試品質
  • 單元測試派別
  • 單元測試Pattern
  • 單元測試牛刀小試

單元測試基本

什麼是單元測試呢?單元測試的目標是什麼呢?單元測試的好處是什麼呢?此章節將會回答上述問題。

什麼是單元測試

所謂的單元測試,指的是針對軟體中的個別元件進行測試,翻成白話文的話,在物件導向中單元就是類別 (Class)、程序式導向中就是函數 (Function)。

所謂的測試就是呼叫元件的函數並給予準備好的輸入資料 (Input Data),在驗證元件執行後的狀態或是元件與相依元件的互動。 (關於驗證方式跟單元測試派系有關,稍後章節會詳細介紹)

以下為使用Junit測試框架撰寫的範例程式碼:

@Test
void sum_of_two_numbers() {
// Arrange
int a = 1;
int b = 2;
// Act
int sum = add(a, b);
// Assert
assertEquals(3, sum);
}

private int add(int a, int b) {
return a + b;
}

上述程式碼做了幾件事件:

  • 準備兩個整數型態的變數,分別是a跟b
  • 呼叫add函數並且傳入變數a跟b
  • 驗證函數的輸出sum結果為3

這三個步驟在單元測試中稱之為3A,依序為

  • Arrange:準備測試資料
  • Act:執行元件
  • Assert:驗證執行結果

而在物件導向中,Arrange通常用來產生待測試物件並且準備好待側物件的狀態,與程序式導向準備參數或是資料結構有所不同,本篇文章將針對物件導向進行討論。

經過上述簡單介紹,相信你對單元測試有基本的了解,接下來我們要探討單元測試的目標。

單元測試目標

單元測試最重要的目標為:讓我們能以穩定的步伐(sustainable pace)進行開發。

你可能會問穩定的步伐為什麼這麼重要?這是因為系統隨著功能的增加,在修改後我們需要驗證的功能也越來越多,如果透過手動的方式來驗證不僅容易遺漏產生Bug、手動花的時間也會越來越多,因為很難估算修改的成本,開發也就越來越不穩定、也容易延期或是腰斬。

而單元測試就像一張保護網,圍繞整個系統,當修改後我們能夠在幾秒內得知目前系統的狀態是否有誤,能讓開發的過程更加穩定。

單元測試好處是什麼

  • 回歸測試 (Regression Test):確保在修改後系統的狀態有無改變,就像是一張保護網。
  • 設計回饋 (Design Feedback):當系統耦合性過高、太過複雜會導致元件難以測試,因此當我們的元件難以測試時,便是測試給我們的提示:元件太過複雜、耦合性太高。我們在根據這些回饋進行改善,讓系統的耦合、複雜度降低。
  • 開發流程 (Development Workflow):當修改程式後,只需要幾秒鐘便能得知系統狀態正確性,能夠大幅地提升開發效率。試著比較看看修改要20分鐘後才能知道統狀態正確性、修改後只需要幾秒鐘便能知道系統狀態統狀態正確性的差別。

單元測試三大特性

通常要評估測試是否為單元測試需要滿足以下基本特性:

  • Unit of behavior:測試應該要針對元件提供給客戶端的行為進行驗證。不應該針對元件內部進行驗證,此舉會導致測試與待測元件耦合過高。(關於unit大小定義跟單元測試派系有關,稍後章節會詳細介紹)
  • Fast:測試速度要非常快,大約在10ms~100ms之間。因為當測試數量一大,如果執行速度慢,會導致開發者執行測試的次數降低,便不能達到加速開發流程的優點。
  • Isolation:測試與測試之間不能夠有耦合性,不管測試執行順序為何,都要能夠通過。如果測試之間有耦合性,會導致測試不穩定,開發人員容易忽略錯誤訊息,便不能達到回歸測試的優點。

上述如果有一點沒辦法滿足,便不是單元測試,常見違反單元測試定義的有:與資料庫溝通、與檔案系統溝通、從UI元件測試、從Restful API測試等等,皆為整合測試。

單元測試品質

測試品質跟撰寫測試一樣重要,還記得一開始提到單元測試的目標為穩定的步伐嗎,試想如果單元測試品質低劣,可讀性差、難以撰寫、元件行為正確下卻報錯,品質低劣的測試跟不寫測試基本上沒什麼不同,沒辦法達到穩定步伐的目標。

以下幾點能夠用來評估單元測試的品質:

  • 抓出錯誤的能力 (Regression Error):單元測試如果在元件狀態不正確卻通過的情況底下,則代表測試驗證邏輯有缺陷,應盡快改善。
  • 可讀性 (Readability):單元測試相較於Production Code,可讀性應該要非常高,必須讓開發者在短時間了解待側元件API的使用方式待側元件提供什麼行為給客戶端。
  • 忍受重構的根性 (Resistance to Refactoring):重構能夠幫助我們改善軟體設計,在沒改變系統行為的情況底下,單元測試不應該報錯。如果在重構且不影響行為的情況下,如果發生幾百個測試報錯,一樣沒辦法達到穩定開發的目標
  • 維護性 (Maintainability):為了測試某元件A,我們需要產生A的實例 (Instance),如果A還有其他相依元件,我們也需要產生A相依元件的實例,當其他相依元件又有其他相依元件時,我們也需要產生這些元件的實例,以此類推。上述準備元件狀態的程式碼又稱之為Test Fixture,測試的Test Fixture應該越簡單越好,當Test Fixture很複雜、夾雜的Mock工具,會導致測試維護性降低,導致最後沒人想寫測試,無法達到穩定開發的目標。

單元測試派別

經過上面的介紹,相信你對於單元測試的特性、品質有了更深層的理解,而關於單元測試特性的解讀,其實有很大的爭議,因此又衍生出了兩種派系,分別為:

Classic Type:

  • Unit of Behavior:透過a cluster of classes達到客戶端期望的行為,不會限制帶測類別的數量。
  • Isolation:測試彼此之間要隔離,執行的順序不會影響測試的結果。
  • Fast:執行速度快

London Type:

  • Unit of Behavior:unit代表一個類別,只針對一個類別進行測試。
  • Isolation:因為unit代表一個類別,其他相依元件需要使用Mock隔離,這邊的隔離指的是與其他類別隔離,與Classic的定義不同。
  • Fast:執行速度快

這兩種派系上,建議選擇Classic Type,以下透過單元測試品質的角度進行比較:

Regression Error:基本上兩種派系都能有效抓出錯誤。

Readability:

  • Classic Type:通常測試客戶端期望的行為,測試整體能夠表達要解決的問題 (Problem Domain),可讀性佳
  • London Type:通常針對一個類別進行測試,但一個類別通常沒辦法代表客戶端期望的行為,較難理解測試要解決什麼問題,可讀性較差。

Resistance to Refactoring:要能夠抵擋重構的衝擊,測試與待側元件之間的耦合需要越低越好。

  • Classic Type:驗證客戶端期望的行為,不會知道內部元件如何溝通,因此測試與待側元件的耦合非常低,重構實作細節變動時,測試不會因此失敗。
  • London Type:驗證一個類別的行為,並且使用Mock做Isolation,但是Mock通常需要知道待測元件如何呼叫依賴元件,這種資訊會導致測試與待測元件的耦合非常高,重構實作細節變動時,測試就會失敗。

Maintainability:上述提到過,Test Fixture越簡單,越好維護。

  • Classic Type:透過new keyword產生待測元件與其依賴元件的instance,不會使用Mock工具來產生實例,Test Fixture較為簡單,因此維護性較高。
  • London Type:透過new keyword產生待測元件的instance、Mock工具產生依賴元件instance,Test Fixture較為複雜,因此維護性較低。

以下為Classic Type與London Type的範例程式碼,你應該能感受到兩者的差別:

Classic Type:

@Test
public void calculate_total_price_of_order() {
// Arrange
String name = "Classic Order";
int quantity = 2;
double price = 399;
Order order = new Order(name);
order.addItem(Item.of(quantity, Product.of("Classic", price)));
// Act
double totalPrice = order.getTotalPrice();
// Assert
assertEquals(798.0, totalPrice, .001);
}

London Type:

@Test
public void calculate_total_price_of_order() {
// Arrange
String name = "London Order";
Order order = new Order(name);
Item mockItem = Mockito.mock(Item.class);
when(
mockItem.getSubTotal()
).
thenReturn(798.0);
order.addItem(mockItem);
// Act
double totalPrice = order.getTotalPrice();
// Assert
assertEquals(798.0, totalPrice, .001);
}

上述最大的差別在於:

  • Classic Type只知道Order類別提供getTotalPrice行為,測試與待側元件耦合低。Classic Type忽略實作細節的特性,也讓我們很好TDD,因為TDD著重於先釐清Problem Space (Test),在實作Solution Space (Class),我們只需要設計元件Public API即可,不需要知道元件與其依賴元件的實作細節,達到Defer Decision的效果。
  • London Type不但知道Order類別getTotalPrice行為,也知道Order會呼叫Item的getSubTotal,這些資訊造成測試與待測元件耦合過高。因為實作細節暴露的關係,導致我們很難TDD,為了撰寫測試,需要把元件與其依賴元件都撰寫成Mock,代表我們需要提前了解內部實作細節,沒辦法達到Defer Decision的效果。

單元測試Pattern

以下列出常見的Pattern,能夠幫助測試更容易撰寫、維護。

Object Mother:封裝基本待測元件的Test Fixture,讓測試呼叫,達到重複利用、降低重複的效果。

Builder:結合Object Mother,讓測試有彈性的產生Test Fixture,降低Object Mother的複雜度。

Custom Assertion:封裝procedure assertion,提供測試High Level Assertion API,描述測試要驗證什麼問題,提高測試的可讀性。

單元測試牛刀小試

此章節首先提出品質較低的單元測試,透過Classic Type的設計方式、單元測試Pattern來提升測試的品質。

以下為範例程式:

@Test
public void map_from_order_dto_to_order_entity() {
// Arrange
List<ItemDto> itemDtoList = new ArrayList<>();
ItemDto itemDto = new ItemDto(2, "Apple", 12.0);
itemDtoList.add(itemDto);
OrderDto orderDto = new OrderDto(itemDtoList);

ItemMapper mockItemMapper = Mockito.mock(ItemMapper.class);
when(
mockItemMapper.convertFrom(mockItemDto)
).
thenReturn(itemDto);

OrderMapper orderMapper = new OrderMapper(itemMapper);
// Act
Order order = orderMapper.convertFrom(orderDto);
// Assert
assertEquals(1, order.itemSize());
assertEquals(24.0, order.getTotalPrice());
List<Item> items = order.getItems();
assertEquals(1, items..size());
assertEquals(2, items.get(0).getQuantity());
assertEquals("Apple", items.get(0).getProductName());
assertEquals(12.0, items.get(0).getProductPrice());
}

上述測試有以下幾點問題:

  • 測試可讀性不好,Test Fixture太複雜
  • 測試與OrderMapper高度耦合,因為測試知道OrderMapper會呼叫ItemMapper的convertFrom函數
  • 驗證太過複雜,沒辦法簡單了解測試要驗證什麼問題

以下為重構過程:

  • 使用Object Mother Pattern封裝OrderDto的生成
@Test
public void map_from_order_dto_to_order_entity() {
// Arrange
OrderDto orderDto = OrderDtoObjectMother.createOrderDto();

ItemMapper mockItemMapper = Mockito.mock(ItemMapper.class);
when(
mockItemMapper.convertFrom(mockItemDto)
).
thenReturn(itemDto);

OrderMapper orderMapper = new OrderMapper(itemMapper);
// Act
Order order = orderMapper.convertFrom(orderDto);
// Assert
assertEquals(1, order.itemSize());
assertEquals(24.0, order.getTotalPrice());
List<Item> items = order.getItems();
assertEquals(1, items.size());
assertEquals(2, items.getQuantity());
assertEquals("Apple", items.getProductName());
assertEquals(12.0, items.getProductPrice());
}
static class OrderDtoObjectMother {
static OrderDto createOrderDto() {
List<ItemDto> itemDtoList = new ArrayList<>();
ItemDto itemDto = new ItemDto(2, "Apple", 12.0);
itemDtoList.add(itemDto);
return new OrderDto(itemDtoList);
}
}
  • 使用Custom Assertion Pattern封裝驗證邏輯,提供High Level Assertion API,增加可讀性。
@Test
public void map_from_order_dto_to_order_entity() {
// Arrange
OrderDto orderDto = OrderDtoObjectMother.createOrderDto();

ItemMapper mockItemMapper = Mockito.mock(ItemMapper.class);
when(
mockItemMapper.convertFrom(mockItemDto)
).
thenReturn(itemDto);

OrderMapper orderMapper = new OrderMapper(itemMapper);
// Act
Order order = orderMapper.convertFrom(orderDto);
// Assert
assertOrderMappingSuccessfully(order, 1, 24.0);

List<ItemValue> itemValues = createItemValue();
assertItemMappingSuccessfully(order, itemValues);
}
private void assertOrderMappingSuccessfully(Order order, int itemSize, double totalPrice) {
assertEquals(itemSize, order.itemSize());
assertEquals(totalPrice, order.getTotalPrice());
}

private List<ItemValue> createItemValue() {
List<ItemValue> result = new ArrayList<>();
result.add(ItemValue.of(2, "Apple", 12.0));
return result;
}

private void assertItemMappingSuccessfully(Order order, List<ItemValue> itemValues) {
List<Item> items = order.getItems();
for (int i = 0; i < items.size(); i++) {
Item item = items.get(i);
ItemValue itemValue = itemValues.get(i);
assertEquals(itemValue.quantity, item.getQuantity());
assertEquals(itemValue.productName, item.getProductName());
assertEquals(itemValue.productPrice, item.getProductPrice());
}
}

private static class ItemValue {
int quantity;
String productName;
double productPrice;

ItemValue(int quantity, String productName, double productPrice) {
this.quantity = quantity;
this.productName = productName;
this.productPrice = productPrice;
}

static ItemValue of(int quantity, String productName, double productPrice) {
return new ItemValue(quantity, productName, productPrice);
}
}
  • 將原本London Type的設計替換成Classic Type,把ItemMapper Mock移除,只透過new keyword產生其instance,並且注入到OrderMapper。
@Test
public void map_from_order_dto_to_order_entity() {
// Arrange
OrderDto orderDto = OrderDtoObjectMother.createOrderDto();
ItemMapper itemMapper = new ItemMapper();
OrderMapper orderMapper = new OrderMapper(itemMapper);
// Act
Order order = orderMapper.convertFrom(orderDto);
// Assert
assertOrderMappingSuccessfully(order, 1, 24.0);

List<ItemValue> itemValues = createItemValue();
assertItemMappingSuccessfully(order, itemValues);
}
// 以下省略
  • 最後針對OrderMapper待測物件的生成使用Object Mother,封裝內部結構 (ItemMapper),因為對於此測試而言,ItemMapper不是重要的資訊,透過封裝隱藏不必要資訊能夠增加測試可讀性。
@Test
public void map_from_order_dto_to_order_entity() {
// Arrange
OrderDto orderDto = OrderDtoObjectMother.createOrderDto();
OrderMapper orderMapper = OrderMapperObjectMother.createOrderMapper();
// Act
Order order = orderMapper.convertFrom(orderDto);
// Assert
assertOrderMappingSuccessfully(order, 1, 24.0);

List<ItemValue> itemValues = createItemValue();
assertItemMappingSuccessfully(order, itemValues);
}
static class OrderMapperObjectMother {
static OrderMapper createOrderMapper() {
ItemMapper itemMapper = new ItemMapper();
return new OrderMapper(itemMapper);
}
}
// 以下省略

重構完後的測試有以下優點:

  • 透過Object Mother封裝待測物件生成,能有效降低測試複雜度、提升可讀性、增加維護性。
  • 透過Classic Type方式進行測試能有效降低OrderMapper之耦合。
  • 透過Custom Assertion提供High Level Assertion API,增加可讀性。

結論:單元測試需要能幫助達到穩定開發目標,因此需要低耦合、高可讀性、高維護性。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

熱愛軟體開發、物件導向、自動化測試、框架設計,擅長透過軟工幫助企業達到成本最小化、價值最大化。單元測試企業內訓、導入請私訊信箱:t107598042@ntut.org.tw。

No responses yet