程序員可以自己寫測試?還需要測試人員嗎?

在向開發人員介紹單元測試或TDD等工程實踐時,往往可以聽到這樣的疑問。比如:

自己寫的程序,自己無法從另一個角度測出問題。寫bug的時間都不夠了,哪有時間來寫測試?開發來寫測試了,測試幹什麼?除了核心的代碼,沒有什麼值得測試的。……
本篇想要通過探討這些問題背後的困難,來說明程序員怎樣通過編寫自測代碼更有效率的進行開發。

一個例子
首先我們看一個例子。全項目唯一的測試

不止一次,我在各種項目中看到這樣的測試,往往這也是整個工程中唯一一個測試。

可以看出,開發者認爲編寫是有必要的。所以按照“標準”的做法建立了測試目錄,引入JUnit依賴。並且利用它在開發的初期來驗證某些技術疑問,一般是某些當時還不熟悉的第三方庫,或者數據庫、中間件等外部依賴。

簡單而言:“寫測試是應該,但我們的代碼沒什麼好測的”

測試,不僅僅關於未知
說起測試,往往與未知相關聯。我們通過試驗、調試、檢測來獲取獲取反饋,不斷調整。

以上圖爲例,一般想到的測試,都集中在“已知的未知”這個象限。正如前面的示例代碼,使用不熟悉的庫帶來未知。程序員通過在測試中調用和觀察結果來消除未知。

然而,對於自動化測試來說,其實關注點在於已知。

“都已知了,還測試什麼呀?”,也許你會有這樣的疑問。

火柴問題
火柴,這種行將消失的物品。也許現在的小朋友只是在《賣火柴的小女孩》中才得知它的存在。在我小時候,還是時常用到的。那時,也許是工藝問題,或者存儲條件有限,往往一盒火柴好多根都不能點着。

記的那時聽到的笑話:

×××的媽媽讓他去買盒火柴,不一會功夫買回來了。媽媽問:“你試過沒有,能點着嗎?”

“試過啦”,×××很驕傲的說,“每一根我都試了一遍。”

我把這種問題稱爲“火柴問題”,往往傳統的質量控制面臨的都是這類問題,有如下限制:

成本,顯然現實中不會有人把所有的火柴拿來測試。不過問題的本質並沒有變,在花費的成本和獲得安全保證的完全性之間取一個平衡。
事後,造出火柴後纔有能否點着的問題,
因而,
一次性,成本換取的安全是一次性的,每當一個批次到來時,以前的測試的付出都成爲了沉沒成本。
另一種測試
讓我們來看另一種關於已知的測試。

比如每天出門的時候,我都會自然而然的檢查一遍,手機、鑰匙、錢包。就是個簡單的清單。

清單是關於已知的,只有十分確定的事項纔會列入在清單裏。

清單本身很簡單,並不能回答火柴問題這樣的難題。但是不代表它沒有作用。

以出門爲例子,有時出門是每天都在做的上班通勤,有時是去面臨某個很大的未知,比如去見一個陌生的客戶,進行重要談判。

這時如果有個水晶球,告訴你會成功失敗,甚至告訴你怎樣做才能成功,那就太好了。

然而沒有水晶球。

一個簡單的清單至少保證你不會走在路上才發現忘帶手機。無論未知的挑戰是什麼,忘帶手機基本上不會產生任何幫助。

切換回軟件開發的場景,程序員夢想中的完美測試也許能告訴我們未知,甚至未知的未知結果。這在目前還不現實。那麼寫一個測試確保你在不斷調整中不破壞正確的事情,仍是值得的。

可以看到,這種視角下的驗證,與檢查火柴有所不同:

預防,這種校驗着眼於未來,是爲了避免更大損失的投入。
過程中,檢查是做事步驟中的一個環節。
反覆,越頻繁的行爲越有必要進行校驗,校驗的越頻繁潛在收益越大。
假定你是獨自居住,出門前還是鎖門後發現沒帶鑰匙的成本,會有一個巨大的飆升。往往檢查列表都是在這種成本拐點前進行的。

應對這種猛增的成本曲線有三種方式:

