Practical Object-Oriented Design Book 心得&整理 (9) Final

Z-xuan Hong
25 min readAug 14, 2021

--

不管DDD、TDD、Design Pattern、Refactoring都是大師們淬鍊出來的結晶,如果我們連原料都沒有的話要如何淬鍊出跟大師們一樣的結晶呢?話不多說就讓本篇文章承接上一回繼續介紹物件導向設計,讓大家對於原料 (物件)的使用有更深入的認知。這樣一來不管是DDD、TDD、Design Pattern、Refactoring都能更容易上手。

未來文章根據下方依序介紹:

本篇文章是這一系列的最終章,在這一系列文章中:記錄了物件責任的切割、物件依賴之間的管理、彈性介面的設計、Duck Type、繼承、Module、物件組合等概念。

如果掌握上述的概念並且加以練習,能讓我們寫出較容易讓人讀的懂、修改、擴充的程式碼。

最大的回報則是:不管在多大型、多複雜的專案底下,使用這些技巧能讓我們寫出簡單、好維護的程式碼,達到維護的成本只跟改變的範圍有關,跟專案生命週期的時間無關。

良好的物件導向系統能大幅度地降低認知複雜度,並且能用多個責任明確、好維護的模組組合起來解決複雜的問題。相較於沒辦法掌握物件導向的初學者會寫出俗稱義大利麵程式碼 (沒有責任劃分、沒有依賴控管、沒有系統分層),自然而然開發的效率也會遠遠低於能掌握物件導向的工程師。

然而隨著專案生命週期的進行,原先的設計會慢慢腐化,對於新需求的支援度也會下降。當這種情況發生時,我們需要透過特定的手段將設計逐步演化到另外一個對於新需求支援度較高的設計。

這種特定的手法就是重構,也就是在不改變系統行為的前提之下,調整系統的內部結構。但是在改變系統內部結構的過程中,我們如何確認系統行為不變呢?

答案很簡單:透過自動化測試來確保系統行為沒有因重構而改變。

當學會物件導向設計、重構、自動化測試時,能讓我們在有限的時間內寫出品質較好、維護成本較低廉的程式碼 (最終目的就是要能幫企業降低開發成本)

本篇文章要讓讀者了解:

  • 測試的定義。
  • 測試背後的動機。
  • 測試的真正價值。
  • 如何寫出良好品質的自動化測試。
  • 如何評估自動化測試的品值。

What is Test

書中沒有明確提到對於測試的定義,但是從維基百科上能夠找到明確的定義:

In software testing, test automation is the use of software separate from the software being tested to control the execution of tests and the comparison of actual outcomes with predicted outcomes.

其實說白話文就是:準備好輸入資料、執行待測試的模組、驗證預期的輸出與真正的輸出是否一致。

這個步驟也是單元測試的3A口訣:

  • Arrange:準備測試所需要的資料。
  • Act:執行待測試元件。
  • Assert:驗證輸出是否正確。

如上面各位所看到的:測試的定義、概念、使用方式很簡單,但實際上真正應用後,會發現有很多問題需要被解決:

  • 如何針對物件導向撰寫測試?
  • 需要準備的測試資料非常複雜怎麼辦?
  • 測試執行時間非常久怎麼辦?
  • 哪些東西要測試、哪些東西不需要測試?
  • 在沒有改變系統行為下重構,卻導致一堆測試亮紅燈怎麼辦?
  • 物件太複雜,根本不知道要驗證哪些狀態、行為怎麼辦?

上述都是實務上導入測試時會遇到的問題,但大部分介紹測試的書籍也僅僅停留介紹什麼是測試,對於上述問題討論的也不多,因此希望透過此篇文章讓讀者了解產生這些問題背後的根本原因,並且知道如何解決。

Motivation of Test

測試的動機為何?良好的設計?快速抓到Bug?履歷上好看?

測試最終目的:降低企業開發軟體的成本、降低工程師維護的成本。

如果撰寫測試的成本遠遠高於不寫測試的成本時,就違反測試最終的目的。

此時我們有兩條路可以選擇:

  • 回歸不寫測試的開發方式,但可以預期的是,系統缺陷會越來越多、開發時間會越來越久。
  • 從測試失敗的回饋中找出根本問題,並且從中了解、改善。

如果你選擇的路是第二條的話,請繼續看下去吧 :)

Advantage of Test

了解了測試背後的動機後,接下來要介紹測試帶來的好處。

  • 快速抓出系統缺陷

