l

2023年6月20日 星期二

你就是寫太多測試才會沒時間(2):自動化測試是金字塔嗎?

June 20 09:55~11:46

  


▲圖1:傳統的自動化測試形成金字塔形狀,單元測試占比最大


前言

傳統軟體自動化測試形成如圖1的金字塔形狀,作為驗證個別軟體元件正確性的單元測試數量做多,整合測試次之,透過使用者介面驗證系統功能的使用者驗收測試或稱為End-To-End測試數量最少。

多年來,很多撰寫自動化測試的開發人員心中大致依循著測試金字塔去規劃與撰寫他們的測試案例。但是,雖著單元測試越來越多,系統功能不斷地演化以及持續重構改善設計,開發人員經常會發現:「靠,剛剛的修改造成N個單元測試失敗。更慘的是,我看不懂這些失敗的單元測試為什麼失敗。」

整合測試因為以黑箱的角度測試「系統功能」,因此相對而言對於軟體修改與重構的抵抗力比較高。但光使用整合測試驗證系統品質存在兩個問題:

  1. 發生錯誤時不易除錯(不容易明確看出錯誤發生在哪個地方)
  2. 執行速度比單元測試要慢很多,導致回饋路徑較長,降低開發人員持續測試的意願

如果可以減少單元測試的數量同時維持單元測試的效果(避免上述兩個問題),自動測試有沒有可能從金字塔變成如圖2所示的菱形


▲圖2:自動化測試有沒有可能是菱形?

 

今天這一集先談第一個問題,下一集再談測試執行速度的問題。

***

用合約取代單元測試

測試只是一種驗證系統行為的方法,在軟體工程中除了測試以外還有一種也算是廣為人知但較少人做的方法:合約式設計(Design by Contract;DBC)也可以規範系統行為。DBC的作法很簡單,模仿真實世界人類的合約,幫軟體元件撰寫合約。軟體合約主要包含:pre-conditions(前置條件)post-conditions(後置條件)以及class invariants(類別不變量)。為了簡化起見之後的範例只討論前兩者,先忽略class invariants。

圖3是ezKanban系統中的Workflow aggregate單元測試,這也是傳統用來驗證程式行為的做法。


▲圖3:Workflow單元測試

 

圖4是幫Workflow的建構函數撰寫合約的程式範例,其中第42~44是pre-conditions,第48~61是post-conditions,第46行是method body。當程式執行的時候,只要pre-conditions和post-conditions都通過,那麼不管method body如何實作,它的行為就被視為具備正確性(correctness)


   ▲圖4:幫Workflow寫合約 

***

看到圖4的範例,鄉民們可能會覺得:「類別的合約就是幫method做輸入參數檢查,然後把平時寫在單元測試裡面的assertions移到production code裡面而已啊。」這樣做雖然不用寫單元測試,但是這些合約也是程式碼,也是要花時間撰寫與維護,這樣有省到時間嗎?

這個問題可以從幾個方面來討論:

  • 不用寫arrange和act:撰寫單元測試有三個步驟,arrangeactassert。寫成合約之後,assert部分還是存在,但少了arrange與act。寫過自動化測試的鄉民們應該很有感,很多時候花在arrange的時間甚至比assert還多。減少arrange與act除了少掉撰寫的時間,也避免了之後需求變更或軟體重構導致需要維護單元測試的時間。
  • 和Production Code生活在一起有助於開發與維護:寫在production code裡面的合約(post-conditions),看起來跟寫在測試裡面的assertions很像,但合約並不是把測試寫在production code,而是把規格寫在production code,這兩者有很大的差別。將規格寫在production code,當規格改變之後,可以直接修改production code(反之亦然),減少context switching。
  • Caller和Callee責任清楚:撰寫合約也可以釐清物件之間的責任,Caller需要滿足pre-conditions,Callee需要滿足post-conditions。當合約被違反時,丟出的例外訊息可以協助找出錯誤,這一點可以達到和單元測試一樣,甚至更好的除錯效果。

***

誰來驗證合約?

看到這裡鄉民們應該有一個疑問:「我可以直接執行單元測試,寫在production code裡面的合約要怎麼執行?」另外,「合約也是程式碼,寫錯了怎麼辦?要不要寫測試來驗證合約?」

合約是在系統執行期間(runtime)被執行與驗證,所以還是要有「人」來執行這些合約。這就是圖2中的整合測試所要負擔的責任:透過整合測試來執行合約

