作者 | 張哲
EasyModeling 是我在2021年圣誕假期期間開發的一個 JAVA 注解處理器,采用 Apache-2.0 開源協議。它可以幫助 Java 單元測試的編寫者快速構造用于測試的數據模型實例,簡化 Java 項目在單元測試中準備測試數據的工作,在提高編寫效率的同時,使單元測試更加整潔易讀。經過一年的維護,EasyModeling 已經在幾個 Thoughtworks 內部的項目上得到了應用,并迭代發布了幾個版本。
單元測試中的數據準備的困難
在企業級應用軟件開發項目中編寫測試代碼時,針對特定的測試場景,我們需要準備相應的測試數據,以驗證被測組件在給定輸入下的行為。在使用 Java 語言的項目中,這些準備測試數據的代碼體現為創建各種“數據模型類”的實例。這里的數據模型類,可以包括聚合模型(Aggregation Model)、數據傳遞模型(DTO)、值對象(VO)以及存儲模型(Persist Model)等等。無論是對服務組件的測試,還是對數據模型本身的測試,我們都無可避免地需要構建這些數據模型類的實例。
在項目的起初階段,準備數據的工作是簡單的,我們只需要調用數據模型類的構造方法,傳入適當的參數來創建實例即可。單元測試代碼的規模不會太大,也尚且清晰易讀。
但是隨著產品開發工作的展開,一方面,項目中使用的這些數據模型會變得越來越復雜;另一方面,測試場景也會變得越來越多。經驗上,在經過幾個版本迭代的企業級應用 Java 代碼中,我們通常不難找出一些擁有十幾個、甚至幾十個成員變量的數據模型類,并且它們之間還存在著復雜的相互持有、嵌套、繼承的關系。這些數據模型類往往都是項目中的核心組件,故而也成為單元測試需要重點關注的組件。相應地,在涉及這些數據模型的單元測試中,為準備測試數據而編寫的初始化數據模型類的代碼量也會越來越大、越來越復雜。
這些冗雜繁復的數據初始化代碼會影響單元測試本身的代碼質量,造成單元測試編寫成本高、易讀性差、易維護性低等問題。而單元測試的質量又與生產代碼的質量息息相關。例如,單元測試的編寫成本過高,會使開發者越來越傾向于僅在已有測試基礎上做修改,而不是為每個場景創建單獨的測試,造成單個測試的職責過多;甚至使開發者放棄單元測試,降低了團隊對產品質量的信心。又比如,單元測試的易讀性差,導致單元測試無法承擔起“測試即文檔(tests as documentation)”的職責。而單元測試的易維護性低,則導致了代碼很難被重構,從而單元測試不僅沒有為重構提供信心,反而變成重構的桎梏。
具體來說,這些初始化數據的代碼會引起三個方面的問題:
- 對測試場景的描述不清晰
- 構建測試數據的代碼重復
- 初始化數據模型代碼的膨脹
我們可以從下面的例子中略窺端倪。你是否在你的項目中見過這樣的單元測試?
圖片
這是一段典型的使用JUnit測試框架的單元測試代碼。在這段單元測試代碼中,被測對象是 leaveCalculator 組件的 annualLeave 方法。我們首先創建一位員工,如(a)處;然后將創建好的員工對象傳入 annualLeave 方法,為其計算出應得的年假數額,如(2)處;最后斷言他應該享有20天年假,如(3)處。為了簡化討論,我們暫且假設此處 annualLeave 方法的業務規則是:員工應得的年假數額只與這位員工加入公司的時間(date of joining)相關,即在代碼中 (1) 處初始化的日期。
我們來詳細分析這段測試代碼中存在的壞味道、以及其潛在的問題。
對測試場景的描述不清晰
如前文所述,我們假設這段單元測試代碼的目的是驗證“入職超過5年的員工應該享有20天年假”這個業務規則。那么顯然,其中只有 (1), (2), (3) 這三處是與當前測試場景相關的,它們共同構成了對上述業務規則的描述。而在 (1) 處之前傳入 Employee 類構造方法的那些參數都是與當前測試場景無關的。遺憾的是,這些與測試場景無關的代碼卻占據了這個代碼片段中的絕大部分代碼行。
在實際項目中,我們會見到很多這樣的單元測試,它們往往需要用幾十行的代碼來準備復雜的測試數據,需要初始化數個數據模型類的對象,以支持對被測組件的調用,然而這些代碼中真正在描述測試場景的,卻只有其中區區幾行、甚至一兩行。這不僅增加了測試的篇幅,還會導致閱讀者無法快速聚焦在有意義的初始化條件上。就像我們在這個例子中看到的,描述測試場景的代碼行(1)處混雜在大量初始化測試數據的代碼行之中,造成了單元測試對測試場景的描述不聚焦。這使單元測試的閱讀者很難從這段測試代碼中一目了然地理解測試的意圖,更遑論以測試為文檔來理解業務規則。而在測試失敗時,也無法快速從測試場景的數據構造出發去定位問題。
一些有經驗的單元測試編寫者已經注意到了這個問題,他們會在關鍵的測試數據初始化行末添加一些注釋以示強調。然而注釋本身就預示著代碼壞味道,并且在重構中也是非常不安全的,甚至反而誤導讀者。
構建測試數據的代碼重復
如果將目光從單個測試放大到單元測試組(Test Suit),我們會發現在針對同一個被測組件的不同測試場景下,初始化數據模型的代碼會大量重復。例如在針對員工年假數額計算(leaveCalculator 組件的 annualLeave 方法)的測試組中,假設按照業務規則,我們需要考慮以下的測試場景:
- 入職不足2年的員工,應該享有10天年假;
- 當年入職的員工,享有按照入職時間折算的年假數額;
- 入職超過2年,而不足5年的員工,應該享有15天年假;
- 入職超過5年的員工,應該享有20天年假;
- 入職超過7年的員工,應該享有25天年假;
- 入職時間在未來(尚未入職)的員工,不應該計算年假數額(拋出異常);
不難想象,我們會分別在這6個測試場景對應的測試方法中重復地編寫幾乎完全相同的代碼來初始化Employee類的對象。
這樣的單元測試模式在企業級應用開發的場景中比比皆是。開發者經常很容易在測試第二個場景時,順手從第一個場景的單元測試中復制初始化數據模型的代碼,略作修改來描述第二個測試場景,后面的測試場景也如法炮制。這樣顯然會造成測試代碼中存在大量的模板代碼(Boilerplate code),進一步降低了代碼的易讀性。
通常在開發項目的實踐中會引入構建者模式(Builder Pattern)或者 Object Mother 組件來消除這些模板代碼。本文非常欣賞這些解決方案,下文會在此基礎上做進一步討論。
初始化數據模型代碼膨脹
另外需要注意的是,前文舉例的代碼中為節省篇幅已經做了很多簡化。我們不僅用省略號折疊了(1)處之后可能傳入構造方法的更多的初始化參數,還折疊了在(b)處初始化 List<Department> departments 參數時逐個構造 Department 類對象所需要的大量細節,甚至在初始化每個Department類對象時,又另外需要構造更多的相關實例。
當然在實踐中,經常使用的策略是將大量無關的屬性設置成 null 或者空集合,但是這有時候會在被測組件對數據類有效性檢查中被攔截。特別是在某些演進了一段時間的代碼庫中,我們經常會遇到的困難是,由于在測試中構造數據時采用了過多的 null 和空集合,一個新添加的數據有效性檢查步驟或者切面(AOP),會造成幾百個單元測試的失敗。逐一修復這些失敗的單元測試的工作量無疑是巨大的,同時是充滿風險的,因為此時對單元測試的修改完全是為了兼容一個新添加的切面,而脫離了單元測試本身的業務上下文。
在這種情況下,開發者會越來越多選擇將相似的數據有效性檢查步驟散布在具體的業務代碼中,而非在構造方法中統一檢查、或者通過切面集中實現。可見,單元測試的不良設計,會反過來增加生產代碼的維護難度,拖累了生產代碼的演進。
EasyModeling提供的能力
造成開發者寫出類似單元測試的原因是廣泛存在的。例如,Employee 類沒有提供更靈活的構造方法,也沒有 Builder 模式的構造器。從 Employee 類自身的職責的角度出發,它的確沒有理由提供一個僅包含 LocalDate dateOfJoining 作為參數的構造方法。在很多業務場景下,數據模型類也完全有可能就是不允許通過 Builder 模式來構造的。我們當然不能為了編寫測試代碼的便利,而去修改生產實現代碼。又例如,代碼中可能存在對 Employee 類的數據合法性校驗。這些校驗可能是類似切面的形式存在的,導致我們無法方便地在單元測試中忽略它。
在實際項目中,開發者很容易從“消除重復”的角度,抽象出相應的工廠類來提供測試所需要的數據模型實例。Martin Fowler 也在他的博客的短文 Object Mother 中簡要討論了相關的思路。但是在測試中使用工廠組件雖然消除了很多重復代碼,卻沒有提供針對不同的測試場景的靈活定制能力,因此一些項目又會同時采用 Builder 模式來提供定制能力。我自己在多個項目上引入 Object Mother 來提供測試數據實例后發現,這些工廠類本身又具有非常固定的代碼模板,于是我開始考慮開發一個工具來自動生成這種工廠類。
受到 Builder 模式和 Object Mother 思想的啟發,我開發了 EasyModeling 來嘗試簡化 Java 單元測試的編寫,并提高測試的可讀性和易維護性。EasyModeling 是一個 Java 注解處理器庫,它主要提供三個方面的功能:
- EasyModeling在編譯期根據指定的數據模型類的結構,生成對應的數據模型工廠類,以方便單元測試快速生成數據模型類的實例。通過向 EasyModeling 注冊一個數據模型類,單元測試的編寫者只需要調用 EasyModeling 所提供工廠類的靜態方法,就可以立即得到這個數據模型類的實例。
- EasyModeling 還可以在單元測試的運行時,自動初始化它所生成的數據模型實例。在生成數據模型實例時,EasyModeling 默認的行為是給數據模型實例的字段填充隨機值,讓開發者不需要再耗費精力去填充對測試場景無意義的屬性。同時,開發者仍然有機會向 EasyModeling 指定每個數據模型類的每個字段所需的初始化方式。
- 另外,EasyModeling 還在其生成的工廠類中提供了一個 Builder 模式的構建器。利用這個構建器,開發者可以定制、并僅定制與當前測試場景相關的字段,使單元測試簡短、清晰、易讀。
在編碼層面,EasyModeling 的行為完全發生在測試包中,絲毫不會侵入項目的生產實現代碼。同時,EasyModeling 只會照顧開發者向它注冊的數據類型類,而不會在代碼庫中主動搜索。所以即使是維護已久的代碼庫,從任何時間點引入 EasyModeling 都不會造成額外的負擔。
EasyModeling簡化后的單元測試
在引入了 EasyModeling 后,本文中第一節中的單元測試例子可以得到顯著地簡化:
圖片
除此之外,如前文提到,開發者需要在測試代碼中向 EasyModeling 注冊 Employee 類:
圖片
首先我們看到,在引入 EasyModeling 后,單元測試的代碼在篇幅上得到了非常明顯地簡化。在單元測試中 (4) 處,EmployeeModeler 類就是由 EasyModeling 在編譯期生成的工廠類,通過引用 EmployeeModeler 類中的靜態方法 builder(),我們可以得到 Employee 類的Builder 的實例。請注意,此處使用的 Builder 類不是由 Employee 類自己編寫的,也不是通過如 Lombok 這樣的工具來提供的,而是由 EasyModeling 在其生成的工廠類 EmployeeModeler 來提供的。這樣的好處是,為了測試而準備的 Builder 完全沒有侵入生產代碼。
其次,在 (4) 處生成的 Builder 類的實例中,EasyModeling 已經為我們盡可能多地填充了所有的成員變量。因此,我們接下來只需要聚焦在當前測試場景所關心的成員變量上。例如在 (5) 處,我們將 dateOfJoining 字段的內容設置為指定的日期。在可讀性方面,由于避免了冗長的初始化參數,所以使開發者在閱讀單元測試時,能夠快速理解測試場景,進而也比較容易修改或維護單元測試。
第三,EasyModeling 在填充數據模型實例的屬性時,不僅能夠填充一些 Java 應用中常用的數據類型,包括基本類型、數組、集合、時間日期等等,還能夠進一步填充當前數據模型所引用的其他數據模型。例如 Employee 類中引用的 List<Department> departments 列表字段。
最后,為了讓 EasyModeling 幫我們生成 Employee 類的工廠類,如以上代碼中 (6) 處,開發者只需要在任意的一個類上通過 @Model 注解聲明即可。EasyModeling在編譯期為所有被 @Model 注解聲明的數據模型類生成對應的工廠(Modeler)類。
除此之外,EasyModeling 還提供了其他一些好用的特性,限于篇幅,具體的用法請參考文檔。
EasyModeling的不足和未來
但是由于我的業余精力和能力都非常有限,EasyModeling 目前還處于它成長的初期,存在幾點顯然的不足。
第一,沒有維護良好的使用文檔。目前我只維護了一份項目 Readme 文件,作為簡要的使用文檔,導致一些略高級的使用方法和一些從新版本開始支持的功能并沒有體現在文檔中。
第二,沒有維護文檔注釋。遵循代碼整潔的原則,在長期從事的企業應用開發中,我幾乎不會寫任何形式的注釋。所以我也沒有意識到,在維護一個更偏底層的開源工具庫時,充分的文檔注釋是非常必要的。一方面,文檔注釋便于開發者用戶查看閱讀,也便于有興趣的貢獻者參與開發。另一方面,由于這種較為基層的工具中無可避免地要使用一些魔法,如果沒有良好的注釋,隨著時間推移,可能連我自己也會忘記其中的細節。
由于 EasyModeling 是一個關注單元測試的工具,而不會入侵任何生產代碼,因此,在 Java 項目中引入 EasyModeling 幾乎不會對項目的可靠性、安全性造成任何風險。所以如果你對這個工具感興趣,認為它有可能幫助你提高編寫測試的效率,請不妨引入到你的項目中嘗試使用。
未來,由于我自己在項目上會持續使用 EasyModeling 來構建測試數據,所以我基本可以保證持續維護這個工具。在近期,我將聚焦在完善使用文檔,以及修復從用戶反饋的一些缺陷。在EasyModeling 的功能特性方面,雖然我手上目前依然積壓著一些我自己想要實現的功能,但是我更想從用戶的反饋中收集更多有趣的好主意,再來推進下一階段的功能演進。