測試存在的目的就是為了驗證系統的行為沒有失效,而自動化測試最大的好處就是,能夠在系統發生Bug的當下就抓出錯誤,達到下一步發生錯誤,測試就抓出錯誤的快速回饋效果。這種回饋,能讓工程師在寫出Bug的同時馬上發現,並且馬上修正。

當系統的Bug越晚被發現時,修復Bug的成本也會越高,因此越快抓到系統錯誤,能有效降低開發成本。就不太需要全民公測了。

  • 延遲決策

開發系統遇到需求不明確、手上沒有太多資訊可以參考時,會讓我們很難找出需求中的關鍵抽象化 (Key Abstraction)。

這時我們能做的就是:想出目前最好的API,封裝實作的不確定因素,先讓程式碼能動就好,但這種狀態不會持續太久,當我們資訊累積到一定程度時,在透過重構手法,改善系統設計、修正抽象化的設計。

此時測試就非常有價值了,因為它可以讓我們在做出真正決策時,確保修改結構行為不會被破壞。

如果沒有測試,即便發現良好的抽象化,也會讓工程師不敢修改系統中的程式碼,或是工程師會在有限的資訊勉強找出堪用的抽象化,但堪用的抽象化沒辦法有效的面對系統需求的快速變化。

  • 模組使用文件

如何生成物件?如何使用物件?此物件有什麼行為?這些都是測試會紀錄的資訊,開發者要了解一個物件的話,只要透過測試就可以測試不同物件的API,快速理解物件的使用方式。降低需要花時間重新閱讀程式碼的時間。

畢竟物件用來封裝實作細節並且提供解決問題的API,我們不希望開發者還要花過多的時間重新理解底層實作。

  • 支撐系統抽象的演化

良好設計的系統,會有一組抽象化物件彼此之間互動,所有具體物件都會耦合到這些抽象物件,這樣能提供良好的彈性。

但是!當要修改這些抽象化時,在沒有測試的幫助、非常多客戶端耦合的情況底下,反而會讓抽象化沒辦法有效的演化,最終慢慢腐化。

  • 給予目前系統設計的精準回饋

測試本身也是物件,而為了要測試待測試物件,測試物件需要使用到待測試物件,因此測試本身就是一種對於待測試物件的重複使用 (reuse),如果物件難以測試,代表物件不好重複使用、限制太多、耦合性太高。

因此當遇到不好測試問題時,我們要思考的其實是系統設計問題。是物件責任太多?還是耦合太高?還是物件邊界切的不好?

常見的回饋如下:

  • Test FixtureSetup非常難建立:代表物件負擔太多的責任、沒有適當的抽象化去解耦合。
  • 測試不知道要驗證什麼狀態:代表物件聚合性很低,特定功能的資訊散落在各個物件。
  • 測試案例非常大:代表物件負擔太多責任,應該把部分功能抽出新的類別。High Level Test Case測試High Level Functionality、Low Level Test Case測試Low Level Functionality。為Same Level of Abstraction的展現,能避免實作細節與抽象化混合的問題。增加系統重複使用性 (reuse)。

How to Write High Quality Test

如何撰寫測試的主題區分為以下部分:

  • 自動化測試特性。
  • 如何對物件撰寫測試?
  • 如何測試private method
  • 如何避免重構時測試紅燈亮不停?To Mock or Not To Mock?
  • Mock vs Stub。
  • 如何針對Duck Type撰寫測試?
  • 如何針對繼承架構撰寫測試?

自動化測試特性

單元測試的特性如下:

  • Fast。
  • Isolation。
  • Unit。

上述介紹乍聽之下很合理,但不同派別對於測試Isolation、Unit的看法有極大的差異。

經典單元測試派別 (Classic)

此派別出自於Kent Beck TDD書中,觀點如下:

  • Isolation:經典派別認為,單元測試的Isolation代表為測試與測試之間互相隔離,測試能夠以隨機順序的方式執行,不會影響系統結果。
  • Unit:經典派別認為,每個測試的基本粒度為滿足客戶端期望的行為,為了滿足客戶端的行為,一個測試可能涵蓋一個類別至多個類別。 (客戶端期望的行為通常貼近使用者角度,並且當行為執行後有明顯可驗證的目標)

倫敦單元測試派別 (London)

此派別出自於Growing Object-Oriented Software, Guided by Tests書中,觀點如下:

  • Isolation:倫敦派別認為,單元測試的Isolation代表每個類別與其互動類別之間要互相隔離,也就是說:當特定類別產生錯誤時,依賴他的類別的測試不會亮紅燈。
  • Unit:倫敦派別認為,每個測試的基本粒度為類別,也就是說一個類別一個測試。

