淺析前端測試的反模式
過于關(guān)注實現(xiàn)細節(jié)的測試
在為前端項目編寫測試用例的時候,你也許和我一樣,曾遇到過以下困擾:
(1) 明明進行了功能正確的改動,測試卻掛了。修復測試有時候得認真閱讀各種mock的細節(jié),或者去了解很多本沒有必要知道的代碼邏輯。最后修測試花的時間比進行業(yè)務(wù)改動花的時間還要長(甚至長很多)。
(2) 對代碼進行提取抽象之后,為各個組件或函數(shù)添加測試,實際上是用測試工具的API去重復 業(yè)務(wù)代碼的內(nèi)部實現(xiàn)邏輯(有時候還很麻煩!)。任何正常的重構(gòu)都會導致測試失敗,你本來希望測試能告訴你什么樣的修改是對的,結(jié)果現(xiàn)在測試只能告訴你代碼確實有被修改。
(3) 測試寫好,覆蓋率提高,本應信心十足地認為代碼變得健壯了,可是捫心自問,你知道自己寫的這個測試弱點在什么地方,或者說還有多少細節(jié)沒有涵蓋。你精心模擬了一個條件,去觸發(fā)邏輯流程,并且測試通過,可是在真實的瀏覽器交互中用戶也許并不能觸發(fā)這個條件。因此,同樣的道理,你在自己的代碼通過了他人寫的測試之后,也不能確定真實場景下沒有問題,只好把后續(xù)的重任交給QA。
造成上面三個問題的原因不止一個,但測試過于關(guān)注實現(xiàn)細節(jié)在我看來是最主要的。
第一個問題,明明是正確的改動,可是測試不止是驗證業(yè)務(wù)功能,還對實現(xiàn)細節(jié)提出了不該提出的要求,比如要求你的函數(shù)接受跟以前一樣的參數(shù),返回值必須是字符串而不能是數(shù)組等等。可是這個函數(shù)只是實現(xiàn)流程中一個小小的環(huán)節(jié),也許在下次重構(gòu)時就會不復存在。
第二個問題很類似,如果測試代碼去重復實現(xiàn)細節(jié),不管進行正確還是錯誤的重構(gòu),你都得把測試改一遍,那原先的測試又能提供什么價值呢?
第三個問題有時發(fā)生在,測試的實現(xiàn)細節(jié),不能覆蓋整個真實交互流程的時候。用戶點擊的是屏幕上的button按鈕,而測試的起點是onClick事件被觸發(fā)。后面的邏輯被驗證成功,可問題偏偏發(fā)生在點擊環(huán)節(jié),真實的點擊也許因為按鈕狀態(tài)而無法觸發(fā)onClick事件。
因此,才會有人提出前端的測試應盡量去模擬真實的用戶行為,Testing-Library就在其官網(wǎng)的“指導原則”章節(jié),鼓勵使用者盡量仿照應用真實的使用方式去編寫測試,并明確提出,你的測試越接近用戶的真實使用方式,它就能給你越多的信心。換句話說,你的測試應該盡量少用函數(shù)去手動觸發(fā),而要盡量多地利用測試框架給你的API,去模擬Input框的輸入,按鈕的點擊,表單的提交等等。
如此一來,有的函數(shù),你也無需寫測試證明它的返回值如你所愿,需要寫的,是頁面顯示了期待的文字,發(fā)生了預期的變化,進行了對應的跳轉(zhuǎn)。你會發(fā)現(xiàn),這時的測試就像寫在卡里的AC一樣。只要測試是通過的,你就有理由相信主體功能沒有破壞,而不只是函數(shù)工作正常。
沒有獨立業(yè)務(wù)含義的測試單元
看到上面的方案,你可能會立馬會想到一些問題。
首先就是測試流程可能會很長,從用戶填完表單,點擊提交,到期待的變化出現(xiàn),當中可能經(jīng)歷了好幾個函數(shù)的執(zhí)行,連帶著一系列的副作用。模擬這一系列行為,似乎是集成測試與E2E測試該干的事情。如果項目中大部分邏輯都是由這種測試去覆蓋,看起來與測試金字塔所說的由單元測試作為地基是矛盾的。
我認為,當真實遇到的問題碰到了某種教條規(guī)范時,后者該適當?shù)刈尣健?/p>
鼓勵多寫單元測試的原因在于它們成本低,有針對性??墒窃谇岸隧椖坷锩?,很多形式上的單元并沒有獨立的業(yè)務(wù)含義。
拿React項目舉例,好多函數(shù)只是因為它們在形式上可以被抽取出來,就被拎到一個單獨的文件里,從而降低主函數(shù)的復雜度。如果給它寫單元測試,你就不得不手動觸發(fā)它的參數(shù)變化,或者檢測它的參數(shù)函數(shù)是否有被調(diào)用。
我們寫的React hook尤其如此。很多時候抽取自定義的hook是出于邏輯上的原因,把相關(guān)的邏輯和數(shù)據(jù)聚合到一起,減輕UI組件的負擔,但這些hook往往沒有一個可以輕易解釋清楚的業(yè)務(wù)含義,而且它們也不會被其它地方使用。
所以這類 “單元”只是長得像單元而已,它們其實只是一個實現(xiàn)環(huán)節(jié)。這里完整的UI操作流程,才更像一個有價值的單元,盡管它們在形式上可能超越了單個函數(shù)的范疇。
但我不想矯枉過正,確實有不少情況下,一個util函數(shù),一個hook,一個很小的公共組件,都是有獨立存在的價值的,因此,它們也應當被視為真正的單元,確實“有資格”擁有自己的專屬測試。
testing-library下面有一個單獨的庫,叫react-hooks-testing-library,讓你無需通過UI行為層面,而是直接以hook的方式去測試它們。它的GitHub頁面上,明確提出了使用以及不使用它的場景:當你的hook不與組件強相關(guān),擁有獨立含義時可以使用;當你的hook只被一個組件使用,且和它的定義強相關(guān)時,則不建議使用。
【插入一段:盡管存在react-hooks-testing-library這樣的工具,但像SWR這樣優(yōu)秀的三方庫,在用testing-library為自己的hook API做測試的時候,依然選擇在UI層面進行。方法是,把自己的hook置于一個臨時的div標簽里進行render,把數(shù)據(jù)的變化映射成html文字的變化,最后對文字內(nèi)容做斷言。其實對于獨立性強的函數(shù),個人覺得放置在UI里面做測試倒沒有太大區(qū)別,但SWR的例子體現(xiàn)了對“仿照真實使用場景去測試”這一原則的尊重?!?/p>
將上面的規(guī)律套用到Angular項目中,也是類似的。對于獨立性和通用性不強的pipe,directive,reducer,effect,service,都可以認為它們是實現(xiàn)流程的一部分,從UI行為層面寫好測試即可。
總之,在構(gòu)思前端測試的時候,與其死守“單元測試”的字面含義,不如結(jié)合實際場景,重新思考什么才是真正有價值的“單元”,因地制宜地去寫。換種角度表述,與其在意我們寫的測試是不是“單元測試”,不如追求更核心的東西——我們的測試有沒有以合適的方式去校驗邏輯。
另外,當我們的“單元”過大,一些邏輯可能就會覆蓋不上。像sonar這類工具,不僅會檢查你的行數(shù)覆蓋率,還會檢查你的各項條件語句是否有被測試執(zhí)行。當一套測試的行為流程囊括了多個函數(shù),而且每個函數(shù)都有好幾個if…else語句時,想要在UI操作與mock數(shù)據(jù)上把所有情況都覆蓋到,成本就會變得非常高昂。
對于此,我們得承認,無論用什么方式組織測試,覆蓋所有的條件分支都是不太現(xiàn)實的,而且價值也不大。對于“滿足條件A就執(zhí)行XXX”之類的語句,條件為非A時沒有業(yè)務(wù)上的規(guī)定,如果為了刻意覆蓋函數(shù)的所有條件,就強行測它在非A的情況下返回一個undefined,則沒有太多價值。對這類情況,用UI行為測試主要條件即可,如果你實在覺得有重要的邏輯沒有被覆蓋,不妨回過頭來想想,是不是漏掉了某種輸入條件,例如特定的用戶鍵入或者特殊的API mock返回值。但是,當有過多的條件分支很難用業(yè)務(wù)場景去表述和模擬的時候,我們可能需要重新思考代碼的實現(xiàn)邏輯是否合理了。
當然,即使按上面這樣做,有時候還是會發(fā)現(xiàn)要覆蓋的條件組合太多,從行為流程上寫測試太復雜,這時就不得不做一定的妥協(xié),為那些沒有獨立性的部分去單獨寫測試。如果這類測試不太好寫,可以參照剛才提到的SWR官方測試用到的技巧,把要測的函數(shù)或者是對象放置在一個臨時的UI組件下,以最小的成本做UI行為測試。
最后
總結(jié)一下上面談到的幾個原則:
(1) 從真實用戶的行為流程去測試,往往比測函數(shù)本身,能給你帶來更多的信心。
(2) 對于沒有獨立性和通用性的函數(shù)或?qū)ο?,把它們視作實現(xiàn)的一部分,一般沒有必要為它們?nèi)憜为毜臏y試。不要拘泥于對“單元測試”的字面理解,不要被形式上的規(guī)律所束縛。
(3) 不要把測試覆蓋率視為太過重要的指標,它的目的還是幫助提升代碼的穩(wěn)定。有的代碼沒有覆蓋也沒關(guān)系,有的代碼值得你覆蓋好多遍。畢竟,我們不是為了寫測試而寫測試。
【本文是51CTO專欄作者“ThoughtWorks”的原創(chuàng)稿件,微信公眾號:思特沃克,轉(zhuǎn)載請聯(lián)系原作者】