我在2010年寫了「程式內的防呆之道」; 現在讀來, 覺得內容略嫌冗長, 恐怕性急的讀者不想看。所以我就把我最近寫的一個 Unit Test 程式拿來當作實例操作, 順便聊一下「開發」與「防呆」二者如何可以相輔相成。
雖然我在「程式內的防呆之道」一文中一再強調「防呆」和「測試」是兩回事, 但是並沒有說二者不能同時進行、相輔相成。特別是在這一篇裡, 我要舉的例子是很難有什麼偉大的 bug 存在的小程式。但是再小的程式也需要防呆, 所以等一下我們會看到有哪些你原本想像不到的「呆」會產生, 而且可以透過測試程式讓它們一一現形。
有看過我文章的讀者們可能知道我個人的背景。我是當過 SQA 和 Tester (還有 PM 和客服) 之後才擔任全職開發者的 (不過我原來就是資工本科系畢業的)。所以我很推薦 TDD (Test-Driven Development) 精神。可惜的是, 我似乎從來沒有成功地向任何人推銷過 TDD。就算有些公司明文規定要推 TDD, 但是大多數開發者都只是做做樣子而已, 一點誠意也沒有。
這讓我想來寫一篇文章, 既能舉實例來推廣 TDD, 又能展示如何防呆的思考方向。希望這樣能夠讓更多人理解預先測試的重要性。
測試先行
我手頭上有幾個性質雷同的 JavaScript 小專案, 原本都是個別開發的, 彼此無關。但是這些小專案後來愈長愈多, 裡面有一些公用程式愈來愈難維護, 於是我就想把一些可以共用的程式整併起來, 集合成一個 Utility 專案。
由於上述的小專案都很簡單, 所以這個 Utility 也很簡單, 規模並不龐大。我既用不到市面上既有的 framework, 這個 Utility 也不會成長到可以稱為 framework 的地步。
不過, 規模再小, 也要做測試; 何況它現在已經變成會在不同專案、不同情境下被呼叫的程式庫了, TDD 精神一定得套用上去。
我把原來的各個小程式集中到 Utility 專案裡當作雛型, 在正式進行修改前, 我先寫了一個 UnitTest.html 來做個別程式的 unit tests。如果你不是個人開發者而是一個團隊, 那麼你們可能已經採用了自動化的測試方式; 但是不管是開發者或者測試者, 都能使用我將介紹的方法來協助產生測試用的樣式 (pattern)。不過, 當然了, 我在這裡所指的呆, 都僅限於程式內的小呆, 而不是邏輯或行為上的大呆。太偉大的呆恐怕沒辦法用這裡舉例的簡單方法來找。
前面說過, 我的程式庫裡的每個小程式都很簡單, 沒有什麼了不起的技巧, 所以我在這篇文章裡不會把程式列出來。
Unit Test
我並沒有使用任何 IDE。真要算的話, 只有 Sublime Text 而已。所以我也沒有使用任何可以輔助測試的 framework 或工具, 連測試用的網頁都是自己刻的。再說一次, 那是因為我的環境和程式都很簡單; 我並不是鼓勵讀者們一定得像我一樣刻苦。
先來看看這個網頁的長相:
我在網頁中加入了兩個輸入框, 那是因為我的受測程式最多只會接受兩個參數。如果你的程式並不是這樣, 應該酌予修改。此外, 我加入了一個按鈕, 用來觸發 unit tests。不過我後來在那兩個輸入框加上 onkeyup listener, 這個按鈕就變得可有可無。
接著, 我在網頁裡寫了幾個簡單的程式, 但只是用來在網頁上輸出文字而已。基本上, 測試程式本身也是需要測試的。但是由於這個 UnitTests.html 裡並沒有任何具邏輯的程式, 所以我就沒有特別為它寫 unit test。如果測試程式本身有問題, 我們在測試過程中應該很快就會發現; 下面我會舉個例子。
我第一個測試的是 Utility 專案裡的 write() 函式和 writeLine() 函式; 還有一個 writeInRed() 程式沒有列出來。後面兩個程式其實都只是呼叫 write() 而已。這個程式覆寫了 HTMLDivElement.prototype, 它可以接收兩個參數 -- text 與 style, 然後會在傳入的 div 元素裡 append 一個 spen 元素, 在它的 innerHtml 中填入 text, 再將其樣式套用為 style。至於 writeInRed(), 則是強制把 style 設定為 "color: red"。而 writeLine() 則會先呼叫 write(), 然後補上一個 br 元素。
熟悉 ASP.NET 的朋友應該可以一眼看出來這個 write() 和 writeLine() 就是模仿 ASP.NET 的 Write() 和 WriteLIne() 程式的, 只是簡化過而已。不過容我再強調一次, 我並不打算在這裡討論程式本身, 我希望把重點放在防呆上面。
其實, 以我為例, 在實際上去寫 unit test 程式以前和以後, 我的想法是歷經重大改變的 -- 每次都一樣。以 write() 這個函式來說吧! 它似乎只是單純地在 div 元素裡加上 span 元素, 然後把文字填進去而已。當我一開始著手寫這個函式時, 出發點就是這麼單純。
但是當我一開始寫它的 unit test 時, 我馬上就意會到我可以把它改寫出一個 writeLine() 來擴充其便利性; 後來又衍生出一個 writeInRed()。換句話說, 在撰寫 unit test 的過程中, 我提早扮演了使用者的角色, 把程式改進了。當然, 在本文的情境中, 這個 Utility 專案的「使用者」就是我自己, 而不是終端使用者。
通常, 我們只要做了測試, 就會思考得更多更遠。例如, 像我一開始設定這個 write() 函式必須依附在一個 div 元素之上, 然後在其下 render 出一個 span 元素來顯示文字。然而, 難道它不能 render 出一個 p 元素嗎? 還有, 它為什麼一定要依附在一個預先存在的 div 元素之上? 它難道不能像 ASP.NET 一樣, 依附在一個虛擬的 PlaceHolder 之上嗎?
像這樣的橫向思考是一定會產生的。只要你有寫 unit test, 自然就會產生這樣的思考。但是, 如果你任由這樣天馬行空的思考自由發揮下去的話, 你已經忘記你在扮演什麼角色了。原則上, 如果你只是在撰寫 unit test 而已, 就不要想要去更改原有的架構。你應該自我節制在原來的設定和初衷之上, 除非真的有必要, 否則請不要任意地脫離原先計畫的 scope 之外。難道你本來只是要寫個 write() 函式而已, 最後卻發展成一整個 ASP.NET 嗎? 如果是的話, 我可以跟你打包票, 你不但什麼都發展不出來, 連原來的程式和 unit test 都寫不好。
依照螺旋式開發模型 (spiral model) 的原則, 除非遇到瓶頸, 否則我們應該堅守原先的計畫進行每個 phase 的發展, 千萬不到想到什麼做什麼, 最後變成四不像。所以, 我們應該尊重原本預訂的 scope, 完成一個 iteration 之後, 再開啟下一個更大更好的計畫; 切忌好高䳱遠。
邊測試邊防呆
我第三個測試的函式是 removeAllHtml()。它的功能很簡單, 就是把一道字串中所有的 HTML 標籤通通移除, 剩下純文字部份。
然而在範例圖中, 在我們還沒有輸入任何值以前, 這個 removeAllHtml() 怎麼會有輸出值? 那是因為我在測式程式中為這個函式設定了預設的輸入值 (亦即 '<b>bold</b>')。這樣一來, 我們每次在網頁剛載入時, 就能立刻看出這個程式是不是被改壞了, 或者受到其它程式的影響。其它各個函式都各有其預設值; 一般 Input1 的預設值是 'ABCDE', Input2 是 'C' 或者 '3' 等等。
不過, 每次都測同樣的值, 這並不是我們想要的。我們還要測不一樣的資料! 這就是為什麼我在網頁中加上了輸入框的目的。
問題來了! 如下圖, 當我在輸入的過程中, 我看到了怪怪的結果:
如果輸入的值是 '<span>', 那麼 removeAllHtml() 應該會傳回空字串; 如果是 '<', 則會維持為 '<' 不變。但是 '<span' 這個字串, 到底傳回了什麼? 怎麼會是上圖中的樣子?
答案很簡單。'<span' 這串字並不是完整的 HTML 編碼, 所以它並沒有被 removeAllHtml() 做任何改動, 傳回的仍然是原字串 '<span'。問題在於測試程式。當我們把 '<span' 這串字丟到網頁上時, 網頁並不能顯示這串字, 所以變成奇怪的空白。
測試程式不能告訴你如何解決問題; 它只能告訴你有沒有問題。我想, 如果你已經從事網頁設計一段時間, 大概對這類 HTML 顥示問題不會感到陌生。解決的方法很簡單, 就是把字串進行 encode 就行了。所以我寫了一個 htmlEncode() 函式, 問題就解決了:
Unit test 本身的問題一定要比受測程式的問題更早被解決。否則你怎麼能信任測試的結果呢?
不管怎樣, 你可以藉由這個簡單的測試網頁, 找出許多你原本連想到沒有想到的問題, 那些都可以稱為「呆」。像我在這裡示範的, 你原本期望傳入的是像 '<span>abc</span>' 這樣的字串, 但或許你應該提早想到, 萬一傳入值並不是那樣呢?
如果傳入的是 '<span>abc</span>', 請問你應該要處理還是不要處理?
如果傳入的是 '<----- 這是一條線 ------->', 請問你應該要處理還是不要處理? 'a < b where b > c' 呢? 依此類推。
介面的測試
把單元測試寫成網頁有個好處, 就是讓我們可以同時測試介面。JavaScript 最強大之處在於它可以操作 DOM 元件; 所以寫 JavaScript 的人通常難免需要寫一下互動式的功能。前面示範過的 write() 和 writeLine() 就是個例子。底下還有個 appendRuler(), 只要你在畫面上有看到那條橫線, 就算過關 (請看上方第一張圖)。
接下來, 我要測試 isDom() 這個函式。基本上 JavaScript 也算是個物件導向的程式語言, 但是感謝它弱型別的特性, 除了基本型態之外, 各種 object 可以自由地傳來傳去, 我們卻不容易判定某個 object 到底是不是 DOM 物件。所以我寫了一個 isDom() 函式以快速判定傳進來的參數到底是不是個 DOM 物件:
如上所示, divInput1 和 divOutput 都是測試網頁中現成的 div 物件, 所以它們檢測出來確實是 DOM。而 btnSubmit 是個按鈕, 自然也是 DOM。
以上都是我們想像得到的。但是, 我們不能只測試正確的結果, 我們也要測試錯誤的結果。所以, 我在程式中多測試了一個數字物件 intSbut 和一個字串物件 strStub。
上述都是可以想像得到的測試樣本。但我們不要忘記做邊界測試。如果傳進來的是 null 怎麼辦? 所以我多測了一個 nullStub。以上都不是把文字填入輸入框就能夠測的, 只能硬寫在程式裡。
其實還有一個東西應該也要測, 就是 undefined。可惜在技術上那是不可能發生的輸入, 也無法測, 所以就可以省略了。
模擬各種呆
接著, 我要測試 isNumeric() 這個函式。顧名思義, 這個函式是用來檢測輸入是否為數字:
'ABCDE' 是不是數字? '3' 是不是數字? DOM 物件是不是數字?
但問題來了。我在最上面放了輸入框的目的, 就是讓你自行決定要測試什麼字串的。針對這個 isNumeric() 函式, 你必須能夠想像出應該測試些什麼, 例如:
- 若輸入 '5.5', 輸出應該是 true 還是 false?
- 若輸入 '5.5e', 輸出應該是 true 還是 false?
- 若輸入 '5.5e3', 輸出應該是 true 還是 false?
- 若輸入 '5.5e333', 輸出應該是 true 還是 false?
- 若輸入 '5.5 ' (後面加入空白), 輸出應該是 true 還是 false?
- 若輸入 ' ' (空白), 輸出應該是 true 還是 false?
- 若輸入 '123' (全形數字), 輸出應該是 true 還是 false?
- 若輸入 'five', 輸出應該是 true 還是 false?
- 若輸入 '1,000', 輸出應該是 true 還是 false?
- 若輸入 '$1000', 輸出應該是 true 還是 false?
- 若輸入 '$10,000', 輸出應該是 true 還是 false?
- 若輸入 null, 輸出應該是 true 還是 false?
- 看你還能想到什麼能測的?
其實在這階段, 不管你的輸出是 true 還是 false, 我都不會斷然地說有沒有錯, 有沒有 bug, 或者是不是呆。如果你連想都沒有想, 那才是呆。但是對於那些輸入值, 不管這個程式應該輸出 true 或者 false, 你都應該講出一個道理出來 -- 最好是能夠明白地寫入文件裡。重點在於, 你絕對不可以在未來的某一天, 不管使用者輸入了任何你連想都沒想到過的資料樣式, 你都故作強硬地說「那就是 as design」。這是不能接受的。如果你寫程式是這種態度, 那麼我奉勸你也許可以考慮去改賣雞排, 可能還比較有前途。
身為一個開發人員, 我們應該盡量摒棄自大的老毛病。我踫到過很多開發者, 死都不肯相信自己的程式有什麼問題。但是當測試者把問題報上來之後, 他們慣性地以各種不太健康的態度回應:
- 哎呀! 被你抓到了!
- 奇怪了! 你到底是站在哪邊的?
- 你看! 在我的機器上明明跑得好好的!
- 你是不是給我改了什麼?
- 大驚小怪! 它本來就是這樣!
- 有誰會像你這樣用!?
- 不改會死嗎?
- 我很累, 我不想改。
- 你這樣是在找麻煩!
- 叫你主管來跟我講!
- 所以大家都不用回家就對了?
- 你不會叫 user 去死一死嗎?
- 你把所有平板跟手機的所有解析度都量來給我, 我再來改。
- 我覺得兩個 pixel 就很寬了, 你還想怎樣?
- 你的責任就是想辦法叫 user 不要輸入全形! PM 在當假的嗎?
- 不是都寫在授權條款裡了嗎? 來告啊!
- 你一開始有講說客戶可能沒輸入文字就按下按鈕嗎? 有講說網路可能傳到一半就斷掉嗎?
防呆到底是誰的責任? 身為開發者, 如果你一點都不覺得防呆是你自己的責任, 那麼你真是白領了薪水。
我再來舉個例子。在上面我提到我寫了一個 isNumeric() 函式; 如果你輸入 5.5e3, isNumeric() 會傳回 true。因為 JavaScript 內建的 isNaN() 函式可以辨認科學記號, 所以它認可 5.5e3 是個數字。但問題來了。我另外寫了一個 padZero(num1, num2) 函式, 它會把數字 num1 補零補到 num2 位數, 例如 padZero(55, 3) 會傳回 '055'。
那麼, 如果傳入值是 (10.3, 8), 你覺得應該傳回什麼? (0.58335552, 8) 呢? ('ABC', 6) 呢? (5.5e3, 8) 呢? 若仿照上面已經示範過的思考方向, 你可以自己問問自己, 到底你打算怎樣實作你自己的程式, 未來才不會造成出乎意料之外的大麻煩?
所以, padZero(5.5e3, 8) 的輸出應該是什麼? 原封不動地傳回:
還是幫它下一個 parseFloat() 再繼續:
還是應該跳出 exception?
因為 JavaScript 的弱型別特性, 它會把許多意料之外的問題默默地吃掉, 然後傳給你同樣是意料之外的值。所以不要以為你可以使用 C#/VB 開發者習慣的那一套 exception handling。你必須發展出更周詳的應對策略才行。
但說句良心話, PM 或者 SA 或者什麼人, 能夠開給你那麼鉅細靡遺的規格, 連最底層的程式都告訴你應該怎麼寫嗎? 如果是的話, 程式讓他去寫好了, 你還有什麼價值?
所以, 當我們在撰寫程式時, 最好在一開始就能夠預先想到各種可能的呆, 靠的是 unit tests。Bug 不一定是呆, 但呆可能造成 bug。千萬不能過於相信輸入端, 也不應該指望使用者 (不管對方是人還是機器) 會輸入你自以為是的樣式。如果你能預先想像並防範, 你的程式就會愈是強靭好用。
程式和測試必定是同時開發的。經由測試, 尤其是非常早期的測試, 可以幫助你提早發現你原本想像不到的呆 (跟 bug), 同時強迫你不得不規畫團隊 (或者你自己) 的 coding style (你總不會讓你的每個函式的問題處理原則都不一樣吧?)。
所以, 誰說 TDD 沒有必要呢?
防呆之後
找到呆跟防呆是兩件事。我之前提過, 像這個 Utility 專案裡的每個函式, 通通處於「敵暗我明」的情況之下。你必須假設呼叫端不會在進行呼叫之前做合理輸入值的檢查。
但是找到問題之後, 應該如何對付? 我再來舉個例子好了。我在 Utility 專案裡有個 rocDateToChristDate(date, time) 函式, 可以把民國日期轉換成西元日期。原本預期的輸入日格式是像 '107/2/3 23:58:03' 這樣的字串, 轉換為 '2018/2/3 23:58:03' 之後輸出為日期物件。
藉由上面的 unit test 網頁, 我們可以推斷出一切可能的問題。例如, 如果輸入字串是 '107/2/3' (缺少時間部份), 那麼我就把它補上 '00:00:00'。像這種單純的狀況, 很容易應付。
但是如果輸入的時間部份錯誤呢? 如果是 '00:00', 該怎麼處理? '00:00:0A'? '24:00:00'? '25:00:00'? 'AB:CD:EF'? '127.0.0.1'? 'three after twenty five'?
同樣的, 輸入的日期部份也有可能錯誤。例如 '107/20/5'、'一百零七年二月五日'、'1070205'、'107.2.5'、'107-2-5'、'.107/2/5' 等等。
面對這類各式各樣的問題, 最簡單的方法, 就是根本不要幫它解決。錯了就是錯了, 超越原本設定的規格, 也應該當作錯誤; 應該跳出 exception 就讓它跳。
那麼我都怎麼做呢? 通常, 我會把最常見的小問題解決掉。例如, 我允許某些不正常的輸入 -
- 時間部份未提供 (補上 '00:00:00')
- 使用 '107.2.3' 的日期格式 (改成 '107/2/3')
- 使用 '107-2-3' 的日期格式 (改成 '107/2/3')
即便原來的需求規格沒有提到, 我還是會「免費」做這樣的小 favor。因為這些都是很常犯的錯誤; 你讓程式多了一點小彈性, 以後可能會省下很多麻煩。
但是對於其它的錯誤輸入, 我就會讓它跳 exception 了。但是我並不會任由程式自己亂跳 exception, 而是先攔截之後再跳。有些原本不會造成 exception 的輸入 (例如 '107' 或 '107/1' -- JavaScript 可以接受 2018 或 2018/1 這樣的日期, 它會把它視為 2018/1/1), 我也會強制它跳 exception。
由於我的專案很小, 我不需要(也無法)引入什麼特別的 logger 或者 exception hanler, 但我仍然會自己發客製的 exception。例如:
throw 'Utility.rocDateToChristDate() Exception: Invalid date "' + date + '"! Program aborted.';
在這個小小的程式裡, 我總共有五個地方會發 exception, 分別對應五個不同種類的錯誤。
這樣做並不是多此一舉。像 Utility 這樣的工具程式, 它未來很可能被包含在其它專案裡面, 如果我把 exception 文字描述得清楚一點, 未來可能可以更容易找出問題出在什麼地方。
以上是我個人的做法, 提供大家參考。
什麼時候不要防呆
我整篇都在教你如何防呆, 這裡卻又說不要防呆, 莫非是自我矛盾? 當然不是!
就像 SQL 有正規化也有反正規化一樣, 我在這裡舉的例子都是寫在共用程式庫裡的函式, 都是最常被呼叫的函式。像這種程式寫得愈複雜, 對效能的衝擊也就愈大。
就像我在「程式內的防呆之道」一文中提到的, 呼叫端跟被呼叫端都可能有呆, 寫在被呼叫端當然有其好處, 就是出包的機率比較小, 程式品質當然會比較高。但是如果你把防呆寫過頭了, 恐怕就犧牲效能了。魚與熊掌不可兼得, 我們必須有取捨。
怎麼取捨? 要看情況。
如果你的程式寫在 real-time 系統裡面, 那麼你就要特別小心效能問題。這種系統對效能的講究更高於穩定性 (除非它剛好又是個 mission-critical 系統), 所以你恐怕不能在程式裡寫太多防呆邏輯, 除非你很確定它絕對不會被密集呼叫。在我上面的例子裡, 我其實都用 Regex match 來判斷輸入值是否合理, 而 Regex 的複雜度通常是 O(n); 雖然沒有特別地高, 但是它絕對會減低效能。
一般來講, 你可以照樣做防呆, 直到正式測試階段中再來找 critical path, 然後逐項檢討防呆邏輯是否拿掉。
但是我可以提供我自己的做法給大家參考。
其實我目前的系統不需要考慮到效能問題, 我的系統也不是 real-time。但是我從以前就習慣在專案裡加個 optional global var (型別是 bool), 我自己都把它命名為 pokayokeFlag, 用來控制所有函數要不要做防呆。格式如 rocDateToChristDate(date, time, noPokayoke)。如果 noPokayoke的值是 false, 程式裡就執行防呆邏輯, 反之就不執行。如果 noPokayoke 的值未提供 (null), 程式會以 pokayokeFlag 的值來取代 (預設為 false)。這裡 noPokayoke 字面上就是「不防呆」的意思。
換句話說, 我保留每個程式可以動態決定要不要做防呆的彈性。這樣一來, 程式的穩定性和效能都算是有考慮到了。
但是, 當然, 你一旦取消了防呆檢查, 呼叫端的程式就要負擔更重的責任了。如果你在呼叫端也做完全相同的防呆檢查, 那麼結果和寫在被呼叫端是一樣的, 你什麼好處都沒撈到, 還平白增加了呆的機會。所以, 你必須採用完全不一樣的策略, 也能確保效能; 像是如何能夠保證不會傳入不該被允許的資料, 或者加入 try... catch... 區段等等 (只是這樣也會損失效能就是了)。