2010/10/19

程式內的防呆之道

在品管圈裡有一個很著名的水泥救生衣的笑話, 大意是說, 一個公司即使通過 ISO 或 CMMI 等認證, 也不能保證它不會做出水泥救生衣這種產品出來。非品管圈的人可能無法在一開始就看懂這個笑話; 但是請你再仔細想想看, 救生衣可以是水泥做的嗎? 如果真有這種東西的話, 也是黑幫拿來謀財害命用的, 不是拿來救人的。ISO 或 CMMI 的主要目的, 在於檢驗設計或生產的流程是否完善而且嚴格的被遵循, 它們卻沒有辦法保證設計或生產的東西是不是合理或賣不賣得出去。

普天同呆, 薄海翻騰

寫程式也是如此; 再嚴格的 Standards, 也無法防範撰寫程式的人突然犯了瘋病、傻病還是呆病。就像俗語講的, 「聰明一世, 糊塗一時」。我相信每個程式設計師都會有思慮不周的時候, 這也就是為什麼需要有 SQA 的存在。然而, 不可否認的, 由於有了 SQA, 也讓許多程式設計師得了便宜還賣乖, 一旦程式出了問題, 竟然可以忝不知恥的把責任歸究於 SQA, 說是因為 SQA 沒有測出問題來; 而如果程式的品質良好, 就自誇是因為自己功力高深...

身為一個由 SQA 出身的開發者, 我察覺到一個十分奇怪, 而且不合理的現象: 我檢查過週遭許多人的程式, 我發現絕大部份的人都不會在程式中做防呆的動作。為什麼我會發現呢? 因為同樣的功能, 我的程式似乎總是比別人寫的程式還要長。經仔細比對之後, 我終於發現, 原來別人在程式中是很少做防呆的! 隨便舉個例子吧! 當我在檢查某位同仁的程式時, 我發現明明一個 TrackBar 的值必須介於 1 到 50, 但是他在程式中就直接下了 trWidth.Value = value; 這樣的句子。

呆之一例

這樣的寫法通常都是不會錯的。但是, 在現實世界中, 事情不可能永遠美好; 在某種特定情境下, 當傳送進來的 value 值大於 50 (例如 60)的時候, 這個程式一定會跳出一個例外。但是要等到 SQA 回報了這個 bug, 這位程式設計師才會回頭把程式改成

if (value > trWidth.Maximum) 
     trWidth.Value = trWidth.Maximum;

但是, 等到下次, 在另一種情境下, 傳送進來的 value 值小於 1 (例如 0)的時候, 這個程式又跳出例外, 經 SQA 發出 bug report 之後, 這時程式設計師才又心不甘情不願把程式再加了一行:

if (value < trWidth.Minimum)
     trWidth.Value = trWidth.Minimum;

如此的周而復始, 最後公司裡所有稍為資深的 SQA 都知道, 對於這位先生所寫的程式, 一定要先從 Boundary Test 先著手。而這位程式設計師也很固執, 因為他就是永遠不在程式中做防呆。也許是因為他打從心裡就不認為 SQA 每次都可以製造出可以產生 bug 的那種情境 (對於專做黑箱測試的 SQA 而言, 他/她的確不一定每次都能猜測出在哪種情境之下才會讓那種 Exception 跳出來)。測不到問題就代表沒有問題... 至少這位程式設計師是這麼想的。

此外, 這位先生也從不思考為什麼有超越邊界的值會被傳進來。以上程式的改法真的正確嗎? 真正的問題也許根本不是出在這一段程式裡!

QA 非萬能

這是一場 SQA 與開發者的攻防戰。在我所待過的公司裡, SQA 始終是屈居劣勢。這跟公司高層的心態有關; 一般以業務為導向的公司, 準時(甚至提早)出貨一定是唯一的金科玉律; 至於什麼品質至上、什麼堅若磐石之類的口號, 一直以來, 也只是口號而已。所以如果你曾經聽到有人說日本的品管可以一句話就把整條生產線停下這種事, Sorry, 那是在日本; 在台灣不會發生(至少在我見識過的公司不會發生)。

