TDD之到底測試如何幫助我們改善系統設計?
TDD (測試驅動開發)發展至今,已經過了幾十個年頭,從一開始大家爭論TDD的可行性,到現在TDD已經被認為是專業軟體工程師需要練習的一項技能。
TDD的好處在網路上已經有很多文章解釋過了 (我也不例外),每篇文章幾乎都會提到一點:TDD能幫助我們改善系統設計。
但這句話相信到現在還是很多人不太能理解。
為什麼呢?因為這句話背後的假設 (implicit assumption)非常多,今天就是要把這些假設一一解釋出來,讓大家理解TDD能幫助我們改善系統設計這句話,並非君子戲言。
在我先前的文章透過純文字的方式紀錄當時的看法,但光用文字沒辦法有效讓大家理解其中的含意,加上我自身對於測試的看法又有更深層的提升,這促使了我撰寫這篇文章,希望此篇文章能達到以下目標:
- 了解TDD如何幫助我們改善系統設計?
- 了解TDD與物件導向原則之間的關係。
- 透過實際範例解釋TDD如何幫助我們改善系統設計。
TDD如何幫助我們改善系統設計?
對於初次練習TDD的人來說,相信都有一種不知道如何下手的窘境,為什麼呢?因為撰寫測試時,我們至少要做出以下決策:
- 我們要解決什麼問題?
- 我們要測試的情境為何?
- 測試的名稱為何?
- 我們如何建立測試所需的物件與資料?
- 哪個物件負責此問題?既有物件?新物件?
- 此物件的API要如何設計?既有API?新的API?
- 我們要驗證哪些狀態才能確保回歸測試的有效性?
- 測試的可讀性為何?測試能強調自身要驗證的問題嗎?
如上面我們看到的,TDD透過測試先行的方式,把問題一口氣攤在檯面上,然而這些問題事實上不會因為不寫測試就不存在,常常是開發者為了快速開發而忽略這些問題 (或是根本沒有設想到)。
TDD有把問題攤在檯面上的能力,迫使我們在撰寫程式前,需要先分析、了解問題後再下手。對於設計也是一樣的道理,當設計不好時,TDD會把設計問題攤在檯面上,讓你覺得測試非常難撰寫,這其實就是TDD針對你的設計給予的回饋 (即便你察覺不出來)。
上述將問題快速攤在檯面上的能力,也是Agile一個很重要的原則:Fast Feedback。
所以對於TDD如何幫助我們改善系統設計的回答就是:
TDD有把問題攤在檯面上的能力,能針對設計提供Fast Feedback。當測試難以撰寫時,代表我們設計的不夠完善,需要修改。
但僅僅知道這些還遠遠不夠,我們需要能針對TDD給予的各種回饋分析並做相對的回應才能真正改善系統設計…
TDD與物件導向原則之間的關係
Kent Beck發明TDD時,背後有一些假設我們需要知道:
- Kent Beck是物件導向大師。
- TDD是XP方法論中的一個practice。
- XP很多方法論,都是建立在物件的基礎之上。
你會想說:這跟回應TDD給予的回饋有什麼關係?
事實上,關係可大了,因為TDD給的回饋都是怎麼樣設計出良好物件導向的回饋,如果不懂物件的基本原則就很難利用TDD給予我們的回饋從而改善設計。
通常我會用以下原則來分析TDD給予的設計回饋:
- Encapsulation:用來驗證是否有隱藏物件的實作細節、變動、確保自身狀態正確性。
- Abstraction:用來驗證是否依據有Problem Domain找出適合的抽象物件,來降低系統複雜度與隱藏不鎖碩的細節。
- Reusability:用來驗證物件是否能在不同的情境下被重複利用。
- Cohesion、SRP:用來驗證物件是否做太多事情,責任分配不明確。
- Coupling:用來驗證物件是否過多暴露實作細節,導致客戶端與其強烈耦合。
實際範例
Ensure object invariant
假設我們要幫吃到飽餐廳設計點餐系統,為了避免客人一進來就狂點100盤食物,進而造成不必要的浪費。因此我們設計一個類別ShoppingCart,並給其限制:商品數量不能大於三。
我們針對此功能撰寫第一版本的測試,如下:
@Test
void shopping_cart_with_more_than_three_products_is_invalid() {
Product apple = new Product("Apple", 20.0);
Product banana = new Product("Banana", 20.0);
Product grape = new Product("Grape", 30.0);
Product gauva = new Product("Gauva", 30.0);
ShoppingCart shoppingCart = new ShoppingCart();
shoppingCart.addProduct(apple);
assertTrue(shoppingCart.isValid());
shoppingCart.addProduct(banana);
assertTrue(shoppingCart.isValid());
shoppingCart.addProduct(grape);
assertTrue(shoppingCart.isValid());
shoppingCart.addProduct(gauva);
assertFalse(shoppingCart.isValid());
}
我們知道商業邏輯的規則限制商品在購物車的數量最多不能超過三個,這是ShoppingCart的object invariant。也就是說,不管在什麼時候,ShoppintCart都要確保商品的數量不能超過三個。
良好的物件需要遵守Encapsulation,但從測試明顯看到:ShoppingCart的商品數量能加到四個,客戶端需使用isValid去驗證ShoppingCart的狀態正確性。
你可能會問說:如果沒有客戶端使用isValid呢?很簡單:物件的狀態就錯了,違反商業邏輯的限制。(當狀態錯誤程式卻繼續執行違反Fail Fast Principle)
從上述測試我們得到的回饋如下:
- ShoppingCart違反Encapsulation原則。
- 讓客戶端使用isValid是程序導向的做法,ShoppingCart要能確保自身狀態正確性。
- 讓客戶端使用isValid驗證狀態,導致他與ShoppingCart耦合過重,本應該ShoppingCart負責的責任,卻有部分散落在客戶端,責任的散落會增加耦合。
為了解決Encapsulation的問題,我們把isValid封裝到addProduct身上,針對上述回饋加以修正的測試如下:
@Test
void shopping_cart_with_more_than_three_products_is_invalid() {
Product apple = new Product("Apple", 20.0);
Product banana = new Product("Banana", 20.0);
Product grape = new Product("Grape", 30.0);
Product gauva = new Product("Gauva", 30.0);
ShoppingCart shoppingCart = new ShoppingCart();
shoppingCart.addProduct(apple);
shoppingCart.addProduct(banana);
shoppingCart.addProduct(grape);
assertThrows(InvariantBrokenException.class, () -> shoppingCart.addProduct(gauva));
}
從上述範例我們可以看到:當狀態一錯誤,shoppingCart就會丟出InvariantBrokenException,用來確保自身狀態正確性。
修改後測試解決上述幾個問題:
- 遵守Encapsulation原則。
- ShoppingCart更加符合物件導向的精神,對於自身的責任有自主權,不需要其他人的幫助。
- 客戶端對於ShoppingCart有足夠的信任,不需要親自驗證ShoppingCart的狀態,降低兩個物件之間的耦合。
Ensure object reusability
假設我們要實作註冊學生的功能,從而建立兩個類別分別是:Student與RegisterStudentUsecase。
Student用來記錄學生資訊、RegisterStudentUsecase用來模擬使用者期望的系統行為 (註冊學生)。
我們針對此功能撰寫第一版本的測試,如下:
@Test
void register_a_student() {
RegisterStudentInput input = new RegisterStudentInput("Mars", 25);
RegisterStudentUsecase usecase = new RegisterStudentUsecase();
Result<Long> result = usecase.execute(input);
assertTrue(result.isSuccess());
Long studentId = result.get();
// stuck ...
}
當我們撰寫測試時,發現有點卡住了…
為什麼?因為如果繼續寫下去,不就要直接連到資料庫嗎?(這樣也太累…)
測試速度大幅度降低就算了,但我們不希望為了驗證usecase的應用層邏輯卻需要耦合到持久層邏輯。
我們希望usecase物件能在測試環境、實際環境被重複利用,滿足Context Independent原則。
從上述測試我們得到的回饋如下:
- 當為了驗證A功能,卻需要牽扯到B功能時,代表物件混合了多種Context (Mixed Context)。
- 當物件混合多種Context,會讓物件難以重複使用,而測試本身就是一種對於物件的重複使用,因此降低物件的可測試性。
為了解決Reusability的問題,我們可以使用DI注入針對usecase定義的抽象化物件來滿足Context Independent原則,修改後的測試如下:
@Test
void register_a_student() {
// Arrange
StudentRepository studentRepository = new FakeStudentRepository(); // Fake object
RegisterStudentInput input = new RegisterStudentInput("Mars", 25);
RegisterStudentUsecase usecase = new RegisterStudentUsecase(studentRepository); // DI
// Act
Result<Long> result = usecase.execute(input);
assertTrue(result.isSuccess());
// Assert
Long studentId = result.get();
Optional<Student> studentOpt = studentRepository.findBy(studentId);
assertTrue(studentOpt.isPresent());
studentOpt.ifPresent(s -> {
assertEquals("Mars", s.name());
assertEquals(25, s.age());
});
}
修改後測試解決上述幾個問題:
- usecase物件滿足Context Independent原則。
- 由於usecase透過DI注入抽象化物件,測試能透過假物件隔離資料庫,提升物件的可測試性跟重複利用性。
Ensure API design is low coupling
假設我們有一個類別叫做StudentCSVReport,負責產生所有學生的CSV資料。
我們針對此功能撰寫第一版本的測試,如下:
@Test
void generate_a_student_report() {
// the repository designed for query side, not the write side.
FakeStudentDataRepository studentDataRepository = new FakeStudentDataRepository();
studentDataRepository.add(new StudentData("Mars1", 25));
studentDataRepository.add(new StudentData("Mars2", 26));
studentDataRepository.add(new StudentData("Mars3", 27));
StudentCSVReport report = new StudentCSVReport(studentDataRepository);
report.appendHeader();
report.appendBody();
report.appendFooter();
String result = report.getCSV();
assertTrue(result.contains("students report"));
assertTrue(result.contains("\"Mars1\", 25"));
assertTrue(result.contains("\"Mars2\", 26"));
assertTrue(result.contains("\"Mars3\", 27"));
assertTrue(result.contains("students report footer"));
}
上述測試透過StudentDataRepository來隔離資料庫,提升StudentCSVReport在不同環境的Reusability。
測試整體也很直覺、好讀,那這樣應該沒有問題了吧?
從上述測試我們得到的回饋如下:
- 測試呼叫StudentCSVReport物件三隻API,分別是appendHeader、appendBody、appendFooter,因此測試耦合了產生報表的實作細節。
- 客戶端為了產生報表,需要根據特定的順序呼叫三隻API,當順序呼叫錯誤,報表內容就會有誤差。
- 客戶端透過程序式的方式manipulate物件的內部結構違反物件導向的基本精神,物件要有獨立的自主權。
- 上述API因為暴露過多實作細節,與客戶端強烈耦合,當實作變動會影響全部的客戶端,沒辦法有效Isolate change into one place。
為了解決Coupling的問題,我們將三個API封裝成一個API csvReport來隱藏全部的實作細節,修改後的測試如下:
@Test
void generate_a_student_report() {
// the repository designed for query side, not the write side.
FakeStudentDataRepository studentDataRepository = new FakeStudentDataRepository();
studentDataRepository.add(new StudentData("Mars1", 25));
studentDataRepository.add(new StudentData("Mars2", 26));
studentDataRepository.add(new StudentData("Mars3", 27));
StudentCSVReport report = new StudentCSVReport(studentDataRepository);
String result = report.csvReport();
assertTrue(result.contains("students report"));
assertTrue(result.contains("\"Mars1\", 25"));
assertTrue(result.contains("\"Mars2\", 26"));
assertTrue(result.contains("\"Mars3\", 27"));
assertTrue(result.contains("students report footer"));
}
修改後的測試解決上述問題:
- 測試與物件耦合降低,只有從原先的實作細節耦合轉變為行為耦合。
- 客戶端不需要記API呼叫的順序,降低Bug發生的機率。
- StudentCSVReport更加符合物件導向的精神,負擔更多有用的責任。
- 封裝所有實作細節,當報表修改時,只有一個物件需要修改 (StudentCSVReport)。
Ensure object’s abstraction is crispy
假設我們有三個類別分別是:Order、OrderPriceCalculator、OrderValidator,我們希望計算Order的價格、限制商品在Order的數量不能超過三個。
我們針對此功能撰寫第一版本的測試,如下:
// OrderPriceCalculator class test file
@Test
void calculate_order_price() {
Order order = new Order();
order.addProduct(new Product("Apple", 20.0));
order.addProduct(new Product("Banana", 20.0));
OrderPriceCalculator calculator = new OrderPriceCalculator();
assertEquals(40.0, calculator.calculate(order));
}
// OrderValidator class test file
@Test
void validate_order_line_item_size() {
OrderValidator validator = new OrderValidator();
Order order = new Order();
order.addProduct(new Product("Apple", 20.0));
assertTrue(validator.isInvalid(order));
order.addProduct(new Product("Banana", 20.0));
assertTrue(validator.isInvalid(order));
order.addProduct(new Product("Grape", 20.0));
assertTrue(validator.isInvalid(order));
order.addProduct(new Product("Guava", 20.0));
assertFalse(validator.isInvalid(order));
}
上述測試顯示一個問題:我們將Order物件的部分責任分散到OrderPriceCalculator與OrderValidator,進而造成類別數量暴增。
上述情況在專案時常發生,稱之為Imperative Abstraction,也就是將執行步驟升級為物件。這種物件通常沒有狀態,而是操作其他物件的狀態,而被操作的物件通常只有getter、setter (是披著物件皮的資料結構)。
會有這種設計的出現,是因為思考的方式還停留在程序式設計 (操作資料、一步一步往下的控制流程),沒有辦法以物件的方式思考並且解決問題 (責任、分散式控制、自主權)。
將entity的部分行為升級成物件是錯誤的抽象化策略 (大部份時候),entity通常代表一個完整的概念,擁有強烈的邊界性跟相同改變的頻率。
使用Imperative Abstraction會導致所有細碎類別耦合在一起,需求變動時造成Cascade Change。
單純資料結構的entity也很難確保物件狀態性,容易違反object invariant。
上述測試給我們的回饋如下:
- 物件責任分配不佳,導致類別數量暴增。
- 只有getter、setter的物件,沒辦法有效確保自身狀態正確性,因此違反Encapsulation。
- 因為Imperative Abstraction的緣故,修改容易造成Cascade Change。
- 測試檔案數量增加,每個測試只針對entity中的一個行為進行測試。
- 測試較難理解,因為entity的邊界被不當切割,原本應該一起理解的元素被分散到多個地方,增加開發者的認知複雜度。
為了解決Abstraction的問題:我們需要從Problem Domain進行分析,了解使用者期望entity有哪些行為 (user expectation)、期望Problem Domain中的entity彼此之間如何互動 (interaction among peers)。
使用者希望Order物件負擔的責任如下:
- 自行計算價錢。
- 自行驗證商品數量。
針對上述回饋加以修正的測試如下:
@Test
void get_order_price() {
Order order = new Order();
order.addProduct(new Product("Apple", 20.0));
order.addProduct(new Product("Banana", 20.0));
assertEquals(40.0, order.price());
}
@Test
void order_with_more_three_products_is_invalid() {
Order order = new Order();
order.addProduct(new Product("Apple", 20.0));
order.addProduct(new Product("Banana", 20.0));
order.addProduct(new Product("Grape", 20.0));
assertThrows(InvariantBrokenException.class, () -> order.addProduct(new Product("Guava", 20.0)));
}
修改後的測試解決上述問題:
- Order物件符合Encapsulation原則。
- Order物件較符合Problem Domain中使用者的期望行為與互動,因此符合Abstraction原則。
- 先前的做法較為procedure,耦合多個類別;現在的做法較符合物件的思維,降低不必要的耦合。
- 先前的做法導致:不管客戶端要計算價錢、驗證商品,皆需要使用兩個物件 (ex: OrderPriceCalculator、Order),增加不必要的dependency;現在客戶端只需要使用Order就足夠了,降低不必要的dependency。
Ensure object’s cohesion
假設我們個類別Order,Order在計算價錢時需要考慮多種折扣方案,像是打折、買一送一等等。
我們針對此功能撰寫第一版本的測試,如下:
@Test
void order_contained_two_apples_get_buy_one_get_one_free_discount() {
double discountRate = 1.0;
String buyOneGetOneFreeProduct = "Apple"; // naive implementation just for demo, don't use.
Order order = new Order(discountRate, buyOneGetOneFreeProduct);
order.addLineItem(new Product("Apple", 20.0), 2);
assertEquals(20.0, order.price());
}
@Test
void order_get_20_percent_off_discount() {
double discountRate = .8;
Order order = new Order(discountRate, null);
order.addLineItem(new Product("Apple", 20.0), 2);
assertEquals(32.0, order.price());
}
上述測試觀察到,為了驗證不同的折價方式,我們需要傳遞不同的參數到Order類別,不同折價方式還會互相影響。
每當折價方式新增,Order類別就需要變動,久而久之Order會逐漸變成God Class (逐漸母湯)。
雖然Order需要負責price的運算,但是關於discount的責任明顯不適合指派到Order身上。
上述測試給我們的回饋如下:
- Order Cohesion很低,也違反SRP。
- 測試有Indirection Test壞味道,驗證折價的功能卻需要經由Order物件。
- 測試的名稱告訴我們內部實作偏向於procedure,因為沒有找出適當的抽象化物件封裝瑣碎細節,而是將實作細節的步驟條列式顯示出來,導致測試複雜度增加、測試檔案會越來越大等問題。
為了解決Cohesion的問題,我們將折價的概念封裝成Discount物件,讓Order與其互動,修改後測試如下:
// Order class test file
@Test
void get_order_price() {
// Inject null object to constructor to isolate discount logic.
Order order = new Order(new NullDiscount());
order.addLineItem(new Product("Apple", 20.0), 2);
assertEquals(40, order.price());
}
// PercentageDiscount class test file
@Test
void apply_discount_to_order() {
Order order = new Order();
order.addLineItem(new Product("Apple", 20.0), 2);
PercentageDiscount discount = new PercentageDiscount(.8);
double price = discount.apply(order, order.price());
assertEquals(32.0, price);
}
// BuyOneGetOneFreeDiscount class test file
@Test
void apply_discount_to_order() {
Order order = new Order();
order.addLineItem(new Product("Apple", 20.0), 2);
BuyOneGetOneFreeDiscount discount = new BuyOneGetOneFreeDiscount("Apple");
double price = discount.apply(order, order.price());
assertEquals(20, price);
}
修改後的測試解決了上述問題:
- 將Order概念與Discount概念分離,增加Order的Cohesion。
- 為了驗證Order,只需要使用Null Object即可,不需要像之前條列所有排列組合。
- 不同的折價方式,分散到不同測試檔案,降低Order測試的數量。
上述舉了非常多實際案例,都是多年來在專案開發中時常遇到的設計問題,如果加以掌握與練習,相信對TDD與物件導向會有更深的掌握,開發的速度會更加有效率、寫出更高品質的程式碼、工作也會更有成就感。
結論:
- TDD能快速把問題攤在檯面上,強迫開發者分析、了解問題。
- TDD有Fast Feedback的特性,能給予物件導向設計的回饋。
- 要掌握TDD的設計回饋,需要有基本物件導向知識,才能加以分析。
- 使用基本設計原則:Encapsulation、Abstraction、Cohesion、Coupling能幫助我們分析測試的回饋。