大類的技術手記

筆記 - 自動化測試與 TDD 實務開發

  • 分類:
  • 字數:雞 x 11雞數:計算文長的常見計量單位,一般而言數字大小與文章長度呈正相關

第一次認識 91 這個名字是在「iT邦幫忙」鐵人賽中注意到的,這個比賽簡單來說就是一些想挑戰自己的人,連續 30 天發表技術類的文章,像瘋子一樣的比賽。之所以記得 91 這個名字,除了名字好記外,還因為他的文章內容特別充實而且有趣,所以一個不小心,就開始追他的文章了(羞),雖然我自己不使用 .Net 開發,但我仍然覺得讀他的文章令我獲益良多。

這次難得有機會,去參加 91 主講的「自動化測試與 TDD 實務開發」課程,反正公司出錢,自然是要盡力爭取,結果一個不小心爭取到了,所以就決定不小心的去上課。

結果聽著聽著,忽有所感,覺得好像得到了什麼,突然一聲系統提示:您的等級上升一級,所以這篇心得筆記就出來了。

基本上這篇文章主要的內容是他上課的內容還有一些我平常學習心得的整理。

為何要測試?

自動化測試最主要的目的是減少開發的時間。除了具體的開發時間外,還包含因錯誤而修改的時間,或是因需求修改而重寫的時間。

我們在開發程式的過程中,時常會碰見下列問題:

  • 花太多時間找尋錯誤,改 A 掛 B,在改 A 的當下,並不知道會影響到 B,直到系統出事了,才只好全部分析整個系統一遍,才知道其實是 B 掛了,非常浪費時間!
  • 系統刪刪改改,改到最後到底系統的那些部分的運行是正確無誤的,其實就算是開發者自己也不敢確定。尤其是當需求改來改去時,這種問題會變得非常常見,結果因為這份不確定,時常又得花時間做確認,還是浪費時間。
  • 環境無法測試,開發者常不能接觸實際的系統,只能憑空猜。這包含許多種情形,並不單指是實際上線的系統,也可能是兩組團隊同時開發一部分的產品,因為另一組團隊的東西尚未完成,所以無法測試,還要等到另一組團隊完成後才能整合,這個過程十之八九都會出事,所以還要再花更多時間解決,都是浪費時間啊!!

所以為了避免浪費時間,結論就是要測試,測試可以減少上述的問題,增加開發的效益。正所謂好的測試能帶你上天堂,省掉許許多多的開發時間,過上不用加班的美好生活!這真是太好了!(迷之音:有夢最美,希望相隨)

但是也別忘了,測試不是目的,只是為了讓開發的過程更順利而已。

單元測試

這裡指的測試包含多個層面,包含單元測試(Unit Test)、整合測試(Integration Test)等等,我主要會提比較有感覺,有關單元測試的部分。

單元測試為最小的測試單位,不含商業邏輯,通常為測試一個函式。由於一次只會測一件事,所以無論是成功還是失敗,都能明確知道其代表的意義。

反過來說,如果不這麼做的話,我們就很難直接透過測試知道那裡出現問題了,好比說:

def test_crud():
    db = database()
    db.insert('chicken', 3)  # 新增
    db.update('chicken', 4)  # 更新
    db.delete('chicken')     # 刪除
    assert_equal(None, db.read('chicken'))  # 讀取

如果死了,究竟是那裡出錯呢?有可能是新增失敗,也可能是刪除失敗,我們無法直接看出來。

單元測試的意義,在於透過模擬外部如何使用,來驗證其行為是否符合預期。我們可以在寫測試的過程中,確認功能的需求。為了讓自己寫的程式能夠被測試,就會盡可能思考怎麼去驗證這個程式,不停地改善程式的架構,不知不覺的就能減少物件之間耦合性。

有了單元測試,我們再不用擔心改東改西,我們可以確保每次增加一個新的功能,都不會影響到之前的需求,就算真的影響到了,也能馬上知道那裡出問題,而且錯的地方很清楚是什麼,不會誤判。