我要很不客氣的講, 在 SQA 失去其祟高地位的環境下, 整個開發環境是 Unmanaged 的。開發者擁有畸型的權威, 卻也背負畸型的時程壓力(可能各種不合理的臨時需求會在最不應該出現的時候跑出來), 最後的結果, 就是 SQA 淪為永遠背黑鍋的 Tester, 而開發者為了趕上時程, 結果也變得不甩任何 Standards, Principles, Regulations, etc. (阿婆利被安作啊?)

當然, 我必須在這裡假設正在閱讀這篇文章的程式設計師並沒有被上述的虛假的「權威」感蒙蔽了心智, 或者在貴公司裡 SQA 和開發者之間並沒有誰佔優勢的問題(例如, 貴公司根本沒有 SQA, 你自己就是兼任的測試人員)。不管如何, 如果你確實有心寫出品質良好的程式(而不是依賴 SQA -- 甚至是使用者 -- 的測試 -- 或抱怨 --)的話, 那麼你是否也認為在程式內做防呆是有其必要的?

不過我也想要特別聲明一下, 我在這裡所提出的重點是在於「防呆」, 而不是「測試」, 請不要把這兩者互相搞混。「防呆」二字恐怕是台灣軟體圈所專有的名詞, 若硬要翻譯成英文, 恐怕找不到最貼切的話來形容; 是“just in case” 嗎? 或者是 "in prevention"? Stupidness proof?

若依我的定義, 「程式內的防呆」包括傳入值的檢查, 或者傳出值的檢查, 以及其它必要的邏輯檢查等等。但是我並不主張企圖在每一段程式中做過多的檢查, 若非真的有必要。為什麼呢? 因為我們原本在程式中就應該把正確的邏輯處理好, 而不是額外加入一大堆測試用的程式碼。

防呆用的程式碼, 就是如同在本文中所列舉的幾個範例一般, 是以「修正」可以預期的錯誤值為主, 根本和測試程式毫無相干。所以不要過度引申我的主張, 以為程式中應該加入什麼自我測試的機制。明白的說, 測試是 SQA 的工作; 而且 SQA 存在的目的, 就是以與開發者不同的觀點(尤其是模擬實際使用者的角度)來做測試; 開發者不必雞婆的把這個工作搶去做(也做不到, 因為開發者有其先天上的盲點存在)。

如果只是為了「防呆」的話, 我們必須嚴格限制驗證的範圍; 既不能小到什麼呆都防不到, 也不能大到讓拿來做驗證的程式碼比原來的程式碼還要龐大個幾倍的地步。而且, 用來防呆的程式部份, 應該是跟單元測試的部份能夠共存的。換句話說, 就算你已經採用了 TDD 原則, 也寫了完整的單元測試程式, 那麼你不能因為單元測試中已經做了某些測試, 就把應該做的防呆程式拿掉; 反之亦然。為什麼呢? 就像前面已經提到過的, 準備測試環境及資料這件事本身就是很麻煩、很難面面俱到的; 不管如何, 即使是放到同一方案中的單元測試程式, 它跟受測程式之間, 仍然是彼此獨立的、不互相隸屬的; 無論你如何準備測試資料與環境, 你就是沒辦法做到最完整的測試(除非你的程式真的非常、非常簡單), 這種情形在軟體測試理論中稱為「Exhaustive Testing is Impossible」, 意思就是說, 想要測試出所有程式分支、所有情況下有沒有問題, 由於測試路徑經過排列組之後, 動輒就是天文數字, 所以實際上是不可能辦到的。

然而, 與其讓 SQA 花很多心思在佈置測試環境與準備資料, 你為什麼就不能從源頭把會出現問題的地方封住呢? 再拿出上一個例子出來說吧! 你在什麼情況下會執行到 trWidth.Value = value;  這行程式? 很明顯的, 這行程式是寫在屬性(Property)裡面的 setter 中, 但是這個屬性什麼時候會被下 set 動作並傳入錯誤的值呢? 說句實在話, 這真的很難預測! 有可能是來自於眾多開發者裡面的某一個人, 因為一時不小心, 在那個 TrackBar 的屬性視窗中把那個控制項的 Value 屬性上按下了 0 (或者多按了一個 9); 又或者是因為該屬性的值是從資料庫或文字檔中繫結而來, 卻因為其它程式出現的問題, 導致這個值被傳入 0; 更有可能是因為後面接手的程式設計師在任何人都想不到的地方設定了這個值。「敵暗我明」這句話可以充分形容這種處境。

凡呆之所在, 防之所在

