[心得] 91 的演化式設計:測試驅動開發與持續重構

Andy Lu
8 min readOct 5, 2020
Image by Free-Photos from Pixabay

這是第二次上 91 的課,我在 6 月時報名參加「針對遺留代碼加入單元測試的藝術」」學習如何單元測試、如何隔離依賴、如何使用 mock framework … 。所以這一次,我參加的是 TDD 的課程。

這次的課程 「演化式設計:測試驅動開發與持續重構」,主要分為兩個部分「重構」與「測試驅動開發 (TDD)」。

什麼是重構?
to restructure software by applying a series of refactorings without changing its observable behavior.

在不改變程式碼的外在行為的前提下,對程式碼做出修改,以改進程式的內部結構。

重構-改善既有程式的設計 Martin Fowler著

知道「重構」這個概念有一段時間了,我也嘗試將自己的程式碼「重構」一番。「重構」最重要的前提就是「不改變程式碼的外在行為」,由於我沒有利用單元測試來確定測試案例 (Use case) 沒有因為我的修改而改變行為,所以在每次的「重構」之後,原本程式碼的行為可能會不一樣,這造成了原本可能沒有問題的地方,越「重構」越糟糕。

為什麼需要重構?

簡單來說,重構的目的是為了消除重複程式碼、使程式碼容易理解、幫助找到 Bugs 以及幫助提高寫程式的速度。 — 重構 — 改善既有程式的設計

在課程裡面, 91 請我們依照需求完成一個小型的專案,在各組完成該專案之後,91 帶我們看看各組學員的程式碼,並且講解重構的方法。因為程式碼有單元測試做保護,所以在進行重構的時候,我們就可以確保重構完的程式碼行為與原本的一致。

程式碼的壞味道

什麼時候需要「重構」呢?在「重構 — 改善既有程式的設計」第三章中,介紹很多程式碼的壞味道,當程式碼出現了這些壞味道,那就代表需要重構了。

在 91 的 Live demo 之下,這些「壞味道」都從學員的程式碼中飄了出來,下面介紹本次課程當中,有提到的「壞味道」。

如果一段程式碼重複出現,我們可以利用提煉方法 (Extract Method),將重複出現的程式碼用方法替換。

例如:

有一個方法名為 play() ,在裡面會判斷 Player1 以及 Player2 的分數,若Player 1 的分數大於 Player 2,則顯示雙方的分數 (分數領先的寫在前),接者列印出超前的分數。

我們可以將上面的程式碼用 Extract Method 改為:

Feature Envy (依戀情結)

若函式對某個類別的興趣高過自己所處的 host 類別的興趣。那麼,我們應該將該函式用 Move Method (搬移方法) 搬移至其他類別。

如果一個函式有用上多個類別的特性,那麼它應該被放在何處呢?

哪個類別擁有最多「被此函式使用」的資料,就將該函式搬移到該類別。

Data Clumps (資料泥團)

有時候,我們常常可以在類別中發現,某些資料型別都會成群結隊的待在一起,這時候我們可以使用 Extract Class 方法,將這些經常會一起出現的資料型態搬移到一個獨立的類別裡,接者將原本使用該資料型態的地方改用新的類別替換 (Introduce Parameter Object)。

Primitive Obsession (基本型別偏執)

跟 Data Clumps 有點類似,不過這邊談的是基本型別,有時候我們會因為懶,所以不想要用物件,只使用基本型別,所以在傳遞給函式的時候,只會傳遞基本型別而不是用物件的,久而久之,這樣的程式碼就會發出壞程式的臭味。

Temporary Variable (臨時變數)

在函式裡,有時候會定義一臨時變數來計算某些值,這個值或許只有在一個地方使用,使用 Inline Variable ,可以將使用臨時變數的地方直接將運算式插入函式內。

Abstraction Distraction (抽象干擾)

在一個方法帶入一個抽象/介面作為參數,但是這個參數可能與這個方法的意義是不同的,那麼就容易被傳入的參數干擾。

例如:有一個方法 eat() 呼叫傳進來的 Car 物件,然後呼叫 Car 物件裡面的 driver.eat ():叫車子的司機吃飯。
但是,從函式的角度來看,傳入 Car 物件應該是不能夠吃飯的,所以就會被參數給誤導。

void eat(Car car){
car.driver.eat();
}

所以改成傳入 Driver 語意上會比較順,函式的使用就不會被參數給影響:

void eat(Driver driver){
driver.eat();
}

單元測試除了可以用來保護程式碼不會在重構的時候被弄壞,也可以用來發現 Bug。在 Live demo 的時候,有學員發現了一個 Bug,91 立刻補上單元測試確認該 Bug 的確存在,修改完成之後,再用測試驗證是否解決。

TDD 原則:紅燈 → 綠燈 → 重構

