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