有沒有需要另外寫其他類型的測試來驗證合約?基本上不需要,當執行驗收測試時如果合約失敗,和單元測試執行失敗一樣,可能有兩個原因:production code寫錯或測試寫錯(合約寫錯),然後開發人員就必須介入排除錯誤發生的原因。

***

結論

透過驗收測試驅動合約,可以極大幅度減少單元測試的數量,接下來只要可以加速驗收測試執行速度,實務上就有可能落實Teddy所介紹的這套方法。至於如何加速驗收測試執行,這個問題比較複雜,下集再談。

***

友藏內心獨白:「你就是寫太多測試才會沒時間」都是真的 XD。

2023年6月17日 星期六

你就是寫太多測試才會沒時間(1):證明自己的清白

June 17 16:31~18:24

▲圖1:單元測試驗證修改過的email是否正確 

 

前言

Teddy的朋友Kuma幾個月前寫了一本書:《你就是不寫測試才會沒時間:Kuma 的單元測試實戰 -- Java篇》。的確,自從Teddy在N年前開始寫第一個自動化單元測試之後,Teddy一直認為測試是開發不可分割的一部分。好的測試可以協助釐清規格、作為驗收條件、找出回歸錯誤,以及支持重構,讓開發人員走得更穩、更快。

但是,隨著測試案例越來越多,管理與重構這些測試案例就變成另一個頭痛的問題。下周二Teddy舉辦一個網路演講,講題是:「你就是寫太多測試才會沒時間」,就是要討論應對這種現象的方法。這個講題這雖然是一句帶有玩笑性質的話,但也代表對於測試看法的一種演進過程:

不寫測試沒時間 ---> 寫了測試有時間--->  累積太多欠管理的測試又變得沒時間 ---> 下階段是什麼?

針對這個主題,今天談一個單純一點的情況:如何簡單驗證待測程式沒有做它不該做的事情?

***

從範例看問題

軟體測試有一個基本原則:「除了要驗證待測程式做了該做的事情,也要驗證它沒有做不該做的事。」舉個例子,圖1中的User物件,呼叫它的changeEmail方法設定新的email,在第101行中驗證email是否被正確設定。這種測試很常見,但嚴格講起來這個測試並不完整。除了驗證email有被正確修改以外,還需要確保User物件的其他欄位沒有被改變。因為難保changeEmail的實作,除了改變email以外,會不會不小心動到User物件的其他欄位,例如把nickname清空。

但是,如果每一個測試案例都去驗證不應該被改動的欄位真的沒有被異動,將會增加很多測試工作,如圖2所示。


▲圖2:第104~109行驗證User除了email以外的其他欄位維持原狀 

***

這還只是單元測試而已,如果是Use Case層次的測試案例,例如ChangeEmailUseCase,從Repository讀出User之後,理論上相同的assertion還要再寫一次。除了需要花費而外時間撰寫測試,也造成duplication code,增加日後維護測試案例的成本。

***

解決方案

先講結論,Teddy使用AssertJ這個「Fluent assertions for java」的測試工具來解決這個問題。圖3為Teddy使用ezSpec(Teddy自行開發的BDD工具軟體,可以直接用Java寫Given-When-Then,過一陣子會開源)所撰寫的ChangeEmailUseCase測試,第73行透過Repository從資料庫中拿出修改過email的User物件,然後第74行比對email欄位是否被正確修改。

接著在第76~78行使用AspectJ的assertThat做為比對物件的方式,呼叫usingRecursiveComparison,然後透過ignoringFieldsMatchingRegexes指定那些欄位不需要比對,最後再呼叫isEqualTo,就可以排除特定欄位之後,比對兩個物件是否相等。

圖3的程式範例擷取自ezKanban,由於ezKanban支援樂觀鎖,因此在每一個Aggregate物件身上都有一個version欄位用以作為樂觀鎖使用。因為User物件是一個Aggregate,所以User的email改變之後,version數值加1,因此在第77行比對修改前與修改後的兩個User物件實例是否相等的時候,除了排除email,也要排除version。


▲圖3:採用ezSpec撰寫的ChangeEmailUseCase測試 

***

結論

透過工具幫忙,就可以用很簡潔的方式去確保物件的狀態。雖然Teddy在範例中使用AssertJ做為比對的工具,但相信不同的語言應該可以找到類似的工具。如果真的找不到怎麼辦?那就自己寫一個啊。

***

友藏內心獨白:開發人員就是要有Maker精神。

