iT邦幫忙

2021 iThome 鐵人賽

DAY 6
1

「然而,沒有測試套件,他們就喪失確保『程式修改後是否仍能照預期般工作』的能力,他們沒辦法保證『對系統某部分的修改不會搞爛系統其他部分的程式』。所以他們的程式缺陷率開始上升」

「他們開始害怕修改程式,他們的產品程式開始腐壞,最後變成沒有任何測試、混亂和 Bug 叢生的產品程式」

取自: Clean Code (p.140)

關於整潔的測試這個議題,本身就能寫成一本書,本章節僅做簡單概念介紹

CH9: 單元測試 (Unit Test)

  • 測試程式保存和加強了產品的:

    • 可擴充彈性 (Extensibility)
    • 可維護性 (Maintainability)
    • 可再利用性 (Reusability)

    原因很簡單,有了測試程式,你就不會害怕修改程式。無論你的程式架構分割的多好,每一次的修改都可能潛藏錯誤。沒有了測試,你將害怕改變會導致其他尚未察覺的錯誤!

  • 先寫測試程式反而能加快產品開發的速度
    作者的朋友 Jason Gorman 以一個將羅馬數字與整數互相轉換的小程式為例,共切分 6 個小階段開發程式,並故意間隔採用 TDD 開發策略 (即,開發功能前先寫好測試程式):
    https://ithelp.ithome.com.tw/upload/images/20210921/20138643cecrm7Xog8.png
    取自: Clean Architecture (p.9)

  • 我們可以發現:

    • 有採用 TDD 開發策略的日子比非 TDD 日(不寫測試,直接上) 的開發效率平均快上 10%
    • 即使是開發最慢的 TDD 日也比開發最快的非 TDD 日還省時
  • 我們或許可以得到兩個啟發:

    測試程式對一個軟體專案的影響程度就跟產品程式一樣重要

    想要走的快,唯一的方式就是要走的好

    取自: Clean Code (p.150) & Clean Architecture (p.10)

TDD 的三大法則

  • 每個知道 TDD 的人都會在寫產品程式之前,先撰寫好單元測試。然而這條準則僅僅是冰山一角而已,來看看以下 TDD 的三大法則
    • Rule 1: 在撰寫一個單元測試 (測試失敗的單元測試) 前,不可撰寫任何產品程式
    • Rule 2: 只撰寫剛好無法通過的單元測試,不能編譯也算無法通過
    • Rule 3: 只撰寫剛好能通過當前測試失敗的產品程式

讓測試程式整潔

  • 測試會覆蓋所有的產品程式,數量足以和產品程式匹敵,將產生管理問題
    測試程式會隨著產品程式的演進而修改,而當測試程式越陷入一團混亂時,所花的時間可能比開發新產品還要多。當開發者將錯誤歸咎於測試套件時,他們會將整個測試套件都捨棄掉

    「然而,沒有測試套件,他們就喪失確保『程式修改後是否仍能照預期般工作』的能力,他們沒辦法保證『對系統某部分的修改不會搞爛系統其他部分的程式』。所以他們的程式缺陷率開始上升」

    「他們開始害怕修改程式,他們的產品程式開始腐壞,最後變成沒有任何測試、混亂和 Bug 叢生的產品程式」

  • 測試程式跟產品程式一樣重要
    「容許測試程式可以是混亂的」,是失敗的源頭。測試程式也需要花時間思考、設計、和維護

  • 良好的測試例子: Build-Operate-Check

    // Test 1
    public void testGetPageHierarchyAsXml() throws Exception {
       makePages("PageOne", "PageOne.ChildOne", "PageTwo");
    
       submitRequest("root", "type:pages");
    
       assertResponseIsXML();
       assertResponseContains(
         "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
       );
    }
    
    // Test 2
    public void testSymbolicLinksAreNotInXmlPageHierarchy() throws Exception {
       WikiPage page = makePage("PageOne");
       makePages("PageOne.ChildOne", "PageTwo");
    
       addLinkTo(page, "PageTwo", "SymPage");
       submitRequest("root", "type:pages");
    
       assertResponseIsXML();
       assertResponseContains(
          "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
       );
       assertResponseDoesNotContain("SymPage");
    }
    
    // Test 3
    public void testGetDataAsXml() throws Exception {
       makePageWithContent("TestPageOne", "test page");
    
       submitRequest("TestPageOne", "type:data");
    
       assertResponseIsXML();
       assertResponseContains("test page", "<Test>");
    }
    

    上述的每個測試都被拆解成三個部分
    1. 建立測資
    2. 操作測資
    3. 結果是否如預期

    任何人都可以在不被細節誤導和干擾的情況下,馬上瞭解測試程式