對於從事黑箱測試的 SQA 來說, 他真的得靠運氣才能測試到所有可能發生問題的情境, 而且前題是他還得非常熟悉整個程式的來籠去脈。而對於從事白箱測試的 SQA 來說, 這問題就可能稍為單純一點, 因為他可以直接針對原始程式寫個邊界測試程式, 也許就可以把這類問題測出來。但我現在要說的就是, 既然如此, 那麼程式設計師為什麼不一開始就把這段驗證程式直接寫進程式裡頭算了? TDD 又如何? 萬一設計測試程式的人並不是什麼資深的 SQA, 反而剛好是個完全不懂測試理論的阿呆呢? 你也跟著呆嗎? 何況, trWidth 的 Minimum 和 Maximum 值真的一定是 1 到 50 嗎? 萬一是浮動的呢? 單元測試程式不一定取得到那個浮動值(搞不好甚至根本無從知道它的值), 所以測試程式不保證一定測得出這種問題。你自己不去檢查, 誰去檢查?

所謂「防呆」, 意思就是指那些原本不應該犯的錯誤, 仍然需要被防範。程式設計師當然知道 TrackBar.Value 不能被設定為超出 Minimum 與 Maximum 之外。他在寫程式時, 若完全依照他的邏輯, 這種問題是不應該發生的! 但是事與願違; 例如, 若依照他的原始邏輯, 原本 trWidth.Value = value; 這行程式只有在 Form.Load() 事件中會被執行到, 但是隨著程式的逐漸開發, 以及需求的逐步實施, trWidth 控制項的值變成也會因為資料繫結而被異動了。前面提過, 程式設計師不是神, 他也會有思慮不周的時候(幸好這種事在平常人身上的發生頻率不是很高, 我看也差不多從每十秒一次到每幾天一次而已), 他就是想不到一個(應該)不可能為 0 的值, 偏偏在出貨給客戶的幾年之後的某一天, 這個值真的傳回 0 了。這可能不是他的錯, 有可能是因為資料庫端的程式出了問題, 或者其它根本料想不到的問題所導致。

但是問題就是問題; 別人出了差錯, 卻把問題歸究於這個程式設計師。現在可就麻煩了! 因為已經過了幾年, 這位程式設計師還在不在職是個問題; 就算還在職, 他記不記得寫過這個程式, 也是問題。那麼, 如果這位開發人員一開始就做了防呆, 未來這些問題就不會發生(或者至少是以其它種較不具殺傷力的方式發生)。

如果看到這裡, 你也同意防呆程式碼有必要寫的話, 那麼, 接下來, 我們再來看看, 到底要防哪些呆?

防呆? 什麼是呆? 什麼不是呆?

前面說過, 防呆是為了預防, 不是為了測試。防呆也不能防過了頭, 而把測試程式和真正的程式寫在一起。那麼, 既然已經打算要做防呆, 究竟應該防什麼呆?

我認為不妨先讓讀者們復習一下理論的部份, 再來思考實際上應該怎麼做。

在軟體測試理論中, 測試約略可以區分為以下幾類:

  1. 基本路徑測試 (Basic Path Test)
  2. 條件測試 (Conditional Test)
  3. 資料流程測試 (Data Flow Test)
  4. 迴圈測試 (Loop Test)
  5. 邊界測試  (Boundary Test)
  6. 等價劃分法 (Equivalence Partition)
  7. 功能測試 (Functional Test)
  8. 壓力測試  (Stress Test)
  9. 錯誤處理測試  (Error Handling Test)
  10. 重行測試 (Regression Test)
  11. UI 測試 (User Interface Test)
  12. 效能測試 (Performance Test)
  13. 相容性測試 (Compatibility Test)

以上, 都算是測試工作所應涵蓋的範圍, 其中有些被劃入白箱測試, 有些被劃入黑箱測試, 有些則被描述為「灰箱」測試(介於黑箱與白箱之間, 或是兩者兼俱)。不過在這裡我想暫時離題補充一點: 關於測試的分類, 很難說有一個每個人都服氣的標準答案, 真的是「人人一把號, 各吹各的調」, 我相信如果你有興趣深入下去研究的話, 你可以輕易找到更多種分類的方法。但是, 不管採用什麼劃分方式, 恭喜你, 那些都算是 SQA 的職責; 除了極少數例外, 我們通通不必把它們寫到防呆程式碼裡面, 以免疊床架屋, 或是多作了很多白工。防呆是防呆, 測試是測試, 不要把二者混為一談; 我已經重複這一點很多次了。因此, 上面所列出來的 13 個項目, 都是測試, 除了少數將被我們偷過來用之外, 剩下的都跟防呆無關。