2023年6月14日 星期三

為什麼Teddy沒使用Specification設計模式?

June 14 21:46~22:53


  ▲圖1:定義Specification介面


前言

在6/5~6/7去客戶家上【領域驅動設計與簡潔架構入門實作班】的時候,有一位學員問Teddy:

  1. 為什麼Teddy建議Entities Layer的物件不要直接操作Repository?
  2. 為什麼有人說UoW(Unit of Work)和Repository在DDD裡面算是Anti-Pattern?
  3. 為什麼Teddy沒有用Specification模式?

前兩集談了前兩個問題,今天討論最後一個問題:「為什麼Teddy沒用Specification設計模式」」?

 

***

Specification 設計模式

這一個設計模式是由Eric Evans與Martin Fowler所整理的,可在此下載介紹該模式的pdf檔案

Specification這個名字很容易讓人聯想到規格或是需求,但它的用作其實是Filter(過濾器)Selector(選擇器)。傳統上,開發一般CRUD-Based的系統,開發人員很常直接下SQL操作資料庫去尋找所需的資料。但是在領域驅動設計(DDD)中,強調透過領域模型來表達業務邏輯。「尋找符合條件的領域物件」這件事,本身就是一個業務邏輯,因此在領域模型中應該有相對應的物件來表達這樣的業務邏輯。而Specification設計模式就是為了這樣的應用場景而存在。

實作Specification很簡單,它的介面只有一個方法isSatisfiedBy,請參考圖1。isSatisfiedBy接受一個物件(通常是領域物件,例如Board, Workflow, Card這些物件),如果物件的內容滿足concreate specification所指定的條件,則回傳true,代表這個物件「符合規格(選到這個物件了)」。

***

圖2是以個用來選擇Board是否屬於某個Team的規格,稱為BoardBelongToTeamSpecification。



▲圖2:BoardBelongToTeamSpecification範例程式 

 

圖3為BoardBelongToTeamSpecification的使用方法,首先呼叫getBoardLise()產生四個Board。然後產生BoardBelongToTeamSpecification instance,傳入”Team 1”當作查詢條件。接著用BoardBelongToTeamSpecification 當作過濾條件,從這四個Board裡面選出Kanban Board與Board Game。


▲圖3:BoardBelongToTeamSpecification使用範例 

 

在Eric Evans與Martin Fowler所整理原始的文章中,Specificatoin還可以串接,形成更複雜的選擇規範。

***

為什麼沒用Specification?

由上面例子可以看出來,Specification其實就是一個Predicate。從實作面的角度來看,可以直接用Lambda來完成。Specification之所以存在,還有一個很重要的作用力,就是要重複使用這個業務邏輯。你可以用Specification去資料庫中挑選資料,或是驗證領域模型物件是否符合否項業務規格。在這種情況下,如果使用Lambda來實作,就會產生重複程式碼。

寫到這裡,還是沒講為什麼在ezKanban中並沒有使用Specification。原因如下:

  • ezKanban透過撰寫合約的方式來驗證領域物件的正確性,而非使用Specification。
  • 如果要透過Specification去資料庫挑選資料,那麼資料必須先從資料庫中載入記憶體或是用某種映射的方式,把傳給isSatisfiedBy()方法的物件身上的每一個欄位,去匹配資料庫中的欄位。因為ezKanban支援State Sourcing與Event Sourcing,無法光用傳統State Sourcing的方式如果採用Specification去資料庫比對資料。因此,ezKanban就沒有使用Specification,而是針對不同儲存方式與不同資料庫,用資料庫相依的方法,撰寫特別的查詢物件。
  • ezKanban套用CQRS,而CQRS和Specification是兩種互相衝突的設計模式。詳細原因請參考Vladimir Khorikov的部落格文章 <CQRS vs Specification pattern>。

***

結論

Teddy當年在學DDD的時候,印象中只有在DDD藍皮書中看到Specification的介紹,在比較接近實作的DDD紅皮書與DDD橘皮書中,沒什麼印象提到Specification。可能是Teddy學藝不精,所以開發ezKanban的時候潛意識中就沒有套用Specification。

但Teddy目前沒用也不代表以後不會用,反正這麼多設計模式,多了解一點也沒什麼不好。等到哪天有合適的場合,這些模式會自動跑出來報效國家。

***

友藏內心獨白:用Collection操作資料還是非常方便滴。

2023年6月12日 星期一

該不該使用Unit of Work和Repository?