我的看法:根據應用的Context選出最適當的方式。

  • Isolation:經典派別適合在物件與物件之間互動頻繁、計算複雜的情境下使用。倫敦派別適合在層與層之間使用,來降低多餘的重複測試。反之。如果在層與層之間使用經典派別會讓Test Fixture Setup變得非常複雜,如果在物件與物件之間互動頻繁、計算複雜的情境使用倫敦派別,會導致測試嚴重耦合待測試物件的實作細節。
  • Unit:經典派別提倡透過行為的角度去劃分測試粒度,可讀性較好,測試的情境較為完整倫敦派別提倡透過類別的方式去劃分測試粒度,當測試發生錯誤時,較容易除錯

我個人的做法比較支持透過行為的角度劃分測試粒度,測試會比較符合Domain中操作Key Abstraction的情境,不會有資訊散落在各個類別的問題。如果一個類別一個測試容易讓開發者沒辦法Domain掌握整體的樣貌,產生資訊碎片化的問題。

至於Isolation則是根據情境下選擇使用,如上面說的物件與物件互動使用經典派別,層與層之間使用倫敦派別。

如何對物件撰寫測試?

要對物件撰寫測試其實很簡單,只要把握一個基本原則即可,只對物件的public API進行測試。物件的public API也不能包含任何實作的資訊。

這樣的測試方式就像是Black Box,測試對於待測試物件的內部一無所知,也因為這樣的觀點,能讓測試與待測試物件之間保持低耦合的關係。

如何測試private method

這題答案很簡單:不要測試private method,請透過物件的public api進行測試。

但如果遇到如果不測試private method的話,沒辦法確保行為是否正確怎麼辦?

答案很簡單:物件責任太多,請使用extract class封裝原本物件的private method,並且讓這些private method轉變為public api。在針對新類別進行單元測試。

上述extract class要注意的是:extract class能夠降低測試案例過大的缺點 (因為物件太多責任)。但private method本身就有unstable的特性,不會因為使用extract class就把原本unstable的api轉變成stable的api。所以針對新類別的測試也會有unstable的物件。需要慢慢提升新類別的抽象化層度,才能夠讓測試逐漸變得穩定。

如何避免重構時測試紅燈亮不停?

如上述說的,只要能夠避免測試與待測試物件產生過高的耦合,即可以確保測試不會在物件實作修改但行為不變的情況下亮紅燈。

而這也間接顯示出過度使用mock物件的問題,也是倫敦派別的問題。學過物件導向的人都知道,物件導向是一種控制權分散化的設計方式,也就是說:當客戶端傳遞訊息給接收物件,接收物件要怎麼處理,客戶端一概不管,也因為這樣特性,讓系統之間的耦合性非常低。

但使用mock的缺點就是:測試需要檢視待測試物件與其互動物件的實作細節,也因為這點,會讓測試與待測試物件產生較高的耦合,容易在重構時亮紅燈。

那To Mock or Not To Mock?我的經驗是,如果物件與其互動物件的protocol是stable的話,使用Mock不會造成太大的耦合,反之如果物件與其互動物件的protocol非常unstable的話,使用Mock會造成不必要的耦合。

所以我的答案是:只針對穩定的抽象化進行Mock

Mock vs Stub

這兩個概念經常被大家混淆或當成同義詞,實際上要區分他很簡單。

  • Mock:用來驗證物件的行為。
  • Stub:用來回傳物件的期望的資料。

Mock範例程式碼:

@Test
void create_student() {
FakeCreateStudentView view = new FakeCreateStudentView();
MockAddStudentUseCase useCase = new MockAddStudentUseCase();
CreateStudentViewPresentationModel pm = createStudentViewPM(view, useCase);

enterStudentInformation(pm);

pm.createStudent();

useCase.shouldStudentAdded(new AddStudentInput("Mars", 25));
}
// this is mock object
private static class MockAddStudentUseCase implements AddStudentUseCase {

private AddStudentInput addStudentInput;

@Override
public UUID execute(AddStudentInput addStudentInput) {
this.addStudentInput = addStudentInput;
return UUID.randomUUID();
}

public void shouldStudentAdded(AddStudentInput addStudentInput) {
assertEquals(this.addStudentInput, addStudentInput);
}
}

Stub範例程式碼:

private interface UserRepository  {
User findUserBy(Long userId);
}

private static class ChangeUserNameUseCase {
private final UserRepository userRepository;
public ChangeUserNameUseCase(UserRepository userRepository) {
this.userRepository = userRepository;
}

public void execute(Long userId, String newName) {
// skip error handling
User user = this.userRepository.findUserBy(userId);
user.changeName(newName);
}
}
// this is stub object
private static class StubUserRepository implements UserRepository {
private User userToReturn;

@Override
public User findUserBy(Long userId) {
return this.userToReturn;
}

void shouldReturn(User user) {
this.userToReturn = user;
}
}