那麼, 前題是什麼?

雖然我們現在對於防呆已經有了一點概念, 但是在我們決定要做哪些防呆動作之前, 我們最好先立下幾個前題:

  1. 可以彌補單元測試之測試困難度(Testability)者, 優先防呆
  2. 能夠以最小代價增加程式品質的, 優先防呆 - 即使在單元測試中已經做過相同或類似的檢查
  3. 若團隊中缺乏撰寫優良自動化測試程式者, 防呆就多做一點
  4. 若專案是屬於從未做過的新類型或新領域, 防呆就多做一點
  5. 與其它單元互動性或依賴性愈高者, 防呆就多做一點 - 但是得考慮效能
  6. 雖然防到了呆, 但如何處置? 這個原則必須事先確立, 並且明定在開發團隊的 Coding Standards 或 Guidelines 中
  7. 要防哪些呆? 不防哪些呆? 這個原則必須事圥確立, 並且明定在開發團隊的 Coding Standards 或 Guidelines 中
  8. 對於單元輸出入介面的測試, 到底是由 Input 端做防呆? 還是 Output 端做防呆? 還是都做? 這個原則必須事圥確立, 並且明定在開發團隊的 Coding Standards 或 Guidelines 中

如果防到了呆, 但到底要如何處置(前題第六項)? 我們必須謹記一點: 防呆的目的之一, 是為了在產品發行到客戶手上後, 把一些事先料想得到的錯誤攔截起來, 以避免 Exception 跳出來把客戶嚇著, 或者甚至中斷了整個系統的執行。所以, 這個問題的答案, 和其它的錯誤處理方式是完全一樣的。就以之前的範例做例子吧! 就算指定的 value 值是 60, 但 trWidth.Maximum 只有 50, 所以我們還是只能把 trWidth.Value 設定成 50:

if (value > trWidth.Maximum)
     trWidth.Value = trWidth.Maximum; 

不過, 如果依照原程式邏輯, value 明明就不應該出現 60 這樣的值! 雖然表面上我們使用防呆程式碼把這個值給「撫平」了, 但我們需不需要追究這個問題? 因為, 會發生這個問題, 可能意味著背後有另一個(可能更嚴重)的問題存在!

像這種問題, 我無法給你「正確」的答案, 我只能提供我的「建議」。我個人的做法是這樣的, 我通常會另外寫一個除錯專用的 logger  來記錄各式各樣的問題; 不只是用在防呆程式碼裡面, 也用在所有 try… catch 區段裡面。這個 logger 在程式發行之前(即測試期間)會發出 Exception 以指出錯誤所在, 在正式發行之後, 則不會再發出 Exception, 但是會記錄在 EventLog 裡面(或者其它地方):

if (value > trWidth.Maximum) { 
     trWidth.Value = trWidth.Maximum; 
     log("MyApplication", 
           true, // A flag that indicates if an exception is allowed to fire, if necessary 
          "MyNameSpace.Class1.ABC(): Invalid argument! The input value ({0}) exceeds trWidth.Maximum({1}).", 
          value,  
          trWidth.Maximum); 
}

對於 (value < trWidth.Minimum) 這一段, 也是做同樣的動作, 我想各位就自己舉一反三吧!

至於前題第八項, 邊界值的防呆檢查應該是由 Output 端做, 還是 Input 端做? 還是都做? 依我個人的看法, 當然是由 Input 端(接收端)來做。為什麼呢? 首先, Output 端(傳送端)不見得知道 Input 端有些什麼條件限制(特別是當這些條件限制是浮動的時候), 所以交給 Input 端做資訊的 alignment 是比較合理的。就算你堅持在 output 端做檢查, 但是 input 端最好還是再做一次! 為什麼? 預防萬一! 否則還需要防呆嗎?

無論如何, 不管你最後決定要採用哪一種方式, 最好還是列入 Coding Standards 之規範, 並且切實執行。

有呆就要防, 但也要有方法