June 12 15:48~16:20;20:22~22:56


▲圖1:ezKanban的Repository介面 


前言

昨天提到6/5~6/7到客戶家上【領域驅動設計與簡潔架構入門實作班】,有一位學員問Teddy的三個問題:

  1. 為什麼Teddy建議Entities Layer的物件不要直接操作Repository?
  2. 為什麼有人說UoW(Unit of Work)和Repository在DDD裡面算是Anti-Pattern?
  3. 為什麼Teddy沒有用Specification模式?

昨天談了第一個問題,今天聊聊第二個問題:「在DDD中,UoW和Repository要不要使用」?

 

***

Unit of Work(UoW)

Unit of Work和Repository這兩個設計模式都出自於Martin Fowler所寫的《Patterns of Enterprise Application Architecture》。Unit of Work顧名思義就是工作單元什麼叫做工作單元?就是把一連串的工作步驟,視為「一個單元」、「一整包完整的大步驟」。一個工作單元隱含一個交易邊界(transaction boundary)

舉個例子,在ezKanban裡面,有以下幾個使用案例:

  • CreateBoardUseCase
  • CreateWorkflowUseCase
  • CreateStageUseCase
  • CreateCardUseCase

每一個使用案例,都是一個工作單元,形成一個交易邊界。產生一個Board是一個工作單元,要嘛成功,要嘛失敗。同理,產生Workflow、產生Stage、產生Card,都是一個工作單元。很簡單,對不對!

ezKanban團隊使用ezKanban的領域模型開發了看板桌遊,這是另一個Bounded Context。在看板桌遊中,有一個CreateKanbanGameUseCase用來產生新的看板遊戲。這個使用案例的實作,呼叫上述四個使用案例。現在問題來了:「在看板桌遊的Bounded Context中,CreateKanbanGameUseCase是一個工作單元,它的執行要嘛成功要嘛失敗。但是因為CreateKanbanGameUseCase的實作方式是重複使用CreateBoardUseCase、CreateWorkflowUseCase、CreateStageUseCase與CreateCardUseCase,這四個使用案例各自是一個工作單元,要怎麼把它們用另一個更大的工作單元包起來?」

Unit of Work設計模式就是要解決這個問題,簡單講,就是把transaction manager注入給使用案例,而不是讓使用案例自己去控制。如此一來,最外層的使用案例負責控制transaction的開始與結束,內部的使用案例只是接受這個由最外層使用案例所注入的transaction manager。如此一來,便可以因應不同使用情境(Context)的需要,動態決定工作單元的範圍。

***

 

為什麼不要使用Unit of Work?

如果不管DDD,Unit of Work是一個很棒的設計模式。但是,在DDD中,Aggregate已經形成了一個交易邊界。如果在DDD中需要使用Unit of Work,則代表在某個Context底下,需要把好幾個不同的Aggregate放在同一個交易中。這不就和原本在DDD中「Aggregate形成了交易邊界」互相衝突了。

更進一步來看,在DDD中,Aggregate由Repository負責儲存與讀取。而「理論上」一個Repository可以各自採用不同的資料庫來儲存Aggregate。也就是說,如果你願意,可以將Aggregate當成一個微服務來佈署。如果在DDD中使用Unit of Work,則這些被放在同一個Unit of Work的Aggregate,就代表它們要綁在同一個資料庫中(除非使用distributed transaction,但採用這種做法的人很少,因為會造成效能問題),這就造成不同的Aggregate透過資料庫產生耦合。因此,Teddy覺得在DDD中,不應該使用Unit of Work。

***

Repository可以用嗎?

在Martin Fowler的《Patterns of Enterprise Application Architecture》書中,Repository代表Collection-Based的儲存體。也就是說,只要從Repository拿出物件,之後對於該物件的修改,會直接反應回Repository,使用者不需要呼叫save方法來儲存該物件。

在Vaughn Vernon所寫的《Implementing Domain-Driven Design》,進一步將Repository的實作分成Collection-Based Repository與 Curd-Based Repository。圖1為ezKanban所設計的Repository介面,採用Crud-Based Repository。ezKanban的所有Aggregate所對應的Repository都是採用相同的介面,只有findById, save與delete這三個方法。

不管是Collection-Based或是Crud-Based,Teddy主張,只要固定Repository介面,將其限制在單一Aggregate的新增、修改、刪除、查詢,這樣子使用Repository並不會有什麼太大的問題。

