【單元測試的藝術】Chap 1: 單元測試基礎


目錄

  • PART I: 入門
    • Chap 1: 單元測試基礎
    • Chap 2: 第一個單元測試
  • PART II: 核心技術
    • Chap 3: 透過虛設常式解決依賴問題
    • Chap 4: 使用模擬物件驗證互動
    • Chap 5: 隔離(模擬)框架
    • Chap 6: 深入了解隔離框架
  • PART III: 測試程式碼
    • Chap 7: 測試階層和組織
    • Chap 8: 好的單元測試的支柱
    • Chap 9: 在組織中導入單元測試
    • Chap 10: 遺留程式碼
    • Chap 11: 設計與可測試性

前言

當年,在柳絮因風起的漫漫粉筆灰中度過的漫漫歲月,彷彿再次聽到數學老師的口頭禪:「定義最重要。」
當時感到莫名、煩躁的一句話,畢竟講了那麼多遍,數學於我也似懂非懂,我於數學概也是交情普普罷了。然而,荏苒多年過去,歲月陳酒香,有些事情似乎長大了,驀然回首,終究懂了。定義之於科學,宛若原則之於人,貨真價實,回到本心。
因此,一開始我們先來看看「單元測試」的定義,又什麼才是「優秀」的單元測試。


一、初步定義

維基百科對單元測試的傳統定義(這個定義待會兒會慢慢演進):「一個單元測試就是一段程式碼(通常是一個方法),這段程式呼叫了另一段程式碼,然後驗證某些假設的正確性。如果這些假設是錯誤的,單元測試就會失敗。一個單元可以是一個方法或函數。」

被你測試程式所測試的對象,稱為「被測試系統」(SUT):「代表 System Under Test,有些人喜歡用 CUT(Class Under Test 或 Code Under Test)。在測試中,被測試的東西稱為 SUT。」

作者以前覺得(對,覺得。書裡面沒有科學,只有 藝術和感覺 ,人生未嘗不也是如此),單元測試這個傳統定義,技術上是正確的。但在過去幾年,作者改變了想法,認為單元代表的是系統中的「工作單元」或是一個「使用案例」(use case)。

那什麼是工作單元?
從呼叫系統的一個公開方法,到產生一個測試可見的最終結果,在期間這個系統所發生的行為統稱為一個工作單元。所謂一個可見的最終結果指的是,我們只需透過系統的公共 API 和行為就可以觀察到它,而不需透過系統內部狀態才能得知結果。
一個最終結果可以是下列其中一種形式:

  1. 被呼叫的公開方法回傳一個結果值(回傳非 void 函數)
  2. 在呼叫方法的前後,系統可見的狀態或行為發生變化,這樣的變化不需要透過查詢私有狀態就能取得與判斷。(如:系統可以登錄一個之前尚未存在的使用者帳戶。)
  3. 呼叫一個不受測試所控制的第三方系統。(如:呼叫一個第三方 log 系統。這個系統不是你寫的,而且你也沒有它的原始碼。)

工作單元這個概念意味著一個單元,它既可以小到只包含一個方法,也可以大到包括實現某個功能的多個類別與函數。

這裡提到了一個特別的一點,工作單元不是越小越好。如果你所建立的工作單元越大,它的最終結果對使用這 API 的使用者可見度就越高,測試其實會更容易維護。

因此,到目前為止,我們來替單元測試的定義做一點進化:「一個單元測試是一段程式呼叫一個工作單元,並驗證工作單元的一個具體最終結果。如果對這個最終結果的假設是錯誤的,那單元測試就失敗了。一個單元測試的範圍,可以小到一個方法,大到多個類別。」

定義的部分到這邊先 暫時 告個段落,因為有單元測試還不夠,最難的事情是定義「優秀的單元測試」。


二、優秀單元測試的特質

單元測試應該具備以下特質:

  1. 它應該是自動化,而且可被重複執行的
  2. 它應該容易被實現
  3. 它到第二天應該還有存在意義(不是臨時性的)
  4. 任何人都可以按個按鈕執行它
  5. 它的執行速度應該很快
  6. 它的執行結果應該一致
  7. 它應該要能完全掌控被測試的單元
  8. 它應該是完全被隔離的(獨立於其他測試)
  9. 如果它的執行結果是失敗的,應該要很簡單清楚地呈現我們的期望為何,問題在哪

很多人把對軟體進行測試與單元測試的概念混為一談,要釐清這個誤解,你可以問自己以下幾個問題:

  • 我兩週前所寫的單元測試,今天還能正常執行並得到結果嗎?兩個月前的呢?兩年前的呢?
  • 我兩個月前所寫的單元測試,團隊中任一人都能正常執行並得到結果嗎?
  • 我能在幾分鐘內跑完單元測試嗎?
  • 我能一鍵執行所有我寫過的單元測試嗎?
  • 我能在幾分鐘內寫出一個基本的單元測試嗎?

如果以上任一題答案是「不能」,那可能你寫的不是單元測試,而是 整合測試


三、整合測試

整合測試的定義:「整合測試是對一個工作單元進行測試,而這個測試對被測試的單元並沒有完全的控制,而是使用該單元一個或多個真實依賴的相依物件,例如時間、網路、資料庫、執行緒或亂數產生器等等。」

例如:一個測試無法控制系統時間,在程式中使用目前時間的 DateTimeNow,那麼每次測試執行所取得的都是不同時間,測試就容易不穩定。

整合測試還可能帶來一個問題:一次測試的東西太多。

以上兩個問題,小妹敝司都正在發生,我們的 App 十分仰賴「時間」因素,每個時間點會發生的情況都不同,後端有後端的調整因子,前端也有前端的調整因子,專案複雜性高,又另牽涉藍牙韌體,公司的 QA 常常測到快崩潰,因為不知道是後端的問題還是前端的問題還是硬體壞掉,又或者是翻譯翻錯等等,每次測試的項目都非常多樣,又因為是新創在快速發展期的關係(好像永遠都在擴張(?)期),也沒有人撰寫測試,至少在我的 iOS 專案的部分,有嘗試引入單元測試,但亦如許多公司碰到的問題,儘管長期來說是最好的方式,卻因為各種因素(開發新功能、維護舊功能、除錯 etc... 時間都快擠不出來)難以導入,也許進入到這本書的「Chap 9: 在組織中導入單元測試」,我能從中找到一個能說服老大們的方式,司司都有本難念的經呀!

總體來說,整合測試會實際使用到真實的相依物件或資源,而單元測試則被測試單元與其他相依物件 隔離 開來,以保證單元測試的結果高度穩定。

直白點說,就是以下這個 GIF:
alt text

單元測試跟整合測試都很重要,單元測試做的是把上面的鎖建好,確保鎖本身可以正常開關,而整合測試則是確保整個系統是在合理的範圍內,如上所示,整合測試找到了 bug,是單元測試找不到的。


四、優秀的單元測試

談了優秀單元測試的特質,也閒聊了整合測試,接下來,我們來做個最終版的單元測試定義:「一個單元測試是一段自動化的程式碼,這段程式會呼叫測試的工作單元,之後對這個單元的單一最終結的某些假設或期望進行驗證。單元測試幾乎都是使用單元測試框架進行撰寫的。撰寫單元測試很容易,執行起來快速。單元測試可靠、易讀、並且很容易維護。只要產品程式碼不發生變化,單元測試的執行結果是穩定一致的。」


五、一個簡單的單元測試範例

話不多說,來點 code 比理論更真實。

P.S. 小妹過往沒有撰寫 .NET Framework 的經驗,但近期幾個想把玩的專案,如兩週前寫作松本想分享「七天學會 Line Bot」,最後因為 .NET 長城而河蟹了Q,至於程式語言 C# 雖然不熟但尚可培養感情(?),更多的是在環境安裝上面的困境(掙扎大半年華後,決定安裝 VS 作為 IDE),而這也是這個月來第二次老天給我暗示要我學習 .NET Framework 了,所以毅然決然,再度押上大把 青春 時間,很高興終於勉為其難不論是非對錯地至少跑出了結果,畢竟在 Xcode 不是很舒適的舒適圈待久了,殊不知天外有天,IDE 外有 IDE,只是恰恰熟悉舒適而已🚬。

進入正題。

假設有個類別叫做 SimpleParser 需要測試。這個類別有個方法叫 ParseAndSum:
輸入是由零個或多個逗號(,)分開的數字所組成的一個字串,如果這個字串不包含任何數字,回傳 0,如果只有單一數字則回傳該數 int 值,如包含多個數字,則將數字相加後回傳總和。

程式碼本人

    public class SimpleParser
    {
        public int ParseAndSum(string numbers)
        {
            if (numbers.Length == 0)
            {
                return 0;

            }
            if (!numbers.Contains(","))
            {
                return int.Parse(numbers);
            }
            else
            {
                throw new InvalidOperationException("I can only handle 0 or 1 numbers for now!");
            }
        }
    }

簡單的測試

    class SimpleParserTests
    {
        public static void TestReturnsZeroWhenEmptyString()
        {
            try
            {
                SimpleParser p = new SimpleParser();
                int result = p.ParseAndSum(string.Empty);
                if (result != 0)
                {
                    Console.WriteLine(@"***SimpleParserTests.TestReturnsZeroWhenEmptyString: ------ Parse and sum should have returned 0 on an empty string");
                }
                else
                {
                    // 顧名思義,我希望 Print 出這句。
                    Console.WriteLine("Print me some success man!");
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }
        }

主控台來執行

        public static void Main(string[] args)
        {
            try
            {
                SimpleParserTests.TestReturnsZeroWhenEmptyString();
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }
        }

OK,一切不是很順利,我一直找不到 VS 的 Console 印在哪裡囧,不過最終左翻右找地成功在 Console 找到 "Print me some success man!"

第二章節會討論測試框架,我們先來討論一下另一個重要的問題,那就是「在開發過程中,該在 何時 撰寫單元測試。」這也是測試驅動開發冒出來的原因。


六、測試驅動開發 TDD

TDD 的技術相當簡單。(作者說的,不是我。)

  1. 撰寫一個會失敗的測試,以證明產品中程式或功能的缺失。
  2. 撰寫符合測試預期的產品程式碼,以通過測試。
  3. 重構程式碼。

這本書不會談更多有關 TDD 的技術,重點還是回到單元測試本身,如:測試的命名、可維護性、可讀性、是否測試正確內容等,TDD 是單元測試的延伸與技巧。

延伸談一下,成功進行 TDD 的三種核心技能:

  1. 僅僅做到先撰寫測試,並不能保證測試是可維護、可讀且可靠的。
  2. 僅僅做到撰寫出可維護、可讀、可靠的測試,並不能保證你能獲得測試先行的各種好處。
  3. 僅僅做到測試先行,且測試可讀、可維護、可靠,並不能保證你能產出一個設計完善的系統。

作者建議一次關注一個技能,循序漸進的學習這個領域的知識,經常看到人們想同時學習三項技能(中槍...),學習過程非常艱辛(中槍 Again...),最後因為難度太大而放棄(介於中槍與沒中槍之間...),所以建議一次只關注一種技能的學習方法。


結語

本章中,定義了一個優秀單元測試該具有的特質:

  • 一段自動化的城市,它會呼叫另一個方法,然後驗證這方法或是該類別的邏輯行為某些預期結果
  • 用一個自動化測試框架進行編寫
  • 容易撰寫
  • 執行快速
  • 能由開發團隊裡任何人重複執行且得到一樣的結果

第一章:單元測試基礎,雖說是基礎,但仔細一看也都是硬底子,到這邊先告一段落囉!接下來會進入「第一個單元測試」。

#Unit Test #單元測試的藝術





也許你是單元測試甚至 TDD 愛好者,也許你著墨過一點單元測試,也許你是個剛開始寫程式的新手,不論你是誰,你都必須讀讀這本「單元測試的藝術」。 這是一本由 Roy Osherove 撰寫,針對靜態程式語言最經典的單元測試書籍。這個系列文會慢聊這本單元測試聖經,感受超層級的藝術。

留言討論