在測試理論中, 我們應該已經知道, 所謂的白箱測試, 指的就是根據你的程式碼下去做分析與檢驗。然而, 如果反過來呢? 比方說, 如果我們已經知道單元測試要做的是哪些檢驗, 那麼我們是不是應該早 SQA 一步, 自己先把該檢查的東西都先檢查過了? 不然, 你的程式如果能通過單元測試, 莫非都是靠運氣的? 或是寫單元測試程式的人, 都從來沒想到要檢查邊界值?

因此, 我們可以從這個地方推衍得出另一個觀點, 那就是在某種程度上, 「防呆」應該被視為「白箱測試」的一體兩面。若從 TDD (Test-Driven Development) 的角度來看, 我們應該先把測試程式寫好之後, 再來著手進行真正的程式; 但是若站在另一個角度來看, 當我們把單元測試做好之際, 你應該防什麼呆, 也差不多可以確定了。當然, 這兩者也不一定得維持一對一的關係; 由於外界的測試程式並不一定能夠輕易的取得原程式的所有資訊, 所以其實防呆程式碼可以做得比測試程式還要再仔細一點、再切中要害一點(如防呆前題的第一項)。

那麼, 單元測試通常都是做些什麼測試呢? 一般而言, 單元測試裡面通常可以再區分為幾種較為細部的測試手法:

  1. 單元 Input/Output 介面(不是 UI)測試
  2. 單元區域資料測試
  3. 單元 IO 介面測試
  4. 單元執行路徑測試
  5. 單元控制流程測試
  6. 單元錯誤處理測試

以上, 雖然是我們應該留待單元測試中做的, 但我建議我們可以把一部份(主要是資料處理的部份)挑選出來, 放進防呆程式碼裡面。

例如, 在單元輸出入介面測試(單元測試手法一)中, 我們偶爾需要檢查傳入資料的型別。在以下程式中, 參數其實是以一個 Interface 型別傳進來的, 但是在稍後的程式中, 你卻固執的認定它一定是個 myType1 型別, 而且也就這麼使用它了:

public bool doAction(iStuff myStuff) 
{ 
     private myType1 stuff = (myType1) myStuff; 
     … 
}

若你敢大膽的採用這種做法, 而且你自己都沒有聞到其中的壞味道時, 這表示你的嚴謹度真的有待加強。

在這種情況下, 你至少可以檢查兩件事情: 第一, 這個 myStuff 參數是否為 null? 第二, 這個 myStuff 物件到底是不是真的是 myType1 型別? 請不要告訴我, 你很確定這個傳進來的物件不可能是 null, 也一定是 myType1 型別。如果你對自己的程式那麼有信心, 我也搞不懂為什麼你能夠那麼有耐心的把這篇文章看到這裡。

接著, 對於傳進來的值, 我們是不是應該做一下邊界測試? 前面已經一再提到 TrackBar.Value 的例子, 所以我就不再贅述。但是我們可以檢查的地方不只如此; 在某些情況下, 如果該參數將被使用於分母, 你應該事先檢查它的值是否為 0; 如果會被拿來開根號, 它的值應該大於 0。如果會發出 Exception 的話, 你可以思考是否由你自己來發, 而不是一昧地把所有 Exception 都攔住不管。諸如此類的檢查, 都值得多花一點心思。

還有, 像在 C# 和 VB 都會有一些隱含型別轉換的小陷阱, 例如 int 和 float 或 double 等等型別, 以及像 Point 和 PointF 之類的型別, 都有可能會在你並未注意的時候被暗中轉換了。平常可能不會出現問題, 但是在某些特定的情況下, 這種轉換的結果卻有可能跳出 Exception (例如遇到溢位的狀況), 或者造成不易查覺的 Round Error。如果你願意多寫一行防呆程式碼, 或許就能預防日後可能發生的大問題。

在單元區域資料測試(單元測試手法二)中, 所有要做的動作、要注意的事項, 和上述一點一模一樣, 只不過把檢查的對象改成區域變數而已。