拉平曲線,通過技術改進使原本難以挽回的決定變得不那麼昂貴。
優化待檢查項目,比如現在出門帶錢包已經不那麼重要了,有手機即可。如果把門換成掃碼開鎖,那麼鑰匙也免了。這樣需要檢查的項目越少,越不容易遺漏。
自動化,比如遺漏了東西就有提醒警報,自然大大降低了犯錯的可能。
自測給程序員帶來什麼
敏捷方法論的一個基礎,就是現代軟件開發方式已經使軟件變更的成本曲線大大平緩了。我們可以看看開發者的自測在其中起到的作用。

對照上面兩個列表,可以回想一下

在最近的開發活動中碰到各類錯誤的比例是多少?
由於反饋時間和定位手段不同,解決錯誤花費的時間有何不同?
有多少最初百思不得其解的錯誤,長時間摸排後定位爲一行修改即可改正的弱智錯誤?
如果這些錯都在第一時間發現,以明顯的方式報錯會怎麼樣?……
從加快反饋,幫助定位的角度思考,也許你會找到更多值得寫的測試。

記錄
常玩遊戲的同學都熟悉要時常存盤,可以讓我們安心挑戰boss,大不了失敗時返回安全點。

那麼代碼呢?Git,SVN等代碼管理工具使可靠的保留代碼歷史成爲可能。然而,如何在歷史中找到安全點呢?(題外話,你有嘗試過Git bisect命令麼)

記錄還帶來了另一件事,覆盤。

沒有記錄也就無從系統的進行回顧和改進。對於編碼,我們往往只能看到最終的結果。這大概也是編碼活動在軟件開發“工程——藝術” 圖譜中最偏向與藝術這一極的原因吧。

頻繁提交的代碼歷史,加上表達行爲變化的測試,會使原本大家熟視無睹的進程如實呈現出來。有興趣的話可以看看這篇cyber-dojo設計者的講演,我們甚至僅僅觀察測試變化的情況就可以對一段程序編寫的過程有個大致的瞭解。

可以通過測試改進的點
把main函數改爲測試
有經驗的開發者大多都知道寫出的代碼都至少要運行驗證一遍。然而運行代碼有時並不那麼簡單,有的要以特定的方式部署,有的需要複雜的前置流程才能觸及。爲了高效的運行代碼,我們會採用一些手段,比如爲目標代碼增加一個main函數,這樣就可以直接以希望的輸入執行想要的操作,並觀察結果。

這種調試技巧可以很容易的用測試來改寫,如下圖所示。

在基本不增加工作量的前提下,帶來如下收益:

明確的分離了調試代碼和生產邏輯。
避免誤導後來維護代碼的人,也防止把測試代碼發佈到生產環境產生隱患。
抹平了“調試期——維護期”的成本差異。
main方法的往往是在調試階段使用。開發人員反覆調整輸入、觀察輸出、修正代碼,直到開發完成。之後這段調試程序就成爲了過去時。後來者無法判斷這段腳手架代碼是否還符合最新的邏輯,是否可以運行。
而測試代碼在每次構建時都會自動檢查,保證代碼保持上次變更後預期的邏輯。爲開發者保留了一個調試現場,是否“開發完了”並無顯著差異。
測試可以記錄多種用例
使用調試方式,我們往往在確認完一個行爲後修改輸入,觀察其它行爲。因爲預期這是一次性的工作。
用測試可以在不同的用例中描述行爲的不同側面。方便維護者理解代碼,也避免了,“咦,這個bug我明明測過呀”的迴歸錯誤。
測試明確寫出了期望的行爲。
通過assert,測試明確的寫出可以自動判別的行爲。而不是main方法中通過肉眼來閱讀理解程序行爲。寫出預期會帶來如下改變:
幫助閱讀者理解什麼是代碼“應該的”行爲。
促使開發者思索代碼的目的是什麼,會怎樣被使用。
自動判斷節省了開發者的注意力,更有效的反饋錯誤,定位錯誤。
用隔離依賴代替調試“高仿”代碼
所謂高仿代碼,是指與實現代碼非常接近,但是稍有不同的代碼。