但是,實務上經常可以看到,很多開發人員在Repository身上加了很多查詢方法。如此一來,雖著需求演進,Repository的介面越來越肥大。你可以說,這種使用Repository的方式,違反了單一責任原則、開放封閉原則,以及介面隔離原則。

所以,只要固定Repository介面,將其餘查詢方法另外設計(在ezKanban中採用Inquiry設計模式來解決這個問題),在DDD中使用Repository是沒有問題的。

***

結論

以上,是Teddy近幾年開發ezKanban所累積的經驗。Unit of Work比較簡單,ezKanban壓根就沒使用過它。但是,針對Repository的使用方法ezKanban團隊重構了好幾次。一開始Teddy也是在不同的Concreate Repository中直接新增個別Aggregate所需要的查詢介面。但隨著系統越來越複雜,Repository也變得越來越亂,不容易理解其中的邏輯。後來,套用CQRS之後,把查詢、命令分離,保留最簡單的Repository介面。如此一來,使用Repository就沒有問題了。

***

友藏內心獨白:不是不好用,是你不會用 XD。

2023年6月11日 星期日

領域模型不要直接依賴Repository

June 11 19:23~20:38


▲圖1:將Repository放到Entities Layer(錯誤示範XD) 

 


前言

6/5~6/7到客戶家上【領域驅動設計與簡潔架構入門實作班】,有一位學員問了Teddy不少問題,像是:

  1. 為什麼Teddy建議Entities Layer的物件不要直接操作Repository?
  2. 為什麼有人說UoW(Unit of Work)和Repository在DDD裡面算是Anti-Pattern?
  3. 為什麼Teddy沒有用Specification模式?


這些問題,應該是有在下功夫研究DDD的人,才會提出來的問題。針對這三個問題,Teddy分三集來說明,今天先談第一個問題。

***

增加領域模型與測試複雜度

Entities Layer是Clean Architecture存放領域模型(Domain Model)的地方,它應該只表達問題領域的業務邏輯,儘可能與外在世界、框架無關。Repository是領域驅動設計(Domain-Driven Design;DDD)中,用來存取聚合(Aggregate)的設計模式。也就是說,Repository隔離了儲存層,讓它的使用者無需知道儲存聚合的實作細節。

Entities Layer知道Repository又怎樣?首先,這麼作讓Domain Model依賴資料存取介面,雖然這個依賴透過Repository介面做到依賴反轉,但「資料存取」的概念還是洩漏到Domain Model,增加不必要的複雜度。

這種不必要的複雜度增加,可以從測試的角度看出來。針對Entities Layer物件的測試,理想上就是傳統軟體測試所說的單元測試,而且是可以做到「測試隔離」(test in isolation)。這樣子的單元測試,因為與外在世界無關,所以可以跑得很快且可以單獨測試業務邏輯。

看到這裡鄉民們可能會想:「我有學過Test Double(測試替身),我可以在測試案例中注入Test Double,這樣就可以寫出與世隔絕的單元測試。」

使用Test Double雖然可以讓使用Repository的Entities Layer物件做到隔離測試,但是付出的代價就是單元測試變得複雜且可能出現重複程式碼。現在,要測試Entities Layer物件之間,都要在單元測試的Arrange階段先設定Repository替身,這會複雜化且重複Arrange區塊的程式碼。

***

弱化階層式架構

請參考圖1,如果將Repository放在Entities Layer,從Clean Architecture的角度來看,為了滿足相依性原則,BoardRepository必須是一個介面,然後在Interface Adapters Layer實作BoardRepositoryImpl。如此一來,雖然滿足相依性原則,卻造成了BoardRepositoryImpl跨層依賴於BoardRepository。雖然在鬆散式階層架構中允許跨層依賴,但Teddy認為這會弱化了階層式架構的一致性。

***

其他人怎麼說

請參考圖2,在IDDD書中也提到,不要將Repository注入給Aggregate。


▲圖2:Teddy的FB廢文1 

 

如圖3所示,在Unit Testing一書中也提到,在領域模型中直接使用資料庫(相當於Repository)會造成程式碼過度複雜。


▲圖3:Teddy的FB廢文2 

 

***

結論

只要程式可以正確動起來,設計沒有絕對的對、錯,但有合適程度的差別。關於這個問題Vladimir Khorikov有一篇很棒的blog: <Domain model purity vs. domain model completeness (DDD Trilemma)>,鄉民們可以參考。

***

友藏內心獨白:領域模型越乾淨越好。