在單元 IO 介面測試(單元測試手法三)中, 我們通常會檢查路徑是否存在、檔案是否存在、字元編碼是否正確、開檔權限是否足夠等等, 而這些都是必防之呆。不過除了以上幾種必要的檢查之外, 我們可能還需要額外應付幾種平常不會發生的問題, 例如檔案被其它程序佔用、使用者突然拔走卸除式裝置的問題等等 IO Exception 的錯誤處理方案。還有, 如果你習慣把檔案全部讀進記憶體後才進行處理的話, 你最好能假設你的檔案在有朝一日可能爆漲為數 G 之大的特殊情況。或者因使用者或其它程式刻意去修改其內容, 而導致檔案完整性受到破壞的情形, 或者把 HTML 檔案當作 XML 傳進來要求處理的狀況; 這類問題非常多樣, 每樣都必須視狀況個別探討。

我相信, 如果你在程式中適度的加入防呆, 雖然程式看起來變大了, 但是其品質也一定會相對的變好了。

Code Contracts 的導入

從 .Net 4.0 開始, .Net Framework 加入了 Code Contracts 功能。在這個命名空間之下提供了幾個方法, 讓我們也可以在程式中加入某種程度的防呆。

Code Contracts 其實是一種 DbC (Design by Contracts) 概念的具體實作。例如, 當我們在設計類別方法時, 我們可以以 Contract.Requires() 方法檢查方法的輸入值(Preconditions), 並且以 Contract.Ensures() 方法檢查方法的輸出值(Postconditions), 以確保這個方法符合規範 (Contract)。

以下, 我們來看看 Code Contracts 的簡單範例程式:

using System.Diagnostics.Contracts;
public class PositiveNumbersCalculator
{
    public Int32 Sum(Int32 x, Int32 y)
    {
        Contract.Requires<ArgumentOutOfRangeException>(x >= 0 && y >= 0);
        Contract.Ensures(Contract.Result<Int32>() >= 0);
        if (x == y)
            return x << 1;
        return x + y;
    }
}

在以上程式中, 如果輸入的參數 (x 與 y) 不符合原先的約定 (必須大於 0), 程式將跳出 ArgumentOutOfRangeException 這個例外。同樣的, 如果程式的傳回值不符合約定 (大於或等於 0), 同樣會跳出例外。

這個工具並不是全部內建於 .Net Framework 4.0 裡面; 你必須到 DevLabs 網站下載並安裝這個工具, 然後還須要在專案屬性頁中進行設定 (其步驟請參考它的說明文件)。我目前還找不到如何在 Web 網站專案中進行設定的方法, 只能找到在 Windows Form 和 Web Application 專案的設定方法。

當然, 這個工具可以為我們帶來小小的方便性, 也的確有助於防呆。

那... 別種呆呢? 要不要防?

到這裡為止, 你可能會覺得我只主張防那種很簡單的呆; 那麼其它種呆呢?

有很多「呆」是沒辦法防的。前面已經提過水泥救生衣的例子了; 像這種超級大耗呆, 就不是我們小小程式設計師能夠防的。我再舉個例子好了。我相信很多人都遇過非本領域的主管; 我個人待過很多公司, 所以也遇過很多這樣的主管。憑心而論, 只有少數幾位是靠著官位大和嗓門大來實行領導的; 這種人有時候可能會下達非常離譜的命令, 堅持要做些不合常理的事情。像這種事情, 即使表面上看起來很呆, 但是, 數大就是美, 當這個呆大到某一個程度, 就不算在我們程式設計師的管轄範圍內了。

還有很多種呆, 是屬於邏輯領域的。例如, 你的程式原本是要做乘法的, 但是一不小心, 你把它寫成減法了。這樣算是「呆」嗎? 事情本身也許很呆, 但此呆非彼呆, 同樣不列入本文所定義的「呆」字統領範圍。

所以, 讓我狗尾續貂一下... 上述幾種情況, 我們可以把它通稱為「錯誤」(Error, Mistake, Fault, Defect, etc), 並不應該列入「呆」的領域。所謂的「呆」, 只侷限於開發者由於過度的自信或不小心, 而把應該防範的小錯誤忽略掉的情形。由於這種錯誤很小, 不容易藉由測試或觀察而發現, 但是其防範之道卻相對的容易, 幾乎只是開發者的舉手之勞而已。

相對於其它較為明顯、容易查覺的「錯誤」而言, 「呆」很容易被大家忽略。但其實很多產品發行後才被找到的大問題, 通常都是一開始沒做防呆的關係。所以, 我希望大家都能改變一下習慣, 隨時把防呆動作做好, 而且不要過於依賴 SQA 的測試, 這才是本文的真正目的。

參考資料:

 

沒有留言:

張貼留言