如何使用PhpStorm實現TDD、重構與偵錯?
TDD要求我們先寫測試,雖然會在專案一開始多花一點時間,但只要我們選對工具,就可將花在測試、重構與偵錯的時間再省回來,讓我們雖然輸在起跑點,卻可贏在決勝點。
Version
OS X 10.11.2
PHP 7.0.0
Laravel 5.1.28
PhpStorm 10.0.3
物件導向
假如今天PM開的需求,就是希望我們做出一台X戰機,我們當然可以完全手刻出符合Spec的X戰機,但只要需求一變,需要我們改功能,加功能時,我們就很頭大了。
因此我們需要將X戰機樂高化,改功能只要換樂高積木,加功能只要加樂高積木即可。
物件導向簡單的說就是樂高導向,每個樂高積木就是class
,樂高積木的規格就是interface
或abstract class
,只要符合規格的積木,我們都可以換掉或加上去。
SOLID
程式語言提供了各種物件導向程式的語法,倒底要怎樣寫才是符合物件導向精神的程式呢?
SOLID原則1 1詳細請參考大澤木小鐵的從實例學習設計模式 威力加強版 使用PHP
- S : Single Responsibility Principle (單一職責原則)
- O : Open Closed Principle (開放封閉原則)
- L : Liskov Substitution Principle (里氏替換原則),Least Knowledge Principle (最小知識原則)
- I : Interface Segregation Principle (介面隔離原則)
- D : Dependency Inversion Principle (依賴反轉原則)
Laravel的作者Taylor Otwell曾有一段話 : 2 2詳細請參考Laravel之父 : 學習出色的Design Pattern
如果有人想成為更棒的PHP工程師,你會怎麼建議?
學習出色的Design Pattern。這不只適用在PHP。你可以在任何程式語言使用這些pattern。尤其是SOLID。把這五個徹底學好,它會把你帶到新的境界,我每次寫code幾乎都在想這五個。
除了Design Pattern,重點在於更根本的SOLID,這5點才是物件導向的心法。
設計模式
設計模式其實就是大神們留下來好的物件導向設計範本。3 3物件導向設計模式-可再利用物件導向軟體之要素
優點 :
- 具體方案 : 至少是個具體的物件導向設計方式,不再流於抽象概念。
- 用得巧就很棒 : 只要在適當的場合,使用適當的模式,就會非常漂亮。
缺點 :
- 學習門檻高 : 理解設計模式已經不容易,要套用在實務上更難,很依賴天份。
- 容易over design : 初學者容易一開始就套大量設計模式,導致系統提前過於複雜。
重構
將既有的程式整形成符合物件導向精神的程式。4 4重構 : 改善既有程式的設計 (二版)
優點 :
- 學習門檻較低 : 重構招式較平易近人,容易學習。
- 可套用在legacy code : 不再只有新的專案才能物件導向。
缺點 :
- 需要依賴測試做保障 : 重構需要頻繁的測試,也需要測試保證重構沒有出錯。
TDD
既然重構需要測試,到底要先寫測試,還是後寫測試呢?
TDD的全名為Test Driven Development (測試驅動開發) 顛覆大家以往的習慣,強調先寫測試,再寫程式
,整個流程是 :
優點 :
- 提供重構堅固的屏障 : 有寫測試,我們才敢放膽重構。
- 避免over design : 只為紅燈變成綠燈寫程式,不會寫出額外的程式。
- Top Down思維 : 因為測試先寫,會以測試好寫的角度去寫程式, 會比較接近使用者,也符合物件導向的精神。5 5詳細請參考使用TDD實踐SOLID
- 偵錯快速 : 將來要debug時,只要一跑測試,就可以快速找到錯誤所在。
缺點 :
- 先寫測試,一開始會多花一點時間 : 所以我們要找更強的工具幫我們將時間省回來。
- 需要學習如何寫測試 : 寫測試有不少技巧,如3A原則、Mock物件、依賴注入、Assertion…。
設定環境
建立Laravel專案
1 | oomusou@mac:~$ composer create-project laravel/laravel Laravel51Refactor_demo 5.1 --prefer-dist |
在命令列下composer create-project
指令建立Laravel專案。6 6GitHub Commit : composer create-project
安裝Laravel Elixir
1 | oomusou@mac:~/MyProject$ npm install |
由於我們會使用Laravel Elixir在背後自動執行測試,因此要使用npm install
。7 7GitHub Commit : npm install
1 | oomusou@mac:~/MyProject$ gulp |
執行gulp
之後,若能出現如下圖的Sass Compiled
,表示Laravel Elixir已經安裝成功。8 8Laravel Elixir是否能安裝成功,取決幾個因素:Node.js、Gulp與Laravel Elixir之間的版本相依,詳細請參考如何在OS X安裝Laravel前端開發環境?
使用PhpStorm開啟
啟動PhpStorm。
選擇剛建立的專案目錄。
一開始indexing
雖稍微久一點,但只要做一次即可。9 9PhpStorm會對整個專案的檔案做index,以加速將來檔案的搜尋。
設定Namespace Roots
第一次開啟專案,PhpStorm會跳出Detect PSR-0 namespaces roots
要求你設定。選擇Settings | Directories
。10 10理論上選擇automatically
也可以,不過由於之前下了npm install
之後,將大量node packages安裝在node_modules
目錄下,若由PhpStorm自動去偵測目錄,將花較長時間,因此在此採用手動設定,詳細請參考如何使用PhpStorm建立Laravel專案?
設定Sources
目錄。
由於Laravel預設的namespace目錄是從app
目錄開始,因此選擇app
目錄,按下Sources
,右側會出現藍色Sources Folders : app
。
設定namespace名稱。
按下P
,設定prefix。
根據PSR-4,我們可以有很多namespace root,因此可以對目錄設定prefix,將app
目錄的prefix設定為App
。11 11這個步驟非常重要,設定好namespace root後,將來只要建立class,PhpStorm都會幫你管理namespace,不用再對namespace操心。
設定Resource Root
目錄。
Laravel預設將前端的asset放在resources
目錄,選擇resources
,按下Resource Root
,右側會出現紫色Resource roots : resources
。
設定Tests
目錄。
Laravel預設將測試程式放在tests
目錄,選擇tests
,按下Tests
,右側會出現綠色Test Source Folders : tests
。
設定PHP Interpreter
PhpStorm允許我們直接在IDE內執行測試與偵錯,因此我們必須告訴PhpStorm,我們使用PHP的版本,以及PHP interpreter位置。12 12詳細請參考如何使用PhpStorm測試與除錯?
設定PHPUnit
PhpStorm允許我們直接在IDE內跑單元測試,因此我們必須告訴PhpStorm,PHPUnit的autoloader與phpunit.xml
設定檔位置。13 13詳細請參考如何使用PhpStorm測試與除錯?
測試Gulp TDD
一開始已經使用npm install
安裝了Laravel Elixir,為了要使Elixir能自動在背景執行PHPUnit,只要我們一存檔就執行測試,需修改gulpfile.js
,加上.phpUnit();
。
在命令列執行gulp tdd
,啟動Laravel Elixir在背景執行PHPUnit。14 14在PhpStorm可按熱鍵:⌥ + F12,可在下方顯示terminal直接輸入指令。
開啟tests/ExampleTest.php
,這是Laravel所提供預設的測試,用來測試Laravel預設的首頁是否有Laravel 5
字串。
將5
改成4
,存檔後就會在右上角顯示紅燈,顯示測試錯誤。
若從4
改成5
,存檔後就會在右上角顯示綠燈,顯示測試成功。
若紅燈與綠燈都能出現,表示Gulp TDD正常。
TDD
Spec
計算一位顧客所有訂單的金額。15 15本範例改編自重構:改善既有程式的設計的第一章範例,原書使用Java,經簡化後改成PHP版本。
影片種類 | 租期 | 租金 | 逾期費 |
---|---|---|---|
普通片 | 7天 | 100 | 10 |
新片 | 3天 | 150 | 30 |
兒童片 | 7天 | 40 | 10 |
測試案例
普通片1支,10天16 16寫測試的第一步,就是要將spec寫成測試案例,也就是實際的input與output結果,如此才能根據input與output判斷測試結果是否正確。
100 + (10-7) * 10 = 130新片1支,5天
150 + (5-3) * 30 = 210兒童片1支,8天
40 + (8-7) * 10 = 50
設定Domain目錄
我們會將所有的class放在自己的Domain目錄下,或稱為Business Layer。17 17詳細請參考Laravel的中大型專案架構
首先,在app
目錄下建立VideoRental
子目錄。
輸入VideoRental
。18 18在左側選擇app
目錄,按下⌃ + N,出現下拉選單,選擇Directory
建立新目錄。
由於新目錄會有自己的namespace名稱,因此要修改composer.json
的psr-4
設定,加上VideoRental
與其目錄。
執行composer dumpautoload
建立新的autoload檔案。19 19這一步一定要做,否則PHP會找不到我們自己建立的class,詳細請參考Laravel的中大型專案架構
在PhpStorm設定VideoRental
namespace。20 20這一步一定要做,如此PhpStorm才會知道新的VideoRental
namespace,將來建立新class時,才可以選的到此namespace。
PhpStorm -> Preferences… -> Project:xxx -> Directories
選擇app/VideoRental
目錄,按下Sources
,右側會出現藍色Sources Folders : app/VideoRental
。
按下P
,設定namespace名稱。
將app/VideoRental
目錄的prefix設定為VideoRental
。21 21GitHub Commit : 新增domain目錄
第一個測試
接下來會介紹3種測試方式。
第一種測試方式 : 使用Gulp TDD
在命令列使用php artisan make:test
建立測試class,預設會繼承tests
目錄下的TestCase
。
在命令列執行gulp tdd
,讓Laravel Elixir在背後執行PHPUnit,將來只要我們一存檔就會自動執行測試。
建立PHPUnit Test Method。22 22在寫測試的class內,按熱鍵 : ⌃ + N,會出現Generate
選單,選擇PHPUnit Test Method
,可幫我們自動建立test method。
PhpStorm自動幫我們建立以test
為開頭的test method。23 23PHPUnit預設會將2種method視為test method,一種是以test
為開頭的method,一種是在PHPDoc註解加上@test
。
更改test method名稱,以最能描述測試案例的口語命名
,不用遵循PSR-2。24 24詳細請參考PSR-2 PHP Coding Style
在test method內加上arrange,act, assert,以3A原則寫測試。25 25因為每個test method都需要3A原則當架構,建議可以自行加入PhpStorm的Live Template
3A原則
Arrange
- 建立物件 (待測物件,相依物件,Mock物件)。
- 建立假資料。
- 設定期望值。
Act
- 實際執行待測物件的method,獲得實際值。
Assert
- 使用PHPUnit提供的assertion,測試期望值與實際值是否相等。
依3A原則為骨架,依次將測試補上。26 26實務上第一個會將act
先補上,也就是先決定要測試哪一個method。
先寫測試讓我們會以測試好寫為前提設計,會幫助我們以使用者需求的抽象化角度去思考架構。
Arrange
因為我們的需求是:計算一位顧客所有訂單的金額
,且金額會隨著電影種類而不同,因此最基本,我們會有Movie
、Order
與Customer
三個class,且一位顧客會有多筆訂單,因此會有addOrder()
提供新增訂單。27 27此時Movie
、Order
、Customer
、addOrder
與calculateTotalPrice()
都還沒建立,因此在PhpStorm會反白,這不用擔心,因為我們現在是先寫測試,以Top Down的方式去思考,不用擔心這些class與method還沒建立,只要先思考這樣子我們測試最好寫
就好了,這是TDD很重要的心法。
將測試案例的期望值寫入$expected
。
Act
實際測試Customer
的calculateTotalPrice()
,獲得實際值$actual
。
Assert
使用PHPUnit的assertEquals()
驗證期望值與實際值是否相同。
這裡當然可以自己用PHP寫 if ($expected == $actual)
判斷,不過因為牽涉到人為的邏輯判斷,當測試錯誤時,很難確定到底是測試有問題,還是我們自己寫的PHP邏輯有問題,所以在測試中不應該寫邏輯,而應該使用PHPUnit的assertion
28 28PHPUnit提供很多assertion method,詳細請參考PHPUnit Assertions,因為PHPUnit已經被測試過
了,當測試結果有錯時,不用再懷疑是不是測試寫錯,一定是我們的程式寫錯了。
存檔後,會出現第一個紅燈,錯誤訊息為Class Movie not found
。29 29GitHub Commit : 第一個測試 : 第一個紅燈
You are not allowed to write any production code unless it is to make a failing unit test pass.
白話就是:你必須先寫測試亮紅燈之後,才可以寫程式。30 30The Three Rules of TDD
目的:
- 先亮紅燈,表示你已經先寫了測試,只是因為沒寫程式所以紅燈。
- 先亮紅燈,表示你之前寫的程式沒有over design。
測試錯誤訊息告訴我們 : Class Movie not found
。因為我們還沒建立Movie
。
直接在PhpStorm內建立Movie
。31 31將滑鼠游標放在Movie
之後,按熱鍵⌥ + ↩,會出現Create class
,按下可自動建立Movie
。
出現Create New PHP Class對話框,選擇目錄在app/VideoRental
下,並選擇namespace : VideoRental
。
PhpStorm會幫我們建立Movie
。
存檔後出現第二個紅燈,錯誤訊息為Class Order not found
。32 32GitHub Commit : 第一個測試 : 第二個紅燈
You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
白話就是:測試出現紅燈之後,你就必須先改程式將紅燈變成綠燈,而不是寫其他的測試製造更多的紅燈。33 33The Three Rules of TDD
目的 :
- 將程式聚焦在目前的需求,方便程式解決目前的紅燈。
- 不會一開始就將架構想的太複雜,造成over design,而枉費我們分拆測試範例。34 34詳細請參考91哥的The Three Laws of TDD - 從紅燈變綠燈的過程
測試錯誤訊息告訴我們 : Order not found
。因為我們還沒建立Order
。
直接在PhpStorm內建立Order
。35 35將滑鼠游標放在Order
之後,按熱鍵⌥ + ↩,會出現Create class
,按下可自動建立Order
。
出現Create New PHP Class對話框,選擇目錄在app/VideoRental
下,並選擇namespace : VideoRental
。
PhpStorm會幫我們建立Order
。
存檔後出現第三個紅燈,錯誤訊息為Class Customer not found
。36 36GitHub Commit : 第一個測試 : 第三個紅燈
事實上TDD的開發流程本來就是先有紅燈才去寫程式,這也是TDD能解決over design的關鍵,因為測試案例的紅燈來自於需求,由紅燈變成綠燈就是解決需求,若沒有紅燈而直接綠燈,就表示程式有over design。
測試錯誤訊息告訴我們 : Class Customer not found
。因為我們還沒建立Customer
。
直接在PhpStorm內建立Customer
。37 37將滑鼠游標放在Customer
之後,按熱鍵⌥ + ↩,會出現Create class
,按下可自動建立Customer
。
出現Create New PHP Class對話框,選擇目錄在app/VideoRental
下,並選擇namespace : VideoRental
。
PhpStorm會幫我們建立Customer
。
存檔後出現第四個紅燈,錯誤訊息為Call to undefined method VideoRental\Customer::addOrder()
。38 38GitHub Commit : 第一個測試 : 第四個紅燈
測試錯誤訊息告訴我們 : Call to undefined method VideoRental\Customer::addOrder()
。因為我們還沒建立addOrder()
。
直接在PhpStorm內建立addOrder()
。39 39將滑鼠游標放在addOrder()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立addOrder()
。
PhpStorm會幫我們建立addOrder()
。
存檔後出現第五個紅燈,錯誤訊息為Call to undefined method VideoRental\Customer::calculateTotalPrice()
。40 40GitHub Commit : 第一個測試 : 第五個紅燈
測試錯誤訊息告訴我們 : Call to undefined method VideoRental\Customer::calculateTotalPrice()
。因為我們還沒建立calculateTotalPrice()
。
直接在PhpStorm內建立calculateTotalPrice()
。41 41將滑鼠游標放在calculateTotalPrice()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立calculateTotalPrice()
。
PhpStorm會幫我們建立calculateTotalPrice()
。
存檔後出現第六個紅燈,錯誤訊息為Failed asserting that null matches expected 130
。42 42GitHub Commit : 第一個測試 : 第六個紅燈
既然測試要求130
,我們就直接很無恥的回傳130
。
這樣我們就獲得了第一個測試的第一個綠燈。43 43GitHub Commit : 第一個測試 : 第一個綠燈
第一個測試為了剛好符合第一個測試案例的需求,我們可以先無恥的使用return方式,反正接下來的測試案例我們自然會重構。
第二個測試
第二種測試方式 : 在命令列執行vendor/bin/phpunit
將第一個測試test_order_1_regular_movie_with_10_days()
複製貼上,改成第二個測試test_order_1_new_release_movie_with_5_days()
。44 44不用擔心在test code使用複製貼上,test code不用擔心duplicated code問題,只有production code才必須考慮。
先在命令列使用⌃ + C結束gulp tdd
,然後執行vendor/bin/phpunit
執行測試。
實務上可以在PHPDoc加上@group
標籤為test method分類,如誰寫的測試,哪一個class的測試,方便vendor/bin/phpunit
執行時只跑該group。
執行測試後,出現第二個測試案例的第一個紅燈,錯誤訊息為Failed asserting that 130 matches expected 210
。45 45GitHub Commit : 第二個測試 : 第一個紅燈
之前我們只新增了addOrder()
,並還沒有填入程式。
宣告一個$orders
陣列,並將$order
push進$orders
。46 46GitHub Commit : 第二個測試 : 補齊Customer->addOrder()
由於將來calculateTotalPrice()
也要使用$orders
陣列,因此我們想將$orders
從method內的變數變成class的field。47 47將滑鼠游標放在$orders
之後,按⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Field…。
出現兩種重構方式,選擇第一種 : $orders
。
出現Introduce field對話框,預設field已經幫我們填入orders
。
可以自行選擇Initialize in
與Visibility
的方式。
這裡我們選擇Field declaration
,也就是會直接在field宣告時初始化陣列。
如我們所願,宣告成protected $orders = []
。
並且push部分也自動改成field。48 48GitHub Commit : 第二個測試:Customer->addOrder()將$orders重構成field
由於需求是計算一位顧客所有訂單的金額
,所以勢必有$totalPrice
變數負責累加,然後需要一個foreach
將整個$orders
loop一次,計算每種影片種類的金額。
以Top Down的方式思考,由於我們現在是在Order
class,所以必須透過getMovie()
傳回Movie
物件,並透過其getType()
method傳回影片種類,然後將計費方式的演算法寫在裡面。49 49GitHub Commit : 第二個測試 : 補齊Customer->calculateTotalPrice()的Regular計算方式
執行測試後,出現第二個測試案例的第二個紅燈,錯誤訊息為Call to undefined method VideoRental\Order::getMovie()
。
錯誤訊息告訴我們 : Call to undefined method VideoRental\Order::getMovie()
,因為我們還沒建立getMovie()
。
直接在PhpStorm內建立getMovie()
。50 50將滑鼠游標放在getMovie()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立getMovie()
。
出現Can not find target class for modification
的錯誤訊息,因為PhpStorm無法得知$order
變數的型別,因此不知道要將getMovie()
建立在哪一個class。
加上註解描述$order
的型別為Order
。51 51這種寫法雖然可行,但不是最漂亮的寫法,最漂亮的寫法應該是直接在field註解型別,也就使在protected $order = []
之前直接@var Order[]
。
之後PhpStorm就會自動在Order
建立getMovie()
。
由於getMovie()
的$movie
來自於field,因此在constructor先將參數補全。
由於我們對constructor加了參數,因此出現了Argument PHPDoc missing
的警告。
使用PhpStorm幫我們補齊PHPDoc。52 52將滑鼠游標放在int $days
之後,按熱鍵⌥ + ↩,會出現Update PHPDoc Comment
,按下可自動將參數新增至PHPDoc。
PhpStorm幫我們將新的參數註解加入了PHPDoc,將原來的參數註解改成@internal
,這顯然是多餘的。
刪除多餘的@internal
註解。
使用PhpStorm幫我們建立field。53 53將滑鼠游標放在int $days
之後,按熱鍵⌥ + ↩,會出現Initialize fields
,按下可自動將constructor參數新增成field。
選擇所要建立field的參數。
PhpStorm自動幫我們宣告field,並在contructor自動加上初始化的程式。
回到getMovie()
,除了加上return $this->movie
之外,還補上了回傳型別Movie
。
使用PhpStorm自動幫我們加上getMovie()
註解。54 54將滑鼠游標放在Movie
之後,按熱鍵⌥ + ↩,會出現Generate PHPDoc for function
,按下可自動替getMovie()
產生註解。
在註解第一行加上人看得懂的註解。55 55GitHub Commit : 第二個測試 : 補齊Order->getMovie()
執行測試後,出現第二個測試案例的第三個紅燈,錯誤訊息為Call to undefined method ViderRental\Movie::getType()
。
錯誤訊息告訴我們 : Call to undefined method ViderRental\Movie::getType()
,因為我們還沒建立getType()
。
直接在PhpStorm內建立getType()
。56 56將滑鼠游標放在getType()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立getType()
。
PhpStorm會幫我們建立getType()
。
由於getType()
的$type
來自於field,因此在constructor先將參數補全。
由於我們對constructor加了參數,因此出現Argument PHPDoc missing
的警告。
使用PhpStorm幫我們補齊PHPDoc。57 57將滑鼠游標放在$type
之後,按熱鍵⌥ + ↩,會出現Update PHPDoc Comment
,按下可自動將參數新增至PHPDoc。
PhpStorm幫我們將新的參數註解加入了PHPDoc,將原來的參數改成@internal
,這顯然是多餘的。
刪除多餘的@internal
註解。
使用PhpStorm幫我們建立field。58 58將滑鼠游標放在string $type
之後,按熱鍵⌥ + ↩,會出現Initialize fields
,按下可自動將constructor參數新增成field。
選擇所要建立field的參數。
PhpStorm自動幫我們宣告field,並在contructor自動加上初始化的程式。
回到getType()
,補上了回傳型別string
。
使用PhpStorm自動幫我們加上getType()
註解。59 59將滑鼠游標放在string
之後,按熱鍵⌥ + ↩,會出現Generate PHPDoc for function
,按下可自動替getType()
產生註解。
加上return $this->type
。
在註解第一行加上人看得懂的註解。60 60GitHub Commit : 第二個測試:補齊Order->getType()
執行測試後,出現第二個測試案例的第四個紅燈,錯誤訊息為Call to undefined method VideoRental\Order::getDays()
。
錯誤訊息告訴我們 : Call to undefined method VideoRental\Order::getDays()
,因為我們還沒建立getDays()
。
直接在PhpStorm內建立getDays()
。61 61將滑鼠游標放在getDays()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立getDays()
。
PhpStorm會幫我們建立getDays()
。
之前已經建立好$days
field,所以可以直接return $this->days
,並加上註解。62 62GitHub Commit : 第二個測試:補齊Order->getDays()
執行測試後,出現第二個測試案例的第五個紅燈,錯誤訊息為Failed asserting that 0 matches expected 210
。
錯誤訊息告訴我們 : Failed asserting that 0 matches expected 210
,因為我們還沒寫NewRelease
的計費方式的演算法。
補上NewRelease
的計費方式演算法。
這樣我們就獲得了第二個測試的第一個綠燈。63 63GitHub Commit : 第二個測試 : 第一個綠燈
You are not allowed to write any more production code than is sufficient to pass the one failing unit test.
白話就是:若沒有測試案例,就不要自作聰明去寫程式。64 64The Three Rules of TDD
目的 :
- 避免over design,導致系統提早無謂的複雜。
- 將來若有新的測試案例,到時候再重構就好,不用現在去擔心。
第三個測試
第三種測試方式 : 直接在PhpStorm測試
將第一個測試test_order_1_regular_movie_with_10_days()
複製貼上,改成第三個測試test_order_1_children_movie_with_8_days()
。65 65不用擔心在test code使用複製貼上,test code不用擔心duplicated code問題,只有production code才必須考慮。
直接在PhpStorm執行測試。66 66在左側選擇CustomerTest.php
,按熱鍵⌃ + ⇧ + R。
我們發現前兩個測試案例都是綠燈,只有第三個測試案例是紅燈。
這是第三個測試案例的第一個紅燈,錯誤訊息為Failed asserting that 130 matches expected 210
。67 67GitHub Commit : 第三個測試 : 第一個紅燈
錯誤訊息告訴我們 : Failed asserting that 130 matches expected 210
,因為我們還沒寫Childer
的計費方式演算法。
補上Children
的計費方式演算法。
這樣我們就獲得了第三個測試案例的第一個綠燈。68 68GitHub Commit : 第三個測試 : 第一個綠燈
重構
3個測試案例都通過,代表程式基本上已經符合Spec要求,可以即時交付程式。
但是符合Spec的程式並不代表這是好的程式,一個好的程式至少要符合5個要求:
- 容易維護。
- 容易新增功能。
- 容易重複使用。
- 容易寫測試。
- 容易上Git,不易與其他人發生衝突。
若更簡單的說,就是要符合SOLID原則的程式,才算是好程式。
接下來我們將使用重構,將目前的程式調整成符合SOLID原則的好程式。
if else改成switch
在Customer
的calculateTotalPrice()
,有個if...elseif
,因為都是判斷$order->getMovie()->getType()
,將if...elseif
改成switch
,會讓程式碼比較容易閱讀。69 69這並不是重構這本書所談的重構手法,不過switch
的確比if...elseif
容易閱讀,所以實務上也常常會將if...elseif
重構成switch
。
重構成switch
之後,馬上跑測試,確認重構沒將程式改壞掉。70 70GitHub Commit : 重構 : if else改switch
Extract Method
改成switch
之後,雖然程式碼已經比較容易閱讀了,但是在calculateTotalPrice()
內還是顯得很臃腫,因此想使用重構的Extract Method將此switch
重構成一個method
。71 71使用滑鼠選擇想要重構的程式碼,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Method…。
出現Extract Method對話框,輸入重構後method名稱與選擇Visibility。
還可以選擇回傳值的處理方式,可以是return
,也可以是pass by reference
。
最後對於switch
的處理方式,可以選擇在case
內直接return
,還是最後一起return
,這裡選擇每個case
內直接return
。
重構之後,PhpStorm會自動幫你建立calculatePrice()
,且原來程式也幫你自動呼叫calculatePrice()
。
馬上跑測試,確認PhpStorm的Extract Method沒將程式改壞掉。72 72GitHub Commit : 重構 : Extract Method (switch -> Customer->calculatePrice())
Inline
經過Extract Method重構之後,我們發現$price
這個暫存變數沒有存在的價值了。可使用重構的Inlilne將此暫存變數移除。73 73將滑鼠游標放在$price
之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Inline…。
PhpStorm會詢問你是否將所有的$price
變數都inline。
Inline之後,程式就變成我們所預期的只有一行。
馬上跑測試,確認PhpStorm的Inline沒將程式改壞掉。74 74GitHub Commit : 重構 : Inline
Move Method
經過重構產生的calculatePrice()
,但程式內卻一直使用$order
物件的method,看起來calculatePrice()
不應該放在Customer
內,而應該放在Order
內。
使用重構的Move Method將calculatePrice()
從Customer
搬到Order
內。75 75將滑鼠游標放在calculatePrice
之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Move…。
出現Move non-static method is not supported
錯誤訊息,也就是PhpStorm目前只能支援將對static method
進行重構的Move Method,而一般method
不支援。
因為PhpStorm目前僅支援static method
的Move Method,因此先暫時將calculatePrice()
改成static method
。
重新對calculatePrice()
執行重構的Move Method。
出現Move Static Member對話框,因為我們要將calculatePrice()
搬到Order
,要連namespace一起輸入。
重構之後,因為calculatePrice()
已經變成static method
,原來的calculateTotalPrice()
內是使用$this->calculatePrice($order)
,已經被PhpStorm重構成Order::calculatePrice($order)
。
但這顯然不是我們要的。
將Order::
改成$order->
。76 76加上註解描述$order
的型別為Order
這種寫法雖然可行,但不是最漂亮的寫法,最漂亮的寫法應該是直接在field註解型別,也就使在protected $order = []
之前直接@var Order[]
,之後所有的foreach
內都不用再加上註釋。
PhpStorm雖然已經幫我們把calculatePrice()
搬到Order
,但顯然static
是多餘的,且因為已經在Order
,所以不需再傳入$order
了。
將static
刪除。
因為已經搬到Order
,所以如getMovie()
與getDays()
都不在需要$order
,而是需要改成$this
。
使用重構的Rename,將$order
改成$this
。77 77將滑鼠游標放在$order
之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Rename…。
將全部的$order
都改成$this
。
剛才我們只是借用重構的Rename將$order
改成$this
,事實上calculatePrice()
根本不需要任何參數,將Order $this
刪除。
馬上跑測試,確認PhpStorm的Rename沒將程式改壞掉。78 78GitHub Commit : 重構 : Move Method : Order->calculatePrice()
在剛剛重構產生的Order
的calculatePrice()
內,我們發現switch
竟然是去判斷Movie
的getType()
,這是不合理的,似乎暗示著應該將這個switch
判斷搬到Movie
內。79 79使用滑鼠選擇想要重構的程式碼,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Method…。
出現Extract Method對話框,原本我們是想將此switch
重構成Movie
的calculatePrice()
,但PhpStorm的Extract Method無法跨class,我們只好先Extract Method在Order
內,然後再使用Move Method搬到Movie
。
因為同一個class不能存在兩個calculatePrice()
,因此先取名為MovieCalculatePrice()
。
使用Move Method將MovieCalculatePrice()
搬到Movie
。
因為目前PhpStorm只支援static method
的Movie Method,因此先改成static
。80 80將滑鼠游標放在MovieCalculatePrice
之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Move…。
出現Move Static Member對話框,因為我們要將MovieCalculatePrice()
搬到Movie
,要連namespace一起輸入。
重構之後,因為MovieCalculatePrice()
已經變成static method
,原來的calculatePrice()
內是使用$this->MovieCalculatePrice()
,已經被PhpStorm重構成Movie::MovieCalculatePrice()
。
但這顯然不是我們要的。
將Movie::MovieCalculatePrice()
改成$this->getMovie()->calculatePrice()
。
PhpStorm雖然已經幫我們把MovieCalculatePrice()
搬到Movie
,但顯然static
是多餘的。
且$this->getMovie()
也不需要了,因為已經在Movie
內,改用$this
就好。
$this->getDays()
則比較麻煩,因為這原本是Order->getDays()
,現在搬到Movie之後,勢必要靠參數從Order
傳進來。
將static
刪除。
將$this->getMovie()
改成$this
。
我們是希望將$this->getDays()
能Extract Parameter,不過目前在PhpStorm無法將一個method直接Extract Parameter,需要透過一些技巧。
先使用PhpStorm將$this->getDays()
透過Extract Variable成變數。81 81選擇$this->getDays()
,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Variable…。
出現Introduce variable對話框,我們希望重構成$days
變數。
將Replace all occurrences
打勾,我們打算將全部的$this->getDays()
都取代。
我們看到$this->getDays()
已經全部被$days
所取代。
但$days = $this->getDays()
顯然是多餘的。
將$days = $this->getDays()
刪除。
加上int $days
參數,並加上註解。
由於多了int $days
參數,因此在Order->calculatePrice()
要多傳$this->getDays()
進來。
馬上跑測試,確認PhpStorm沒將程式改壞掉。82 82GitHub Commit : 重構 : Move Method : Movie->calculatePrice()
Replace Type Code with State/Stratgey
在重構的技巧中,有一招叫做Replace Type Code with State/Strategy,簡單的說,當你的程式會使用switch
對同一個變數去做判斷時,可以改用物件導向的多型來處理,或者更白話的說,改用設計模式的State模式或Strategy模式去處理。
這樣的好處是會使你的程式符合SOLID的開放封閉原則,將來若新的需求要新增,將不用去改原來程式的switch
,只要去新增class即可。83 83開放封閉原則 : 軟體中的類別、函式對於擴展是開放的,對於修改是封閉的。
Self Encapsulate Field
我們即將重構成State模式,因為State模式會將變化抽象化成一個物件,也就是需要將原本的字串,如Regular
、NewRelease
、Children
最後抽象化成物件,因此重構教我們要使用Replace Type Code with State/Strategy前,先執行另一招重構 : Self Encapsulate Field。
Self Encapsulate Field簡單的說,就是將field全部改用setter
的方式寫入,這樣我們就可以在setter
內將字串抽象化成物件。84 84將滑鼠游標放在$type
之後,按熱鍵⌘ + N,會出現Generate
選單,選擇Setters
,可幫我們自動建立$type
的setter。
選擇所要建立setter的field。
PhpStorm自動幫我們加上$type
的setter : setType()
。
將setType()的參數加上type hint。
建立setter只是Self Encapsulate Field的第一步,接下來就將程式所有地方改用setter去寫入$type
field。85 85GitHub Commit : 重構 : Self Encapsulate Field
將變化封裝在class
State模式會將變化封裝在class內,也就是以物件導向的多型取代switch
,無論將來怎麼變化,對使用者看起來都是相同的abstract class
。
建立Abstract Class
在VideoRental
目錄下建立AbstractMovieType
。86 86在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入AbstractMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立AbstractMovieType
。
因為我們要建立的是abstract class,所以在class前面加上abstract
。
另外定義一個abstract method : calculatePrice()
。87 87GitHub Commit : 重構 : 建立AbstractMovieType
建立RegularMovieType Class
接著我們要將各種影片類型的計費方式,封裝在class內。
新增RegularMovieType
,負責普通片的計費方式。88 88在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入RegularMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立RegularMovieType
。
繼承AbstractMovieType
,使用PhpStorm幫我們建立abstract class所定義的method。89 89將滑鼠游標放在AbstractMovieType
之後,按熱鍵⌥ + ↩,會出現Add method stubs
,按下可自動根據所繼承的abstract class
建立method。
PhpStorm會幫我們建立calculatePrice()
,連註解也會一併建立。
將原本在Movie->calculatePrice()
內的普通片計費方式剪下。
貼到RegularMovieType
的calculatePrice()
內。
直接將$price
的初始值指定為100
即可。90 90GitHub Commit : 重構 : 建立RegularMovieType
建立NewReleaseMovieType Class
新增NewReleaseMovieType
,負責新片的計費方式。91 91在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入NewReleaseMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立NewReleaseMovieType
。
繼承AbstractMovieType
,使用PhpStorm幫我們建立abstract class所定義的method。92 92將滑鼠游標放在AbstractMovieType
之後,按熱鍵⌥ + ↩,會出現Add method stubs
,按下可自動根據所繼承的abstract class
建立method。
PhpStorm會幫我們建立calculatePrice()
,連註解也會一併建立。
將原本在Movie->calculatePrice()
內的新片計費方式剪下。
貼到NewReleaseMovieType
的calculatePrice()
內。
直接將$price
的初始值指定為150
即可。93 93GitHub Commit : 重構 : 建立NewReleaseMovieType
建立ChildrenMovieType Class
新增ChildrenMovieType
,負責兒童片的計費方式。94 94在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入ChildrenMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立ChildrenMovieType
。
繼承AbstractMovieType
,使用PhpStorm幫我們建立abstract class所定義的method。95 95將滑鼠游標放在AbstractMovieType
之後,按熱鍵⌥ + ↩,會出現Add method stubs
,按下可自動根據所繼承的abstract class
建立method。
PhpStorm會幫我們建立calculatePrice()
,連註解也會一併建立。
將原本在Movie->calculatePrice()
內的兒童片計費方式剪下。
貼到ChildrenMovieType
的calculatePrice()
內。
直接將$price
的初始值指定為40
即可。96 96GitHub Commit : 重構 : 建立ChildrenMovieType
重構setType()
之前的setType()
只是單純的$type
field的setter,不過使用State模式之後,setType()
的角色就有了改變,不再只是單存的setter,而是要建立適當的AbstractMovieType
物件。
將calculatePrice()
剩下的switch
剪下。
貼到setType()
內。
將$this->getType()
改成$type
。
將$this->type
改new
我們剛剛建立,用來封裝計費方式的物件。
private $type
的PHPDoc註解型別,也從原來的string
改成AbstractMovieType
。
重構getType()
因為$type
field的型別已經從原本的string
改成AbstractMovieType
,因此getType()
的回傳型別與PHPDoc註解也要更新。
重構calculatePrice()
由於setType()
已經幫我們切換計費方式物件,據SOLID的里氏替換原則,且這些物件都是繼承於AbstractMovieType
,根我們可以直接呼叫子類別的calculatePrice()
。
馬上跑測試,確認重構沒將程式改壞掉。97 97GitHub Commit : 重構 : Movie->setType(), getType()與calculatePrice()
Replace Constructor with Factory Method
在重構的技巧中,有一招叫做Replace Constructor with Factory Method,簡單的說,當你使用new
去建立物件時,就直接相依了該物件,我們可將new
物件的邏輯封裝在Simple Factory模式內,如此我們就只相依工廠物件,而不會直將相依於計費方式物件。
新增MovieTypeFactory
,負責建立計費方式物件。98 98在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入MovieTypeFactory
,namespace選擇VideoRental
。
PhpStorm會幫我們建立MovieTypeFactory
。
建立create()
,並宣告成static。
將原本在Movie->setType()
的程式全部貼到create()
內。
create()
加上string $type
參數,並加上回傳型別AbstractMovieType
。
因為create()
功能就是在建立物件,所以全部改成return
。
原本Movie->setType()
,改由MovieFactory::create()
來建立計費方式物件。
如此Movie
將不再直接相依於每個計費物件,只相依於MovieTypeFactory
。
馬上跑測試,確認重構沒將程式改壞掉。99 99GitHub Commit : 重構 : Simple Factory
Replace Conditional with Polymorphism
在重構技巧中,有一招叫做Replace Conditional with Polymorphism,簡單的說,就是要使用物件導向的多型來取代switch
,達到SOLID的開放封閉原則。
使用Laravel的service container,利用App::bind()
將AbstractMovieType
與實際的計費方式連結。
使用App::make()
建立AbstractMovieType
型別的物件。
也就是說,只要計費方式物件改變,App::bind()
會重新與AbstractMovieType
連結,但對於App::make()
來說,都是建立AbstractMovieType
型別的物件,這就是物件導向的多型。100 100詳細請參考深入探討Service Provider
馬上跑測試,確認重構沒將程式改壞掉。101 101GitHub Commit : 重構 : 多型App::bind()
開放封閉原則
影片種類 | 租期 | 租金 | 逾期費 |
---|---|---|---|
普通片 | 7天 | 100 | 10 |
新片 | 3天 | 150 | 30 |
兒童片 | 7天 | 40 | 10 |
國片 | 10天 | 80 | 10 |
測試案例
普通片1支,10天
100 + (10-7) * 10 = 130新片1支,5天
150 + (5-3) * 30 = 210兒童片1支,8天
40 + (8-7) * 10 = 50國片1支,12天
80 + (12-10) * 10 = 100
新增國片測試案例,得到第一個紅燈。
新增TaiwanMovieType
,負責國片的計費方式。102 102在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入TaiwanMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立TaiwanMovieType
。
繼承AbstractMovieType
,使用PhpStorm幫我們建立abstract class所定義的method。103 103將滑鼠游標放在AbstractMovieType
之後,按熱鍵⌥ + ↩,會出現Add method stubs
,按下可自動根據所繼承的abstract class
建立method。
PhpStorm會幫我們建立calculatePrice()
,連註解也會一併建立。
補上國片的計費方式。
馬上跑測試,確認新增的國片測試案例是否正常。104 104GitHub Commit : 重構 : 開放封閉原則
- Extract Method : 將原本很長的函式利用Extract Method拆解成數個小小的method。
- Move Method : 利用Move Method將method搬到它適合的class內。
- 使用多型取代switch : 若程式內有
switch
,考慮使用物件導向多型的State模式或Strategy模式取代,達成SOLID的開放封閉原則。
偵錯
假設在國片的計費方式,故意將逾期費用10
元改成5
元,我們該如何找到這個bug呢?
使用測試案例偵錯
執行測試,發現錯在test_order_1_taiwan_movie_with_12_days()
這個測試案例,且期望值是100,而實際值是90。
且由於每個測試案例是針對單一class的method,我們可以很快的鎖定問題是出在Customer->calculatePrice()
的錯誤。
使用PhpStorm偵錯
PhpStorm允許我們直接在PHP內下中斷點,假如你知道問題在哪裡,可以直接在該class的method內下中斷點,若完全沒有頭緒,可以在act
之處下中斷點,最少在執行target的method前會停下來。105 105在欲中斷的程式之處按熱鍵 : ⌘ + F8,可設定或取消中斷點。
啟動偵錯模式。106 106在欲啟動偵錯的測試案例內,按熱鍵 : ⌃ + ⇧ + D啟動偵錯模式,程式會停在剛剛建立的中斷點。
Step Into進Customer->calculateTotalPrice()
。107 107Step Into : 熱鍵 : F7
Step Over : 熱鍵 : F8
Step Out : 熱鍵 : ⇧ + F8
找到root cause在TaiwanMovieType
的calculatePrice()
的18行有問題。
學習資源
- 重構 : 重構 : 改善既有程式的設計 (二版)
- 設計模式 : 大話設計模式
學習方式
- 直接由設計模式學習物件導向的學習曲線較為陡峭,很吃天份,失敗機率較高。
- 學習測試與重構達成設計模式的學習曲線較為平緩,適合常人,成功機率較高。
Conclusion
- 好程式不是設計出來的,而是重構出來的。
- 重構一定要搭配測試。TDD讓我們以Top Down方式,以需求出發,幫助我們抽象化思考,不用太早就去思考細節,可以更容易設計出符合SOLID的物件導向程式。
- 測試重構會多花一點時間,因此我們要選擇更強悍的工具將時間省回來。
Sample Code
完整的範例可以在我的GitHub上找到。