測試覆蓋率治不好你的精神內耗

「Talk is cheap, show me the code」

在測試覆蓋率的討論里我越來越疑惑了。

有人聊起來如數家珍,彷彿它是程式碼品質的防腐劑,有了它在流水線上把關我們便能無憂無慮地做起甩手掌柜。說實話當他們在爭論閾值究竟應該設為60%,70% 亦或是 99%時,我搞不清這些數值背後帶來的影響有多大差異。因為我從來沒有帶目的去嘗試達成某個數值,也不知道在達成這些目標之後,會帶來何種效應。

在我看來檢驗他們振振有詞是否成立的不二方法,就是親身體驗一回衝刺測試覆蓋率的過程,當中經歷的付出和收穫是最公允的評價。

然而覆蓋率目標應該是什麼?如果我們都同意測試覆蓋率帶來的正面效應與數值成正相關的話,那100% 絕對具有說服力。可以想像當達到 100% 時,測試覆蓋率在發揮出所有功效的同時弊端也暴露無遺。反過來說,如果 100%測試覆蓋率的影響力依然不盡如人意,那麼它就是存在缺陷的。

在前段開始思考這個問題時我剛好有一個前端side project 正在進行中,它便是我實踐這個想法的最好對象。在寫這篇文章的當下它已經上線,並且源碼在此。整個項目源程式碼共計3952行程式碼,你可以從這個項目它的每一次執行的GitHub Action 里 Run unit tests 環節中看到,它的測試覆蓋率從分支到行數都是 100%。

以下記錄的文字便是在衝刺測試覆蓋率 100%過程中我的感受與發現的問題。我的結論是:我不反對統計測試覆蓋率,我反對的是把覆蓋率視為不可逾越的教條,以至於讓程式設計師也淪落為二等公民。

覆蓋率的原罪

測試都是有條件的,其中最重要的前提便是測試範圍,所以測試覆蓋率同樣有欺騙性。在我們討論它之前最好問一問自己,我所說的測試究竟處於測試金字塔的哪一層?

以剛剛提到的項目為例,參展整潔架構整體上我把它劃分成View(前端組件,React 實現)和 Model(業務邏輯,Zustand 實現)兩層,依賴關係上組件只單向依賴業務邏輯 。在編碼前端組件時,我有意將它設計為無(業務)狀態組件。這樣一來表現層便獨立於業務層,無論將來打算遷往 Vue 還是 Angular,核心業務邏輯都不用發生變化。

而我所說的100%,其實只覆蓋了所有的業務邏輯程式碼,考慮到我們還可以單獨對前端組件進行單元測試,現階段編寫的測試充其量屬於單元測試的一個子集。之所以不繼續進行組件測試或者將業務邏輯與組件結合起來做功能測試,眾所周知這是由成本決定的。當我們越企圖向測試金字塔的頂端(端到端測試)移動,測試成本就會越高,在實際工作中我們必須捨棄對部分程式碼的測試,這便是原罪。

所以當我們討論 100%時,從測試的廣義上看,它可能連 50% 的程式碼覆蓋率都還不一定達到。

原罪不應該得到譴責,因為它是一類必要的惡(necessary evil)。但我們依然有辦法將這份惡的傷害減少到最低,這需要回歸到對於測試策略的優化上。對於類似的項目來說,把資源全部投入到對於業務邏輯的驗證上其實是最優解,一方面因為相比交互上可能出現的問題,業務邏輯的風險更難以被察覺,也更與收益息息相關;另一方面對於交付複雜的應用,窮舉所有測試的可能性是不現實的。需要注意的是測試策略的制定不是在完成實現之後的一種補救,而是在整個項目開始前就要納入規劃中。可以想像如果我們在設計整個應用之初就決定不採用第三方狀態管理工具,而是直接用利用組件狀態去存儲業務邏輯,這種測試策略幾乎無法實施。

誰為覆蓋率買單

如果你追逐的是漂亮數字,恭喜你最終一定會如願以償,但可能這個數字早已百孔千瘡。不妨看項目里的下面這一條語句:。

state.elementCollection[targetId].border[position][name]= value;