一個測試一次斷言 (Assert)

  • 上述例子雖然採用了 Build-Operate-Cehck 的模式來設計測試程式,但仍有另一派人認為,每個測試函式都只能有唯一的一個 Assert。好處是每個測試都只會產生一個結論,人們可以更快速容易地瞭解它們。讓我們以此概念來改寫上述例子:

    // Test 1 (Refactored)
    public void testGetPageHierarchyAsXml() throws Exception {
       givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
    
       whenRequestIsIssued("root", "type:pages");
    
       thenResponseShouldBeXML();
    }
    
    public void testGetPageHierarchyAsXml() throws Exception {
       givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
    
       whenRequestIsIssued("root", "type:pages");
    
       thenResponseShouldContain(
         "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
       );
    }
    

    注意: 這邊依據[1] Given-When-Then 的概念來替換了函式的名稱,這讓程式可讀性上升。不幸的是,這樣的拆解會導致重複程式碼產生

  • 要解決 Give-When-Then 模式產生的重複程式碼,可以利用 Template Method [2]設計模式來提取共用程式碼。也就是將 Given/When 提取至基底類別,Then 則放在不同衍生類別

  • 斷言僅能唯一或許是一種較為極端的作法,但不論如何,謹記:

    「測試裡的斷言應該盡可能地減少」

一個測試一個概念

  • 不要撰寫一個冗長的測試函式,以下例而言,應該被拆解成三個獨立的測試
    public void testAddMonths() {
         SerialDate d1 = SerialDate.createInstance(31, 5, 2004);
    
         SerialDate d2 = SerialDate.addMonths(1, d1);
         assertEquals(30, d2.getDayOfMonth());
         assertEquals(6, d2.getMonth());
         assertEquals(2004, d2.getYYYY());
    
         SerialDate d3 = SerialDate.addMonths(2, d1);
         assertEquals(31, d3.getDayOfMonth());
         assertEquals(7, d3.getMonth());
         assertEquals(2004, d3.getYYYY());
    
         SerialDate d4 = SerialDate.addMonths(1, SerialDate.addMonths(1, d1));
         assertEquals(30, d4.getDayOfMonth());
         assertEquals(7, d4.getMonth());
         assertEquals(2004, d4.getYYYY());
     }
    

整潔測試法則: F.I.R.S.T.

  • Fast
    測試要能被快速地運行
  • Independent
    測試程式不應該互相依賴,要能獨立地運行,並可按照任何順序進行測試。當測試互相依賴,會讓錯誤的診斷變得更困難
  • Repeatable
    測試環境應該可以在任何環境中重複執行,避免發生「為什麼測試會失敗」的藉口
  • Self-Validating
    測試程式應該輸出布林值
  • Timely
    單元測試要恰好在產品程式之前不久撰寫。如果你在撰寫完產品後再去寫測試,你可能會認為某些產品難以被測試,導致你不會去設計 可被測試(Testable) 的產品程式

小結

「測試程式對於一個專案的健康程度,就跟產品程式一樣重要。如果你讓測試程式腐敗,那麼你的產品程式也會跟著腐敗。保持你的測試整潔


P.S. 關於 TDD 的探討在國外也有另一群人批評其為 "Cargo Cult" (邪教),有興趣的讀者們可以自行 Google 查找相關資訊。另外,究竟台灣的職場環境適不適合導入測試驅動開發(或說,該如何說服主管?),筆者挺好奇各位大大們的看法,歡迎交流~

Reference

  1. BDD - 如何寫出好的 Gherkin 語法展示你的 Specification By Examples
  2. [Design Pattern] Template 模板模式

上一篇
Day 05: 物件及資料結構、邊界
下一篇
Day 07: 類別、系統、羽化
系列文
成為乾淨的開發者吧! Clean Code, Clean Coder, Clean Architecture 導讀之旅31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言