紅燈:用單元測試描述 Spec。每一個測試案例 (Test Case) 都必須要滿足 User Story。

綠燈:修復前一步驟發生紅燈的原因,有可能是編譯錯誤 (因為還沒有實作產品代碼 (Production code)),也有可能是邏輯不正確 (因為測試新的測試案例,原本的產品代碼無法滿足此測試案例)。在這一步驟,需要以最少的程式碼讓測試變成綠燈。

重構:當測試變成綠燈之後,就可以開始重構程式碼,無論是測試程式碼或是產品程式碼,都可以重構。

開始 TDD 之前

常常在公司的專案裡面發現,PM 提出的需求可能都是短短一句話,以往我們都是收到需求之後開始猛做,因為沒有把需求探索清楚,所以在程式碼的編寫上,有時候會變得綁手綁腳的,有可能寫到一個段落才發現有地方沒有想得很清楚,需要修改之前的程式碼。

91 在丟出需求之後,便開始跟學員進行需求探索。「需求探索」是在還沒有進行程式碼編寫之前,先試著發現這個需求會有什麼樣的使用情境,在白報紙上寫下每一個情境,就成為了一個個的測試案例 (Use Case)。在一來一往的需求探索之中,需求變得越來越明確,也從原本的一句話,變成好幾張白報紙的內容。

需求探索後,便要開始構思測試案例的順序,每一個新的測試案例,應該都要基於前面的測試案例內容迭代上去。所以每一個測試案例都必須要有一個目的。課堂上,91 利用便利貼將每一個測試案例的目的貼在測試案例的旁邊,所謂的目的有可能是「決定方法名稱」、「加入屬性」… 等等。

有了這些需求探索的內容,在使用 TDD 的時候就會更有依據,我們才可以寫出更符合 User Story 的產品代碼。

Baby Steps

在對產品程式碼 (Production Code) 修改時,每一次綠燈、重構,都只寫最少的程式碼來滿足當下的情況。例如:如果要讓測試變成綠燈,我們可以先用 hard code 來滿足,而重構則是每次都只關注一個地方,不做太多的修改。在每踏出一步,都可以立刻檢視是否正確,假如正確,便可以繼續往下一步邁進。

高內聚、低耦合

好的程式碼應該具備高內聚、低耦合的方向。所謂的「高內聚」指的就是一個類別裡面,所有的屬性、函式都是同樣的職責,將具有相同功能的物件打包在一個類別裡面。「低耦合」:每一個類別都應該與其他類別的關係越少越好,如此才不會依賴著對方。

在重構的時候,將一個類別中可以獨立的內容放置在另一個類別中,一來可以減少類別中程式碼的數量,二來該獨立的類別就具有高內聚的方向。

TDD 最大的效益之一,在於面對龐大的需求,能化繁為簡,穩扎穩打,自我迭代式的堆砌出滿足需求的產品程式碼。

試過一次 TDD 就會發現,有些物件的設計是因為重構時發現了對應的壞味道,而重構出對應物件,因為資料內聚而提供對外行為。換句話說,在程式設計的時候,不需要將所有的類別都定義出來,有些類別是在重構之後便自然地出現了。

- by 91

簡單設計法則

我們的最終目標是,物件與物件之間的溝通越簡單越好。
例如要完成同樣一件事:
參數越少越好
回傳越簡單越好
對外公開行為越少越好
用到的物件數越少越好
同時不具備其他的 code smell (壞味道)

課後心得

要怎麼才能夠編寫出好的程式碼?在開始寫之前,一定要好好的了解需求,唯有清楚需求,才能夠把程式碼寫清楚。使用 TDD 可以幫助我們只把需要的程式碼寫進去,在每一個測試案例,都應該要滿足某一個情境,當所有的測試案例寫完,所有的情境也就滿足了。

Pair Programming:這對我來說是蠻新鮮的一種方式,這達成了用嘴寫程式的成就 (誤) ,兩個人一起在同一台電腦上 Coding,針對每一段程式碼立刻進行討論,有問題的地方,馬上就能得到改善,不過當我在後方用嘴寫程式的時候,我恨不得跳下去寫,這一點可能需要改進。

練習、練習還是練習,TDD 是一個不好上手的技術,自己在練習的時候,很容易就一口氣寫太多 (違反 Baby Steps),假如這段程式碼有問題,只能全部 roll back,沒有辦法回到上一個狀態。

重構最重要的就是辨別壞味道,壞味道出現的時候,那麼程式碼就該重構了,辨別「壞味道」也是一個重要的學習方向,唯有知道什麼樣的程式碼會被稱為「壞味道」,才能夠把有壞味道的程式碼重構一番。

--

--

Andy Lu
Andy Lu

Written by Andy Lu

Android/Flutter developer, Kotlin Expert, like to learn and share.

No responses yet