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