往往在調試時,目標代碼並不是純粹的邏輯處理,還會涉及到其它的外部依賴。這些依賴可能要單獨部署配置,甚至根本無法在開發環境獲得。

爲了對付這種情況,一個顯而易見的方法是把目標代碼copy一份到調試代碼處,修改依賴相關的部分。比如下圖就演示了一段代碼,需要根據外部依賴判斷執行某操作,並更新數據庫。爲了測試執行操作的邏輯,開發者copy了代碼,註釋掉與環境相關的代碼。

另一種類似的處理方法,在每次調試時臨時修改目標代碼,調試結束後再恢復。

這種情況,只需要結合mock框架對外部依賴進行模擬,就可以在不改變目標代碼的情況下在測試中改變代碼行爲。如上圖所示。

這種做法有避免了顯而易見的問題:

copy代碼方式在經歷修改後,不能保證於實際生產代碼一致。
臨時修改代碼有事後忘記恢復的風險。
除此之外,還有些潛移默化的收益:

使隱含的輸入輸出更加明顯了。
比如例子中的代碼,從外部看起來只有一個字符串輸入一個字符串輸出。通過測試可以明確的看到,事實上輸入還有從外部依賴獲取的布爾值,輸出還有對數據庫的操作。
促使代碼向鬆耦合、單一職責演化。
有時候爲了在測試中mock隔離依賴,會需要對實現代碼稍作重構。短期看來似乎寫測試引發了更多的工作量和變更,但這種變更一般會使代碼向職責更明確,模塊間更鬆耦合的方向改變。促使開發者在設計層面更多的思考。
用測試來增強註釋
適當的註釋能極大的增強代碼的可維護性。好的註釋描述代碼在做什麼,而非怎麼做的。

對於複雜結構的處理,往往看代碼千頭萬緒,摸不着頭腦。註釋裏附上示例數據,馬上讓人對代碼的大致行爲有所掌握。

將這種註釋中的樣例放入測試中,可以:

避免代碼修改註釋無人維護的問題。
把不同的輸入和對應輸出一一對應起來。
利用自測促進開發
前面說了一些通過自測手段對已有工作方式的改進。事實上在熟悉掌握這些手段後,可以更進一步,主動利用測試來完成原來不能高效做到的事情。

分解“已知的未知”
對於未知的解決方案,有時是由於我們對於相關技術瞭解有限。也有一種情況,技術方面已經確定,但是由於問題較爲複雜,一時看不到解決方法。

面對這種問題,一般的做法是構造式的。也就是說從自己知道的方案出發,看看需要增加什麼來接近目標,增加後調整使整體一致,再次看需要增加什麼……

還有一種分解式的方式。假定已經有了一個解決方案,從中選取一個子集,解決這個子集,然後選取下一個,直到完全解決。測試就很適合在這種方法中對問題進行分解和檢驗。

在最近的一次練習中,我就體會到即使沒有開始編碼,測試也能對解決問題起到幫助。

練習:寫一個函數,判斷兩個字符串是否同構。所謂同構,是指字符串A可以通過字符替換變爲字符串B。比如Hello 與 Apple,不同構Hello 與 Speed,同構
有興趣的同學可以自己嘗試嘗試,能否通過測試逐步分解問題找到解決方案。

提示:從最簡單確定的問題開始,比如一個字母的字符串如何判斷。
顯現“未知的已知”
有多少次,當你正在開發調試的過程中,發現了某種更好的做法。然而思索後你對自己說:“已經差不多寫好了,算了,還是以後再改吧”。即便這個改動只是給函數起個更貼切的名字而已。

而我們都知道,以後往往等於永遠也不會。

造成這種狀況的,除了我們固有的弱點,比如拖延、圖省事外,有個很重要的原因是難以評估改變的影響。還記的前面錯誤反饋列表麼?如果幾個月後纔會知道有沒有問題的改動,就算再簡單我們也會避免的。這就是遺留代碼的處境。

衆所周知,不產生bug的最佳方式就是不寫、不修改代碼。當然這是不現實的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章