@Test
public void change_user_name() {
User user = createUser("Mars.Hong");
StubUserRepository repository = new StubUserRepository();
repository.shouldReturn(user);

ChangeUserNameUseCase useCase = new ChangeUserNameUseCase(repository);
useCase.execute(user.getId(), "Mars");


User changedUser = repository.findUserBy(user.getId());
assertEquals(changedUser.getName(), "Mars");
}

從上述兩段程式碼,我們可以看出非常明顯的差異,Stub主要回傳待測試物件簡單的罐頭訊息、Mock主要驗證待測試物件有確實呼叫特定行為。

這兩種概念為Query Message與Command Message的差異:

  • Stub為Query Message:因為我們要測試的是:待測試物件接收到回傳值的後的邏輯並且系統不會對Query產生side-effect,所以不需要針對Stub進行assert。
  • Mock為Command Message:因為我們要測試的是:待測試物件接收到訊息時,有執行特定的行為並且系統會對此行為產生side-effect,所以需要使用Mock來確認特定行為被呼叫。Mock通常用於層與層之間,例如:Spring Web MVC與Application Service、Application Service與Repository、Application Service與External Service。

如何針對Duck Type撰寫測試?

先前的文章我們提到過Duck Type,概念就是讓客戶端只專注的行為與角色,而不是特定的類別,這樣的特性讓Duck Type是一種穩定的抽象化,因此使用倫敦派別進行測試會讓整體變得更簡單,也能避免使用經典派而產生多餘的程式碼。

範例程式碼:

private static class DuckClient {
public void doSomething(Duck duck) {
// logic...
duck.doSomething();
// logic...
}
}

private interface Duck {
void doSomething();
}

private static class DuckA implements Duck {
@Override
public void doSomething() {

}
}

private static class DuckB implements Duck {
@Override
public void doSomething() {

}
}

@Test
public void test_duck_type() {
FakeDuck fakeDuck = new FakeDuck();
DuckClient duckClient = new DuckClient();

duckClient.doSomething(fakeDuck);

fakeDuck.verify();
// assert duckClient's state...
}

private static class FakeDuck implements Duck {
private boolean isCalled = false;
@Override
public void doSomething() {
this.isCalled = true;
}

public void verify() {
assertTrue(this.isCalled);
}
}

在先前文章中提到過,過度使用Mock會造成測試與待測試物件高度耦合,但Duck Type是一種穩定的抽象化,因此在這使用Mock能讓測試變得更簡單。

如果上述的例子硬是要使用經典派的方式,然而Duck介面有多個implementor,那測試要選擇哪一個呢?

為了建立Duck implementor物件,同樣的setup程式會出現在DuckClient測試與Duck implementor測試進而造成重複性問題。即便使用extract method,DuckClient測試也會對Duck implementor產生耦合。

如何針對繼承架構撰寫測試?

書中的做法是使用倫敦派別的方式,但我個人推薦使用倫敦派加上經典派的方式

原因如下:繼承用來模擬specialization的關係,我們希望在穩定的抽象化中,加上一些邏輯的差異化。

我們應該針對父類別本身使用倫敦派的方式驗證public stable non-variation api的行為。針對每個子類別使用經典派的方式驗證public stable variation api的行為。

範例程式碼:

private abstract static class People {
private int age;
private int height;

People(int age, int height) {
this.age = age;
this.height = height;
}
// public stable non-variation api
public int getHeight() {
return height;
}
// public stable non-variation api
public int getAge() {
return age;
}
// public stable non-variation api
public void changeAge(int newAge) {
// validation logic...
this.age = newAge;
}
// public stable non-variation api
public void changeHeight(int newHeight) {
// validation logic
this.height = newHeight;
}
// public stable variation api
public abstract void run();
}

private static class Man extends People {
// additional state
Man(int age, int height) {
super(age, height);
}

@Override
public void run() {
//
}
}

private static class Women extends People {
// additional state...
Women(int age, int height) {
super(age, height);
}

@Override
public void run() {

}
}
// super class use fake to test public stable non-variation api
private static class FakePeople extends People {

FakePeople(int age, int height) {
super(age, height);
}

@Override
public void run() {
// we will not test public abstract run on fake object
}
}
@Test
public void changeAge() {
FakePeople fakePeople = new FakePeople(12, 156);

fakePeople.changeAge(24);

assertEquals(fakePeople.getAge(), 24);
}

