l

2021年3月10日 星期三

用合約彌補測試案例的不足(上):撰寫合約

March 09 23:05~March 10 00:55


工商服務

在套用領域驅動設計的時候,你的物件導向領域模型都生不出來嗎?針對領域模型的狀態正確性感到很棘手嗎?你想透過撰寫合約來增加領域模型的正確性嗎?

你需要補充基本的物件導向養分,歡迎參加2021年4月20日(二)的【敏捷開發懶人包:物件導向技能】課程。

***

前言

前一陣子Teddy與ezKanban團隊將ezKanban系統重構成能夠支援event sourcing,在重構的過程中,不免破壞了一些原本正常執行的測試案例。由於被影響的測試案例數量太多,雖然都是一些介面修改的小地方,但是數量一多還是很費工夫。

這個現象讓Teddy注意到原本的測試案例可讀性有點降低的趨勢,於是花了幾天重構測試案例。在這個過程中,Teddy發現雖然目前ezKanban系統一共有380個測試方法(test method),但還是有一些程式行為沒有涵蓋到。

另一方面,有些測試案例感覺又過於簡單,簡單到幾乎不會出錯,好像也沒什麼撰寫的必要,但是不寫又覺得心不安。例如,修改Board名稱的單元測試,最原始的程式碼就只有:

this.name = name;

簡單到不會出錯的程式就可以不用寫自動化測試,但是,因為採用領域驅動設計的方式開發ezKanban,所以除了this.name=name以外,還要發出BoardRenamed領域事件,這也增加了另一個需要測試它的動機。

因此,Teddy想到很多年前學過的Design By Contract(DBC;依合約設計),透過撰寫合約可以定義程式的行為,並在runtime自動驗證這些合約是否成立。若合約被違反,便代表程式有bug。合約撰寫越完整,可以大幅減少撰寫測試案例的數量。

這兩集Teddy要談在ezKanban中撰寫合約的方式,以及如何透過撰寫合約找到新的bug,之後再談如何簡化單元測試。

***

準備撰寫合約工具

合約有三種:precondition(前置條件)postcondition(後置條件)class invariant(類別不變量)。要在程式中撰寫合約需要程式語言支援。但可惜的是知名的商用程式語言除了Eiffel以外,Teddy就沒有聽過其他語言有內建支援合約。

退而求其次,如果有工具、框架支援,也是可以。但支援DBC的框架雖然有好幾個,但感覺都是實驗性質的成分比較多,而且很多都已經不再維護。

結論就是,離開Eiffel語言以外,先不要在其他程式語言追求完整的DBC支援。先求有,再求好。Teddy就是秉持這個精神,開始在ezKanban中撰寫合約。

一條合約就一個assertion,中文翻成斷言,也就是結果為真的敘述。講白了合約就是一個結果為true的if條件式。

例如,以下敘述都是assertion:

  • Teddy是泰迪軟體創辦人
  • 台灣中南部現在缺水
  • Pascal是Teddy收編的貓
  • 海水是鹹的
  • 你走路的速度小於光速


所以只需要把一個if條件式包在代表precondition與postcondition的method裡面,就可以用最陽春的方式撰寫合約,如圖1所示。

【圖1】土炮撰寫合約的Contract類別

***

合約範例1

【圖2】Board類別的rename method


先看一個簡單的範例:幫Board類別的rename method寫合約,如圖2所示。因為ezKanban套用了event sourcing,54~59行代表發出BoardRenamed領域事件,真正設定board name = newName 的程式碼在另一個私有方法handleBoardRename身上,如圖3所示。


【圖3】BoardRenamed領域事件的處理程式

圖3是rename方法的實作細節,訂合約的時候不用去理會他,把焦點放在圖2,幫rename撰寫合約:

precondition:

    requireNotNull("Board name cannot be null", newName);

postcondition:

ensure(format("Board name is '%s'", newName), getName().equals(newName));


這段合約表示:如果呼叫rename給定一個非null的字串newName(precondition的內容),則rename執行完畢後,Board name就會等於newName(postcondition的內容)。這也就是rename的語意

看到這裡Teddy要請問一個問題:「Board name可否為空字串?!

依據precondition,board name是可以接受空字串。如果你們的商業邏輯是規定board name不能是空的,就需要再加上一條precondition:

require("Board name cannot be empty", 
!newName.isEmpty());

***

合約範例2

最後看一個複雜一點的合約,Workflow類別的moveLane方法,如圖4所示。一個看板的Workflow(工作流程)可以有多個Lane,每個Lane代表一個工作步驟,它底下可以有多個sub lane。


【圖4】Workflow類別的moveLane方法

moveLane的:

precondition

  • lane id不能是null
  • new parent id不能是null
  • order是正整數
  • User id不能是null
  • 要移動的lane必須存在workflow裡面
  • 要移動的新位置(new parent)也必須存在workflow裡面
  • 不能移動到自己身上,或是自己的子節點

postcondition

  • lane的parent id等於new parent id(確定有移動到新的節點之下)
  • lane的order等於new order (移動後的順序是對的)
  • lane從原本的parent移除(確定lane已經不存在原本的parent,是移動而非複製)
  • 新parent的子節點都已排序
  • 舊parent的子節點都已排序

以上,如果合約訂得夠完整足以代表程式的行為,測試案例只要去呼叫這些method即可,不需要在測試案例裡面寫太多assertion,因為合約正確就代表程式行為正確

但前提是,你的合約要寫對。因為合約也是程式碼,也可能會寫錯XD。

***

友藏內心獨白:既使沒有程式語言的支援,合約也是一種很棒的設計方法。

1 則留言: