【單元測試的藝術】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 的技術相當簡單。(Me: Huh?)

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

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

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

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

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


結語

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

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

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

#Unit Test #單元測試的藝術







你可能感興趣的文章

619. Biggest Single Number

619. Biggest Single Number

Why the HRCI PHRca Exam Matters for California HR Professionals?

Why the HRCI PHRca Exam Matters for California HR Professionals?

轉職前端工程師之路 Day1

轉職前端工程師之路 Day1






留言討論