而測試案例本身,幾乎就可以拿來當成說明需求和使用方法的文件。有了它,我們就可以減少說明文件的使用,而且對 RD 來說,這樣可能反而更容易搞懂你的程式碼。

FIRST 原則

好的單元測試應該要符合 FIRST 原則,分別是 F(Fast)、I(Independent)、R(Repeatable)、S(Self-validating) 和 T(Timely)。

  • F (Fast)

    測試所需要時間要快,最好不要超過 500ms。如果花的時間太長,開發者就不會想測試,失去寫單元測試的意義。而且速度太慢通常代表物件有外部相依,可能需要重新審視一下物件的相依情形。

    Note

    時間的實際的長短,也有多種說法,有的說 100ms,也有的說 200ms,我認為數值不重要,應該要依實際情形而定。

  • I (Independent)

    為了保證其代表的意義,也為了避免誤判,單元測試最關鍵的要點是外部相依性必須為零,舉凡檔案、網路、其他類別都不能影響測試的結果。一旦包含外部的因素,好比說網路,一旦相依於網路,如果測試失敗,很難直接確定是函式實作的錯誤,還是網路的問題。一旦依賴其他類別的功能,失敗了也很難確認是自己的問題還是該類別的問題。

    除此之外,測試案例(TestCase) 之間的相依性也應該要為零。

  • R (Repeatable)

    無論何時、何地,測試的結果不能改變。比如說寫一個函式,「測試今天是否為二月二十四日」,同樣的測試程式碼,不能因為不同時間測而有所差別。

  • Self-validating

    每一個測試案例都真能驗證某一件事,如果對就是對,錯真的會回傳錯,而這個過程不應該需要手動操作,才能判斷結果是否正確。

  • T (Timely

    程式碼與測試程式碼要即時,只有兩者都完成才算完成。不要相信測試程式碼有可能之後會補齊,那是不可能發生的。

除了基本的 FIRST 原則,實際撰寫測試時,也要非常注意測試程式碼的可讀性。因為別人很可能會直接透過測試程式來了解你的程式的功能和用法,以及是否滿足自己的需求。

3A 原則

為了增加測試程式碼的可讀性,可以使用 3A 原則的格式來寫測試程式碼。

3A 分別代表:

  • Arrange(初始、期望結果):初始化目標物件和相依物件,設定物件之間的互動方式,並指定功能執行後的預期結果。
  • Act(實際呼叫):執行待測試的功能。
  • Assert(驗證):驗證結果是否符合預期。

實際寫的樣子約略如下:

def test_Calc_add_first_1_second_2():
    # Arrange
    target = Calc()
    first, second = 1, 2
    expected = 3
    # Act
    actual = target.add(first, second)
    # Assert
    assert_equal(actual, expected)

開頭是 Arrange,再來是 Act,最後則是 Assert 的部分,使用 3A 原則的格式撰寫測試,好處是只要了解該原則的人,就可以輕易看懂你的程式碼。

除此之外,一致的命名方式也可以進一步增加程式的可讀性。好比說待測的物件固定以 target 命名,或是期望的結果固定以 expected 命名等。

測試函式的命名以一樣容易理解為主,有時可以不用太在乎英文的文法,比較極端的例子,如果團隊都是台灣人,甚至可以考慮直接選擇用中文來命名函式。(迷之音:「畢竟人家 yahoo 的還不是用中文寫測試?」)

def test_Calc_add_參數給字串會回傳0():
    # Arrange
    target = Calc()
    first, second = "中文", "字串"
    expected = 0
    # Act
    actual = target.add(first, second)
    # Assert
    assert_equal(actual, expected)

但撰寫的格式,也不是說非要如此不可,只要有助於別人理解程式碼,並沒有規定一定要用上面例子的方式寫測試才行。

另外,3A 中的 Assert 也是很重要的一環,有時也是容易被忽略的一環,根據 FIRST 原則中的 Self-validating,「正確的出錯」也是不可或缺的。不是綠燈就好,要能正確地紅燈才是關鍵。你可以故意讓結果出錯,看看測試程式是否真能把這個錯誤抓出來。

驗證結果是否正確的情況有很多,除了最常見的測試回傳值是否符合預期外,也可以測試狀態的改變或是用 Mock 測試與外部的互動。

Note

可驗證的內容

  • 回傳值(最常見)
  • 狀態
  • 與外部的互動(Mock)

單元測試最重要的概念,我認為恐怕就是「隔離」了,也就是 FIRST 原則 I 的部分。

為什麼說「隔離」最重要?

我們可以反過來看如果沒有做好隔離,會有什麼問題?

沒有做好隔離的程式碼,因為每次都要用到多種功能,甚至可能要與外部溝通,因為執行測試的時間會長許多,也會違反 FIRST 的 Fast 原則。

平常上網,連一秒延遲都受不了,如果每次執行測試的時間太長,就不能養成「測試強迫症」的好習慣(咦?)。隨著所需要的測試時間越來越長,很多人最後就會放棄使用了。

沒有做好隔離的程式碼,互相相依,一旦發生問題,很難準確的判斷錯誤的原因何在。因為算出來的結果錯了,原因可能很多,也許是因為抓取資料的時候抓錯了,也許是確實是計算時出錯了,但說不定也可能結果是對的,只是輸出的格式錯了而已。我們可能會因為誤判錯誤的地方,浪費太多時間在除錯上。

所以說如果測試不能準確告訴我們那裡錯了,很多時候其實就失去測試的意義。

還有一個問題,商業程式通常都不只有一個人開發,這時候還得找出那個環節出錯才行。這更加麻煩,由於對別人的程式碼的不熟悉,再加上人們都「傾向」認為不是自己的錯,而是別人的錯,所以還必須花時間「證明」是誰的錯。無形中,時間就這樣又被浪費掉了。

除此之外,由於多人平行開發,所以在開發的過程中,時常會碰到別人開發的部件可能還沒完成的情況,既然沒完成,又怎麼能測試呢?

所以為什麼要隔離?透過拆分不同物件的功能,讓每一個物件只負責一件事,盡可能的減少對別人的依賴,讓別的程式碼不會影響到這個類別。這樣不但可以減少錯誤發生,可以讓測試的執行速度變快,還能增加類別重用的可能性。

但物件還是要相互溝通,不可能真的完全無關,這時就可以利用一些物件導向的技巧來解決這個問題,像是依賴介面、依賴注入等技巧解決。詳細的技巧可以使用 Google 查詢,或是直接看 91 的部落格(推銷?)。接下來,透過實作其介面的方式製作假物件,模擬物件行為,然後再將這些假物件傳給目標,確認其行為是否合乎預期,以達成測試的目的。

Note

為何要隔離?

  • 執行速度快
  • 關注點分離
  • 單一職責
  • 可以獨立測試
  • 健壯性

如何解決相依?

  • 關注點分離
  • 單一職責
  • 依賴介面
  • 依賴注入

要實行單元測試,不光是加上測試程式碼而已,設計的方式也必須要有所改變才行,另一方面,大部分舊的程式碼,就只是為了寫出功能,沒有考慮如何測試。這麼一來,前人留下的債,後面的人改得就很辛苦。問題是後面的人為什麼要幫前人改?有什麼動力幫前人改?又不會加薪?就我看來,這些就是 TDD 實務上時常會覺得難以實現的原因。

不過話雖如此,前人寫的程式碼就算了,我認為自己的程式碼還是要有所要求才行。

程式碼覆蓋率(Code coverage)

如果程式沒有被測試保護,一旦發生改動,就不能保證最後結果是否為正確。在寫測試的過程中,可以用程式碼覆蓋率這個指標來保證測試的品質。

測試的覆蓋率若為 100%,代表自己的程式都有被測試保護,任何一個改動都可以非常安心。

反過來說,如果覆蓋率不足,就代表可能測試案例(TestCase)不足夠,有該測的東西沒有測。碰到這種情況就該增加一些測試案例來保證行為,除非它真的很不重要。

話說回來,如果真的不重要的到不需要寫測試,可能也代表另一狀況,那就是這段程式碼可能與需求無關,這時就可以選擇直接刪去這段無用的程式碼。

Note

Code Coverage 不足的意義

  • 測試案例不足
  • 存在與需求無關的程式碼

程式碼覆蓋率是一個非常實用的指標,也是非常不實用的指標。大家都知道這個數字越高越好,但你不用指望前人就有這個意識,所以一開始大家都是零。老闆看到這個數字可能就會說:「這什麼指標?」「越高越好?」「最高是多少?」「100%?」「那你說為什麼我們不是 100%?明天內解決!」

Fu*k!

結果大家都不敢導入這項指標(至少不敢讓老闆知道)。

所以一個重要的觀念是這個指標千萬不能急著拿來當 KPI,畢竟這包含了很多層面的因素在裡頭。不過雖然在實務上要求完美的 100% 可能沒法這麼快,但也不能因為這樣就不用。

反正雖然不能保證「數字」,但還是可以「趨勢」嘛!只要這個數字持續上升,那也就表示新寫的程式碼確實都有做好測試不是嗎?只要保持下去,之前的程式碼影響會越來越小,覆蓋率的數字總會越來越漂亮的,所以關鍵是只需要確保數值不可以下降就行了。

Note

  • 檢查測試案例有沒有包含最主要的情境(尤其是線上回報的)
  • 檢查有沒有不必要的程式碼和測試項目
  • 數字不是絕對,只要保持上升即可

整合測試

原則上,不是單元測試,幾乎都可以說是整合測試,不過在實務的情況下,整合測試(Integration Test),至少至少針對某一個類別的測試,常與單元測試幾乎沒什麼不同,畢竟無論是單元測試還是整合測試,需求都不會改變。

要說最直觀的差別,可能就是單元測試相依的部分是透過 Stub/Mock 來模擬,而整合測試則不用,所以可以很明顯的看出來單元測試的測試案例中,通常都會有相依物件的建立和注入,但整合測試就沒有。

不過整合測試也可以是更高一層,針對 module/package 的測試,這種就是黑箱測試了,不需要知道內部的相依性和實作,只要知道輸入和輸出就可以做驗證。

事實上,若要再分,還是有更高粒度的測試,也就是這種就是 Acceptance test,讓使用者來驗收測試,以使用者的角度來看需求是否有如預期地被完成。這種測試常常會有UI,算是最貼近使用者的測試。

測試驅動開發(TDD)

測試驅動開發,它開發的流程其實一直都很清楚,很多人也耳熟能詳,簡單來說就是先寫測試,然後才寫實作。

實作不用寫得太複雜,只要剛好可以通過測試即可,一步一步來,每一行程式碼都只為了滿足需求,用最笨的實作完成測試,不斷重覆這個流程,直到出現那種「這種程式碼讓人難以忍受的感覺」為止,好比說當你發現某段重覆或類似的程式碼出現過三次,這時就應該要重構了。等到重構完後,再開始進行新的一輪新功能實作。

Note

三次法則

重覆的程式碼最多只能出現二次,因為一旦出現三次,往往就會有第四份、第五份,所以重構是必要的。反過來說,之所以可以允許有兩份,常是因為實務上的考量,畢竟會發生這種事情,有可能是很緊急的情況。

這種開發流程的改變,還有另一個好處--如果說平常寫程式是腦袋已有了一個小雞,只是花時間把它寫出來,那麼 TDD 就是腦袋放不下小雞的人的福音。你可以不用放下一整隻雞才能開始寫,只要想到一隻雞腳就可以開工了。當寫完一隻雞腳,你可能就會發現寫另一隻雞腳變得如此簡單,不斷如此反覆,不知不覺,一整隻雞就這麼寫完了。(然後吃掉!)

Note

用最笨的方式開發,千萬不要忍不住多實作,這樣才能減少過度的設計。而且習慣了 TDD,萬一寫了新的測試卻沒出現紅燈,很可能反而會嚇到自己(咦?

基本上,只要依循 TDD 的流程開發,每一行程式碼就都會有測試保護,因此完全不用擔心重構會出問題,而每次需求的變動,也不用擔心會不會把之前的程式碼改壞,因為測試都會乖乖「慘叫給你看」。既然不會發生問題,就很容易「忍不住」想要將程式碼改得更好看,所以整個程式架構就會越來越漂亮。

Note

TDD 的原則

  • 只寫剛好可以通過測試的程式碼
  • 不能在測試不過的情況下加新的測試
  • 新加的程式碼只允許剛好讓先前不過的測試通過

使用 TDD 重構前人的程式碼

實務上要導入 TDD,免不了必須要處理前人的程式碼,這裡 91 已經將相關的技巧整理的非常好,我覺得這甚至是他在有關 TDD 的介紹最精華的部分,他用宅配的例子講解,非常精彩,有興趣的可以直接去看他的文章(傳送門),反正我也不會寫得比他好。

不過整理一下,大致上有下列幾個步驟:

  1. 先用測試保護整個程式(雖然各別的物件可能不好測,但整個程式還是可以測的)
  2. 為程式加上適當的註解(用人類的語言描述程式碼在做什麼事)
  3. 將程式的 UI 與程式邏輯分開
  4. 將各個物件的職責分開,學著用各個物件的角度看世界,不是我的不該我做
  5. 再針對這些物件的行為建立單元測試
  6. 擷取共同之處抽出介面,最後再將生成物件的職責獨立出來。

關鍵是每一次重構都必須要有測試保護,不斷小範圍的重構會比一次大範圍的重構有效果,而且一次只做一件事,切勿邊重構邊加需求。

TDD 的好處和意義

  • 減少過度設計(Over-engineering)

    很多人都有過度設計的問題,尤其是那些學太多設計模式(Design Pattern)的人來說,常常會忍不住會想套用,最後寫了一堆「看起來很有彈性」但沒用的程式碼。

    但是 TDD 每一次循環,都是只寫剛好能滿足需求的程式碼,每一行程式碼都是為了需求而產生,所以永遠不用擔心過度設計的問題。

  • 改善 API 的設計與可用性

    TDD 最大的特色就是先寫測試再寫實作,也就是說,再實作之前,就必須先考慮該如何使用。很多時候,實作者和使用者的想法差別是很大的,很多時候,實作者所謂的「我覺得這樣這樣很好用」與使用者的想法大相逕庭,這是看事情角度的問題,不過現在因為必須先寫測試,因此實作者便被迫以使用者的觀點思考。

    畢竟,不使用的話,怎麼知道設計對不對?

    TDD 可以改變實作者看問題的角度,由於測試先行,還沒有實作,就比較容易能以使用者的角度來看問題,這樣設計出來的 API 可用性就會比較好。至少,只要你有辦法寫完測試,你就能保證只要而將 API 實作出來,這個功能絕對是可用的。

  • 減少維護說明文件的需求

    有人說,RD 最痛恨兩件事,一是寫註解和說明文件,二是別人不寫註解和說明文件。

    程式畢竟不是一個人開發的,為了互相溝通,還是必須要有一個交流的方式。與其要求有交流障礙的 RD 說明他的程式,看起他吱吱嗚嗚,詞不達意的發言,直接看他的測試可能還比較快。至少對 RD 來說,這可能反而比看說明文件還來得容易。

  • 減少開發和思考的難度

    俗話說,「萬事起頭難」,程式的問題通常很複雜,很難一開始就用全局的思考來寫程式,時常會找不到切入點。TDD 一開始都是用最簡單的案例開始,一步一步處理越來越複雜的案例。由於只單走一條路,事情就不會這麼複雜,當走通了一條路,之後再拓寬便容易許多,開發的過程就會變得比較順暢。

  • 增加程式碼的品質和確保其正確性

    由於測試先行,所以在確定能不能用之前,就要先知道東西能不能測,如果發現不能測,就會知道職責分配的方式有問題。這時就能重新考慮程式設計的架構,進而達到改善品質的效果。

    有了測試,不用擔心改東壞西的問題,因為每一個需求都有測試保護,如果不小心影響到別的功能,也能馬上知道。如果沒有測試保護,你很難保證你改的東西一定不會有問題。所以最後就會變成大家都不敢去改動,不願意去重構,不適合的程式架構一直保留,隨著程式的增長,程式碼的品質就會越來越糟。

    你可以透過這些測試,知道你目前完成的內容有那些。而且因為有測試保護,你可以證明有測試涵蓋到的範圍都是正確的,你可以很有信心。如果之後發現了什麼問題,你只要補上相應的測試並讓它變成綠燈,就能證明你已經解決這個問題。

    Note

    TDD 的意義和好處(or 說服老闆的理由?)

    • 減少過度設計
    • 改善 API 的設計與可用性
    • 減少維護說明文件的需求
    • 減少開發和思考的難度
    • 增加程式碼的品質和確保其正確性

更進一步 - BDD

BDD 全名為老闆意向驅動開發(Boss-Driven Development),全世界最常見的開發方式,一切都是以老闆的意向開發,不過由於這種開發方式非常依賴老闆的腦袋,所以世界上才有這麼多失敗的產品……咦?好吧,至少「理想上」的 BDD 指的是行為驅動開發(Behavior-Driven Development)。

BDD 可說是進一步改善 TDD 的缺點的好物。

前面說了 TDD 這麼多優點,其實關鍵就 TDD 是讓開發的過程中更重視需求,需求要什麼,才做什麼。最理想情況就是需求與程式完全一致,才是最完美的。為了讓需求與程式是一致的,我們可以使用測試案例來比對需求是否一致。

概念好棒棒,但有一個小問題--無論測試案例寫得再好、再乾淨,但除了 RD,其他人都還是看不懂測試案例。要怎麼知道需求與程式的一致的?結果搞了半天,還是要準備一個落落長的文件來互相溝通。

我覺得 BDD 最大的好處便是能夠用人話來表達需求,讓 RD 以外的人看得懂,然後再透過工具轉換成可以使用的測試,讓 RD 自己看得懂。

沒錯,就是「翻譯蒟蒻」!代溝處理工具!

概念很好,但我覺得這種方式最關鍵的地方是工具,並且需要與好的 IDE 互相配合才能實現,所需要的觀念較少。好的工具可以讓需求可以用更清晰的表達,像是自動產生美美的圖表等,反過來也可以轉成非常實用的測試樣版,減少 RD 撰寫測試的時間,並且可以輕易的除錯等。

總結與心得

這些技術都有一個很簡單的目的,那就是滿足需求,讓程式確實與需求一致。並且盡可能的在這個目標下追求效率。在這個過程中,我們發現測試可以幫助我們達成這個目的,好的測試幾乎可以完美解決我們開發上碰到的諸多問題。但測試也有它的難處,畢竟它還是需要另外寫很多看似無用的程式碼,還是會花上不少時間,這免不了會讓人產生惰性,懶得去寫它。因此這時一個好的 IDE 可以幫助我們處理大部分無聊的工作,減少所需的時間,讓我們專注在開發上。

我覺得這是一個非常充實的 21 小時的課程,雖然我不會 C#,也不熟 Visual Studio,課上得很辛苦,但我能說 91 的講解絕對不會比他部落格文章差,真的可以給個讚。

俗話說得好:「好課當修直需修」,有興趣可以去 SkillTree 關注一下。

最後是自己的期許,「盡可能的讓自己的程式碼都被測試覆蓋,沒有測試的程式碼都不算完成」

以上。

,,
,