這條語句的本意是從一堆DOM 元素集合(elementCollection)中,修改指定元素(targetId)的特定位置(position)上的邊框屬性(name)

為了滿足行覆蓋率,我們必須要讓測試程式碼執行到這一行。「執行到這一行」其實非常容易做到,比如我們把變數填充之後即可:

state.elementCollection["guid"].border["shadow"]["color"] ="Lee";

但這樣的執行結果是完全沒有意義的,DOM元素的邊框樣式里根本不存在 shadow 屬性,即使存在,你把「Lee」賦值給 color屬性也是完全說不通。除此之外對於元素、邊框是否存在的可空判斷也是很明顯的程式碼缺陷。但測試覆蓋率並不關心執行的「意義」和「結果」,它認定「路過」即可。

覆蓋率潛在的好處是,理想情況下,當開發人員通過覆蓋率發現這行程式碼被遺漏之後,他應該意識到這行程式碼是有風險的,需要被及時修正。但開發者同時發現自己面臨一個窘境:如果我要修補這個漏洞,就要補充實現程式碼,新程式碼又降低了當前的測試覆蓋率,為了覆蓋率我要繼續寫更多的測試。

你覺得他願意按照我們期望的情節去付出嗎?

很簡單這取決於我們有沒有給到他足夠的空間。我們追求覆蓋率的本質是追求好的產品品質,但你要意識到品質是昂貴的,如果你只是想要覆蓋率卻把成本給開發者自己承擔,大家都不傻,那麼你得到也只是數字罷了。

測試表面上是面向過去的:我要驗證我之前寫的程式碼;但測試的本質上卻是面向未來的:我要怎麼把它做得更好;測試覆蓋率很容易讓人迷失在過去之中。

自欺欺人

在項目里有一類程式碼幾乎是毫無技術含量的模板程式碼,比如我們可以看與這份有關 store 的實現,這個文件里的程式碼幾乎都遵循同一種模式:

export constuseUIStore = create(persist((set) => ({
 property: false,
 setPropertyValue: (value) => set({property: value }),
})

這種模式在整個文件中被重複了11次,同樣如果你去看它所對應的測試文件,它也遵循了某種被重複了11次的測試模式。

拋開這類程式碼的嚴謹不說(比如沒有對值進行校驗),假設有一天我們的實現程式碼真只有這麼簡單,我們還有沒有必要對其做測試?

我的意見是,如果是出於守護目的進行黑盒測試我贊同(但話說回來用如果要守護做端到端不是應該更好),但是為了滿足測試覆蓋率沒有必要,因為一眼可以看出來這些實現程式碼毫無風險或者說儘是風險,無論哪種情況追求覆蓋率起不到任何效果。

編寫這類「低收益」程式碼讓我擔心的另一點是,在實際項目中它們所佔用的比例會不會過於可觀,而我們又難以保證時間投入帶來的收益——當我們成倍時間投入了完善的時間之後,我們的 bug數量是否也能得到成倍地減少呢?

animation.js 這個腳本為例,在我補全測試的過程中,當我把主線補充完畢之後,我發現剩下的全部都是類似於 if (!value)return之類的語句。但說實話在我看來爭取這類程式碼行的覆蓋是沒有意義的,因為對於這些邊界情況我認為大部分時候 return並不是最好的處理方式,這些測試並不能發現問題,同時也無益於我考慮這個問題。

最後

別被我繞暈了,讓我們回歸到最簡單的邏輯上來:一個擁有30%測試覆蓋率的項目當然要比 0%測試覆蓋率的項目優秀,但它是否等同於產品層面的優秀以及它能否傳達到用戶的感知上我表示懷疑。數字是一個很好的指標,它會告訴我們剩下 70%程式碼可能存在風險,它的急劇波動應該引起我們的警覺,但它不應該成為指導人寫程式碼的標杆。

測試或者 QA不應該是被全組人都寄予希望的品質保險——哪怕在設計產品之初,如果它能更像是擁有按鈕的計算器而不是任意拖拽的 Photoshop,它的品質都會更有保證。

你可能會喜歡