@Test
public void changeHeight() {
FakePeople fakePeople = new FakePeople(12, 156);

fakePeople.changeHeight(156);

assertEquals(fakePeople.getHeight(), 156);
}
// subclass use classic to test public stable variation api
@Test
public void man_run() {
Man man = new Man(12, 156);

man.run();

// assert man's state...
}

@Test
public void women_run() {
Women women = new Women(12, 156);

women.run();

// assert man's state...
}

從上述程式碼我們可以發現:

  • 針對父類別使用倫敦派來驗證public stable non-variation api的行為。
  • 針對子類別使用經典派來驗證public stable variation api的行為。

How to Evaluate the Quality of the Test

管理階層常常把Code Coverage, Branch Coverage拿來當作驗證測試價值的參考,但事實上這些東西很難反應現實的真正情況。即便是Code Coverage、Branch Coverage非常高,還是能夠寫出難以維護的測試。

很可惜的是,除了人工驗證,要透過自動化的方式去評估測試的價值是非常困難的。

透過Code Review加上測試矩陣的方式,抓出品質不良的測試,並且加以改善是比較可行的辦法。

測試矩陣 (取自Unit Testing Principles, Practices, and Patterns):

  • Fast Feedback:測試執行的速度。
  • Easy to Refactoring:在系統行為不變重構時,測試亮紅燈的頻率。
  • Catch Regression Error:在系統破壞行為,測試亮紅燈的頻率
  • Easy to Maintain:測試整體的維護性、可讀性、除錯能力。

這邊只針對單元測試進行討論,整合測試與系統測試雖然重要,但單元測試在執行速度快速找出系統錯誤上是遠遠勝過整合測試與系統測試的。

以下針對測試矩陣的每一點進行探討:

Fast Feedback

良好的單元測試執行速度要非常快,全部測試的平均速度應該在20毫秒到30毫秒之間。如此一來,才能讓數千個測試在幾分鐘內跑完,幫助開發者快速找到錯誤、並且修正。

當測試的速度很慢時,可能的情況如下:

  • 其實是寫成整合測試,當測試與資料庫、系統指令、檔案系統溝通時,就不是單元測試了。
  • 待測試物件的責任過大,導致測試執行時,需要產生數十個物件,自然會降低執行速度。應該把不相干的責任劃分到其他物件,讓系統責任的分配越均化越好 (也就是沒有一個物件負責全部事情,是平均分散到各個物件)。

Easy to Refactoring

在文章一開始我們提到,有測試的保護,能避免我們在重構系統時破壞既有的行為。

因此好的測試需要對重構的耐受程度很高,也就是說:測試只能在系統行為確實改變時亮紅燈,反之,如果只是改變結構但沒有改變行為就不應該亮紅燈。

先前我們已經說過,為了達到這樣的品質,需要讓測試與待測試物件的耦合性很低,也就是只能有abstract coupling (stable),不能有implementation coupling (unstable)。

當測試對重構的耐受程度很低時,可能的情況如下:

  • 測試使用太多mock物件去檢視待測試物件在接收訊息後,所處理的實作細節。mock只能在物件與物件之間的protocol較為穩定時才能使用,否則會造成測試不穩定。
  • 待測試物件的public API實際上是實作細節,應該將該public API更近一步的抽象化,降低測試與它的耦合。

Catch Regression Error

好的測試應該要在系統行為錯誤時即時亮紅燈,這也是測試很重要的價值,能幫助我們降低開發的成本。

當測試沒辦法有效抓出regression error時,可能的情況如下:

  • 測試沒有任何的assertion,請大家不要覺得好笑,因為現實就真的發生過這種問題。
  • 測試assert的行為不夠完整,應該重新確認測試的目標,並增加缺少的assert。
  • 測試沒辦法光靠針對待測試物件的資訊就能進行assert,代表功能被不適當的分配到其他物件,應該使用merge class把相關功能的程式碼組合成新的物件,才能解決根本問題。

Easy to Maintain

好不好維護有時候是很主觀的問題,但我還是在這邊提出我自身的看法給各位參考:

  • 可讀性:測試需要讓開發者一眼了解在測試什麼行為?如何驗證測試的行為?
  • 除錯性:當測試亮紅燈時,能否讓開發者一眼了解出了什麼問題?
  • 撰寫容易程度:測試使否有使用factory method、builder提供適當的DSL來撰寫測試?能否讓不熟悉此測試的開發者簡單撰寫?是否有過多的mock導致測試難以撰寫?

結論:撰寫好的測試跟撰寫好的production code一樣重要,品質不良的測試不但沒辦法降低成本,甚至可能比不寫測試的成本還要更高。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

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

No responses yet