2016年9月2日 星期五

如何使用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,樂高積木的規格就是interfaceabstract class,只要符合規格的積木,我們都可以換掉加上去

SOLID

程式語言提供了各種物件導向程式的語法,倒底要怎樣寫才是符合物件導向精神的程式呢?
SOLID原則11詳細請參考大澤木小鐵的從實例學習設計模式 威力加強版 使用PHP
  1. S : Single Responsibility Principle (單一職責原則)
  2. O : Open Closed Principle (開放封閉原則)
  3. L : Liskov Substitution Principle (里氏替換原則),Least Knowledge Principle (最小知識原則)
  4. I : Interface Segregation Principle (介面隔離原則)
  5. D : Dependency Inversion Principle (依賴反轉原則)
Laravel的作者Taylor Otwell曾有一段話 : 22詳細請參考Laravel之父 : 學習出色的Design Pattern
如果有人想成為更棒的PHP工程師,你會怎麼建議?
學習出色的Design Pattern。這不只適用在PHP。你可以在任何程式語言使用這些pattern。尤其是SOLID。把這五個徹底學好,它會把你帶到新的境界,我每次寫code幾乎都在想這五個。
Taylor Otwell
除了Design Pattern,重點在於更根本的SOLID,這5點才是物件導向的心法。

設計模式

設計模式其實就是大神們留下來好的物件導向設計範本33物件導向設計模式-可再利用物件導向軟體之要素
優點 : 
  1. 具體方案 : 至少是個具體的物件導向設計方式,不再流於抽象概念。
  2. 用得巧就很棒 : 只要在適當的場合,使用適當的模式,就會非常漂亮。
缺點 :
  1. 學習門檻高 : 理解設計模式已經不容易,要套用在實務上更難,很依賴天份
  2. 容易over design : 初學者容易一開始就套大量設計模式,導致系統提前過於複雜

重構

將既有的程式整形成符合物件導向精神的程式。44重構 : 改善既有程式的設計 (二版)
優點 :
  1. 學習門檻較低 : 重構招式較平易近人,容易學習。
  2. 可套用在legacy code : 不再只有新的專案才能物件導向。
缺點 :
  1. 需要依賴測試做保障 : 重構需要頻繁的測試,也需要測試保證重構沒有出錯。

TDD

既然重構需要測試,到底要寫測試,還是寫測試呢?
TDD的全名為Test Driven Development (測試驅動開發) 顛覆大家以往的習慣,強調先寫測試,再寫程式,整個流程是 :

優點 :
  1. 提供重構堅固的屏障 : 有寫測試,我們才敢放膽重構。
  2. 避免over design : 只為紅燈變成綠燈寫程式,不會寫出額外的程式。
  3. Top Down思維 : 因為測試先寫,會以測試好寫的角度去寫程式, 會比較接近使用者,也符合物件導向的精神。55詳細請參考使用TDD實踐SOLID
  4. 偵錯快速 : 將來要debug時,只要一跑測試,就可以快速找到錯誤所在。
缺點 :
  1. 先寫測試,一開始會多花一點時間 : 所以我們要找更強的工具幫我們將時間省回來。
  2. 需要學習如何寫測試 : 寫測試有不少技巧,如3A原則Mock物件依賴注入Assertion…。

設定環境


建立Laravel專案

1
oomusou@mac:~$ composer create-project laravel/laravel Laravel51Refactor_demo 5.1 --prefer-dist
在命令列下composer create-project指令建立Laravel專案。66GitHub Commit : composer create-project

安裝Laravel Elixir

1
oomusou@mac:~/MyProject$ npm install
由於我們會使用Laravel Elixir在背後自動執行測試,因此要使用npm install77GitHub 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雖稍微久一點,但只要做一次即可。99PhpStorm會對整個專案的檔案做index,以加速將來檔案的搜尋。

設定Namespace Roots

第一次開啟專案,PhpStorm會跳出Detect PSR-0 namespaces roots要求你設定。選擇Settings | Directories1010理論上選擇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設定為App1111這個步驟非常重要,設定好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位置。1212詳細請參考如何使用PhpStorm測試與除錯?

設定PHPUnit

PhpStorm允許我們直接在IDE內跑單元測試,因此我們必須告訴PhpStorm,PHPUnit的autoloader與phpunit.xml設定檔位置。1313詳細請參考如何使用PhpStorm測試與除錯?

測試Gulp TDD

一開始已經使用npm install安裝了Laravel Elixir,為了要使Elixir能自動在背景執行PHPUnit,只要我們一存檔就執行測試,需修改gulpfile.js,加上.phpUnit();
在命令列執行gulp tdd,啟動Laravel Elixir在背景執行PHPUnit。1414在PhpStorm可按熱鍵:⌥ + F12,可在下方顯示terminal直接輸入指令。
開啟tests/ExampleTest.php,這是Laravel所提供預設的測試,用來測試Laravel預設的首頁是否有Laravel 5字串。
5改成4,存檔後就會在右上角顯示紅燈,顯示測試錯誤。
若從4改成5,存檔後就會在右上角顯示綠燈,顯示測試成功。
紅燈綠燈都能出現,表示Gulp TDD正常。

TDD


Spec

計算一位顧客所有訂單的金額。1515本範例改編自重構:改善既有程式的設計的第一章範例,原書使用Java,經簡化後改成PHP版本。
影片種類租期租金逾期費
普通片7天10010
新片3天15030
兒童片7天4010

測試案例

  1. 普通片1支,10天1616寫測試的第一步,就是要將spec寫成測試案例,也就是實際的input與output結果,如此才能根據input與output判斷測試結果是否正確。
     100 + (10-7) * 10 = 130
  2. 新片1支,5天
     150 + (5-3) * 30 = 210
  3. 兒童片1支,8天
     40 + (8-7) * 10 = 50

設定Domain目錄

我們會將所有的class放在自己的Domain目錄下,或稱為Business Layer1717詳細請參考Laravel的中大型專案架構
首先,在app目錄下建立VideoRental子目錄。
輸入VideoRental1818在左側選擇app目錄,按下⌃ + N,出現下拉選單,選擇Directory建立新目錄。
由於新目錄會有自己的namespace名稱,因此要修改composer.jsonpsr-4設定,加上VideoRental與其目錄。
執行composer dumpautoload建立新的autoload檔案。1919這一步一定要做,否則PHP會找不到我們自己建立的class,詳細請參考Laravel的中大型專案架構
在PhpStorm設定VideoRental namespace。2020這一步一定要做,如此PhpStorm才會知道新的VideoRentalnamespace,將來建立新class時,才可以選的到此namespace。
PhpStorm -> Preferences… -> Project:xxx -> Directories
選擇app/VideoRental目錄,按下Sources,右側會出現藍色Sources Folders : app/VideoRental
按下P,設定namespace名稱。
app/VideoRental目錄的prefix設定為VideoRental2121GitHub Commit : 新增domain目錄

第一個測試

接下來會介紹3種測試方式。
第一種測試方式 : 使用Gulp TDD
在命令列使用php artisan make:test建立測試class,預設會繼承tests目錄下的TestCase
在命令列執行gulp tdd,讓Laravel Elixir在背後執行PHPUnit,將來只要我們一存檔就會自動執行測試。
建立PHPUnit Test Method2222在寫測試的class內,按熱鍵 : ⌃ + N,會出現Generate選單,選擇PHPUnit Test Method,可幫我們自動建立test method。
PhpStorm自動幫我們建立以test為開頭的test method。2323PHPUnit預設會將2種method視為test method,一種是以test為開頭的method,一種是在PHPDoc註解加上@test
更改test method名稱,以最能描述測試案例口語命名,不用遵循PSR-2。2424詳細請參考PSR-2 PHP Coding Style
在test method內加上arrangeactassert,以3A原則寫測試。2525因為每個test method都需要3A原則當架構,建議可以自行加入PhpStorm的Live Template
3A原則
Arrange
  • 建立物件 (待測物件,相依物件,Mock物件)。
  • 建立假資料。
  • 設定期望值
Act
  • 實際執行待測物件的method,獲得實際值
Assert
  • 使用PHPUnit提供的assertion,測試期望值實際值是否相等。
3A原則為骨架,依次將測試補上。2626實務上第一個會將act先補上,也就是先決定要測試哪一個method。
先寫測試讓我們會以測試好寫為前提設計,會幫助我們以使用者需求抽象化角度去思考架構。
Arrange
因為我們的需求是:計算一位顧客所有訂單的金額,且金額會隨著電影種類而不同,因此最基本,我們會有MovieOrderCustomer三個class,且一位顧客會有多筆訂單,因此會有addOrder()提供新增訂單。2727此時MovieOrderCustomeraddOrdercalculateTotalPrice()都還沒建立,因此在PhpStorm會反白,這不用擔心,因為我們現在是先寫測試,以Top Down的方式去思考,不用擔心這些class與method還沒建立,只要先思考這樣子我們測試最好寫就好了,這是TDD很重要的心法。
測試案例期望值寫入$expected
Act
實際測試CustomercalculateTotalPrice(),獲得實際值$actual
Assert
使用PHPUnit的assertEquals()驗證期望值實際值是否相同。
 該自己用if else寫測試嗎?
這裡當然可以自己用PHP寫 if ($expected == $actual)判斷,不過因為牽涉到人為的邏輯判斷,當測試錯誤時,很難確定到底是測試有問題,還是我們自己寫的PHP邏輯有問題,所以在測試中不應該寫邏輯,而應該使用PHPUnit的assertion28 28PHPUnit提供很多assertion method,詳細請參考PHPUnit Assertions,因為PHPUnit已經被測試過了,當測試結果有錯時,不用再懷疑是不是測試寫錯,一定是我們的程式寫錯了。
存檔後,會出現第一個紅燈,錯誤訊息為Class Movie not found2929GitHub Commit : 第一個測試 : 第一個紅燈
 The Three Rules of TDD No.1
You are not allowed to write any production code unless it is to make a failing unit test pass.
Uncle Bob -The Three Rules of TDD
白話就是:你必須先寫測試亮紅燈之後,才可以寫程式。3030The Three Rules of TDD
目的:
  1. 先亮紅燈,表示你已經先寫了測試,只是因為沒寫程式所以紅燈。
  2. 先亮紅燈,表示你之前寫的程式沒有over design
測試錯誤訊息告訴我們 : Class Movie not found。因為我們還沒建立Movie
直接在PhpStorm內建立Movie3131將滑鼠游標放在Movie之後,按熱鍵⌥ + ↩,會出現Create class,按下可自動建立Movie
出現Create New PHP Class對話框,選擇目錄在app/VideoRental下,並選擇namespace : VideoRental
PhpStorm會幫我們建立Movie
存檔後出現第二個紅燈,錯誤訊息為Class Order not found3232GitHub Commit : 第一個測試 : 第二個紅燈
 The Three Rules of TDD No.2
You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
Uncle Bob -The Three Rules of TDD
白話就是:測試出現紅燈之後,你就必須先改程式將紅燈變成綠燈,而不是寫其他的測試製造更多的紅燈3333The Three Rules of TDD
目的 : 
  1. 將程式聚焦在目前的需求,方便程式解決目前的紅燈
  2. 不會一開始就將架構想的太複雜,造成over design,而枉費我們分拆測試範例。3434詳細請參考91哥The Three Laws of TDD - 從紅燈變綠燈的過程
測試錯誤訊息告訴我們 : Order not found。因為我們還沒建立Order
直接在PhpStorm內建立Order3535將滑鼠游標放在Order之後,按熱鍵⌥ + ↩,會出現Create class,按下可自動建立Order
出現Create New PHP Class對話框,選擇目錄在app/VideoRental下,並選擇namespace : VideoRental
PhpStorm會幫我們建立Order
存檔後出現第三個紅燈,錯誤訊息為Class Customer not found3636GitHub Commit : 第一個測試 : 第三個紅燈
 不要因為在測試時看到紅燈而沮喪
事實上TDD的開發流程本來就是先有紅燈才去寫程式,這也是TDD能解決over design的關鍵,因為測試案例的紅燈來自於需求,由紅燈變成綠燈就是解決需求,若沒有紅燈而直接綠燈,就表示程式有over design
測試錯誤訊息告訴我們 : Class Customer not found。因為我們還沒建立Customer
直接在PhpStorm內建立Customer3737將滑鼠游標放在Customer之後,按熱鍵⌥ + ↩,會出現Create class,按下可自動建立Customer
出現Create New PHP Class對話框,選擇目錄在app/VideoRental下,並選擇namespace : VideoRental
PhpStorm會幫我們建立Customer
存檔後出現第四個紅燈,錯誤訊息為Call to undefined method VideoRental\Customer::addOrder()3838GitHub Commit : 第一個測試 : 第四個紅燈
測試錯誤訊息告訴我們 : Call to undefined method VideoRental\Customer::addOrder()。因為我們還沒建立addOrder()
直接在PhpStorm內建立addOrder()3939將滑鼠游標放在addOrder()之後,按熱鍵⌥ + ↩,會出現Add method,按下可自動建立addOrder()
PhpStorm會幫我們建立addOrder()
存檔後出現第五個紅燈,錯誤訊息為Call to undefined method VideoRental\Customer::calculateTotalPrice()4040GitHub Commit : 第一個測試 : 第五個紅燈
測試錯誤訊息告訴我們 : Call to undefined method VideoRental\Customer::calculateTotalPrice()。因為我們還沒建立calculateTotalPrice()
直接在PhpStorm內建立calculateTotalPrice()4141將滑鼠游標放在calculateTotalPrice()之後,按熱鍵⌥ + ↩,會出現Add method,按下可自動建立calculateTotalPrice()
PhpStorm會幫我們建立calculateTotalPrice()
存檔後出現第六個紅燈,錯誤訊息為Failed asserting that null matches expected 1304242GitHub Commit : 第一個測試 : 第六個紅燈
既然測試要求130,我們就直接很無恥的回傳130
這樣我們就獲得了第一個測試的第一個綠燈。4343GitHub Commit : 第一個測試 : 第一個綠燈
 直接使用return也太無恥了吧!!
第一個測試為了剛好符合第一個測試案例的需求,我們可以先無恥的使用return方式,反正接下來的測試案例我們自然會重構。

第二個測試

第二種測試方式 : 在命令列執行vendor/bin/phpunit
將第一個測試test_order_1_regular_movie_with_10_days()複製貼上,改成第二個測試test_order_1_new_release_movie_with_5_days()4444不用擔心在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 2104545GitHub Commit : 第二個測試 : 第一個紅燈
 別忘了Uncle Bob的叮嚀,每個測試案例都要先出現第一個紅燈,若一開始就出現綠燈,表示你之前程式有over design了。
之前我們只新增了addOrder(),並還沒有填入程式。
宣告一個$orders陣列,並將$orderpush進$orders4646GitHub Commit : 第二個測試 : 補齊Customer->addOrder()
由於將來calculateTotalPrice()也要使用$orders陣列,因此我們想將$orders從method內的變數變成class的field。4747將滑鼠游標放在$orders之後,按⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Field…
出現兩種重構方式,選擇第一種 : $orders
出現Introduce field對話框,預設field已經幫我們填入orders
可以自行選擇Initialize inVisibility的方式。
這裡我們選擇Field declaration,也就是會直接在field宣告時初始化陣列。
如我們所願,宣告成protected $orders = []
並且push部分也自動改成field。4848GitHub Commit : 第二個測試:Customer->addOrder()將$orders重構成field
由於需求是計算一位顧客所有訂單的金額,所以勢必有$totalPrice變數負責累加,然後需要一個foreach將整個$ordersloop一次,計算每種影片種類的金額。
以Top Down的方式思考,由於我們現在是在Order class,所以必須透過getMovie()傳回Movie物件,並透過其getType() method傳回影片種類,然後將計費方式的演算法寫在裡面。4949GitHub Commit : 第二個測試 : 補齊Customer->calculateTotalPrice()的Regular計算方式
執行測試後,出現第二個測試案例的第二個紅燈,錯誤訊息為Call to undefined method VideoRental\Order::getMovie()
錯誤訊息告訴我們 : Call to undefined method VideoRental\Order::getMovie(),因為我們還沒建立getMovie()
直接在PhpStorm內建立getMovie()5050將滑鼠游標放在getMovie()之後,按熱鍵⌥ + ↩,會出現Add method,按下可自動建立getMovie()
出現Can not find target class for modification的錯誤訊息,因為PhpStorm無法得知$order變數的型別,因此不知道要將getMovie()建立在哪一個class。
加上註解描述$order的型別為Order5151這種寫法雖然可行,但不是最漂亮的寫法,最漂亮的寫法應該是直接在field註解型別,也就使在protected $order = []之前直接@var Order[]
之後PhpStorm就會自動在Order建立getMovie()
由於getMovie()$movie來自於field,因此在constructor先將參數補全。
由於我們對constructor加了參數,因此出現了Argument PHPDoc missing的警告。
使用PhpStorm幫我們補齊PHPDoc。5252將滑鼠游標放在int $days之後,按熱鍵⌥ + ↩,會出現Update PHPDoc Comment,按下可自動將參數新增至PHPDoc。
PhpStorm幫我們將新的參數註解加入了PHPDoc,將原來的參數註解改成@internal,這顯然是多餘的。
刪除多餘的@internal註解。
使用PhpStorm幫我們建立field。5353將滑鼠游標放在int $days之後,按熱鍵⌥ + ↩,會出現Initialize fields,按下可自動將constructor參數新增成field。
選擇所要建立field的參數。
PhpStorm自動幫我們宣告field,並在contructor自動加上初始化的程式。
回到getMovie(),除了加上return $this->movie之外,還補上了回傳型別Movie
使用PhpStorm自動幫我們加上getMovie()註解。5454將滑鼠游標放在Movie之後,按熱鍵⌥ + ↩,會出現Generate PHPDoc for function,按下可自動替getMovie()產生註解。
在註解第一行加上人看得懂的註解。5555GitHub Commit : 第二個測試 : 補齊Order->getMovie()
執行測試後,出現第二個測試案例的第三個紅燈,錯誤訊息為Call to undefined method ViderRental\Movie::getType()
錯誤訊息告訴我們 : Call to undefined method ViderRental\Movie::getType(),因為我們還沒建立getType()
直接在PhpStorm內建立getType()5656將滑鼠游標放在getType()之後,按熱鍵⌥ + ↩,會出現Add method,按下可自動建立getType()
PhpStorm會幫我們建立getType()
由於getType()$type來自於field,因此在constructor先將參數補全。
由於我們對constructor加了參數,因此出現Argument PHPDoc missing的警告。
使用PhpStorm幫我們補齊PHPDoc。5757將滑鼠游標放在$type之後,按熱鍵⌥ + ↩,會出現Update PHPDoc Comment,按下可自動將參數新增至PHPDoc。
PhpStorm幫我們將新的參數註解加入了PHPDoc,將原來的參數改成@internal,這顯然是多餘的。
刪除多餘的@internal註解。
使用PhpStorm幫我們建立field。5858將滑鼠游標放在string $type之後,按熱鍵⌥ + ↩,會出現Initialize fields,按下可自動將constructor參數新增成field。
選擇所要建立field的參數。
PhpStorm自動幫我們宣告field,並在contructor自動加上初始化的程式。
回到getType(),補上了回傳型別string
使用PhpStorm自動幫我們加上getType()註解。5959將滑鼠游標放在string之後,按熱鍵⌥ + ↩,會出現Generate PHPDoc for function,按下可自動替getType()產生註解。
加上return $this->type
在註解第一行加上人看得懂的註解。6060GitHub Commit : 第二個測試:補齊Order->getType()
執行測試後,出現第二個測試案例的第四個紅燈,錯誤訊息為Call to undefined method VideoRental\Order::getDays()
錯誤訊息告訴我們 : Call to undefined method VideoRental\Order::getDays(),因為我們還沒建立getDays()
直接在PhpStorm內建立getDays()6161將滑鼠游標放在getDays()之後,按熱鍵⌥ + ↩,會出現Add method,按下可自動建立getDays()
PhpStorm會幫我們建立getDays()
之前已經建立好$days field,所以可以直接return $this->days,並加上註解。6262GitHub Commit : 第二個測試:補齊Order->getDays()
執行測試後,出現第二個測試案例的第五個紅燈,錯誤訊息為Failed asserting that 0 matches expected 210
錯誤訊息告訴我們 : Failed asserting that 0 matches expected 210,因為我們還沒寫NewRelease的計費方式的演算法。
補上NewRelease的計費方式演算法。
這樣我們就獲得了第二個測試的第一個綠燈。6363GitHub Commit : 第二個測試 : 第一個綠燈
 其實Children的計費方式也蠻接近的,我就順便elseif將Children補上好了!!
 The Three Rules of TDD No.3
You are not allowed to write any more production code than is sufficient to pass the one failing unit test.
Uncle Bob -The Three Rules of TDD
白話就是:若沒有測試案例,就不要自作聰明去寫程式。6464The Three Rules of TDD
目的 : 
  1. 避免over design,導致系統提早無謂的複雜。
  2. 將來若有新的測試案例,到時候再重構就好,不用現在去擔心。

第三個測試

第三種測試方式 : 直接在PhpStorm測試
將第一個測試test_order_1_regular_movie_with_10_days()複製貼上,改成第三個測試test_order_1_children_movie_with_8_days()6565不用擔心在test code使用複製貼上,test code不用擔心duplicated code問題,只有production code才必須考慮。
直接在PhpStorm執行測試。6666在左側選擇CustomerTest.php,按熱鍵⌃ + ⇧ + R。
我們發現前兩個測試案例都是綠燈,只有第三個測試案例是紅燈
這是第三個測試案例的第一個紅燈,錯誤訊息為Failed asserting that 130 matches expected 2106767GitHub Commit : 第三個測試 : 第一個紅燈
錯誤訊息告訴我們 : Failed asserting that 130 matches expected 210,因為我們還沒寫Childer的計費方式演算法。
補上Children的計費方式演算法。
這樣我們就獲得了第三個測試案例的第一個綠燈6868GitHub Commit : 第三個測試 : 第一個綠燈

重構


3個測試案例都通過,代表程式基本上已經符合Spec要求,可以即時交付程式。
但是符合Spec的程式並不代表這是好的程式,一個好的程式至少要符合5個要求:
  1. 容易維護。
  2. 容易新增功能。
  3. 容易重複使用。
  4. 容易寫測試。
  5. 容易上Git,不易與其他人發生衝突。
若更簡單的說,就是要符合SOLID原則的程式,才算是好程式。
接下來我們將使用重構,將目前的程式調整成符合SOLID原則的好程式。

if else改成switch

CustomercalculateTotalPrice(),有個if...elseif,因為都是判斷$order->getMovie()->getType(),將if...elseif改成switch,會讓程式碼比較容易閱讀。6969這並不是重構這本書所談的重構手法,不過switch的確比if...elseif容易閱讀,所以實務上也常常會將if...elseif重構成switch
重構成switch之後,馬上跑測試,確認重構沒將程式改壞掉。7070GitHub Commit : 重構 : if else改switch

Extract Method

改成switch之後,雖然程式碼已經比較容易閱讀了,但是在calculateTotalPrice()內還是顯得很臃腫,因此想使用重構Extract Method將此switch重構成一個method7171使用滑鼠選擇想要重構的程式碼,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Method…
出現Extract Method對話框,輸入重構後method名稱與選擇Visibility。
還可以選擇回傳值的處理方式,可以是return,也可以是pass by reference
最後對於switch的處理方式,可以選擇在case內直接return,還是最後一起return,這裡選擇每個case內直接return
重構之後,PhpStorm會自動幫你建立calculatePrice(),且原來程式也幫你自動呼叫calculatePrice()
馬上跑測試,確認PhpStorm的Extract Method沒將程式改壞掉。7272GitHub Commit : 重構 : Extract Method (switch -> Customer->calculatePrice())

Inline

經過Extract Method重構之後,我們發現$price這個暫存變數沒有存在的價值了。可使用重構Inlilne將此暫存變數移除。7373將滑鼠游標放在$price之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Inline…
PhpStorm會詢問你是否將所有的$price變數都inline。
Inline之後,程式就變成我們所預期的只有一行。
馬上跑測試,確認PhpStorm的Inline沒將程式改壞掉。7474GitHub Commit : 重構 : Inline

Move Method

經過重構產生的calculatePrice(),但程式內卻一直使用$order物件的method,看起來calculatePrice()不應該放在Customer內,而應該放在Order內。
使用重構Move MethodcalculatePrice()Customer搬到Order內。7575將滑鼠游標放在calculatePrice之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Move…
出現Move non-static method is not supported錯誤訊息,也就是PhpStorm目前只能支援將對static method進行重構Move Method,而一般method不支援。
因為PhpStorm目前僅支援static methodMove 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->7676加上註解描述$order的型別為Order這種寫法雖然可行,但不是最漂亮的寫法,最漂亮的寫法應該是直接在field註解型別,也就使在protected $order = []之前直接@var Order[],之後所有的foreach內都不用再加上註釋。
PhpStorm雖然已經幫我們把calculatePrice()搬到Order,但顯然static是多餘的,且因為已經在Order,所以不需再傳入$order了。
static刪除。
因為已經搬到Order,所以如getMovie()getDays()都不在需要$order,而是需要改成$this
使用重構Rename,將$order改成$this7777將滑鼠游標放在$order之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Rename…
將全部的$order都改成$this
剛才我們只是借用重構Rename$order改成$this,事實上calculatePrice()根本不需要任何參數,將Order $this刪除。
馬上跑測試,確認PhpStorm的Rename沒將程式改壞掉。7878GitHub Commit : 重構 : Move Method : Order->calculatePrice()
在剛剛重構產生的OrdercalculatePrice()內,我們發現switch竟然是去判斷MoviegetType(),這是不合理的,似乎暗示著應該將這個switch判斷搬到Movie內。7979使用滑鼠選擇想要重構的程式碼,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Method…
出現Extract Method對話框,原本我們是想將此switch重構成MoviecalculatePrice(),但PhpStorm的Extract Method無法跨class,我們只好先Extract MethodOrder內,然後再使用Move Method搬到Movie
因為同一個class不能存在兩個calculatePrice(),因此先取名為MovieCalculatePrice()
使用Move MethodMovieCalculatePrice()搬到Movie
因為目前PhpStorm只支援static methodMovie Method,因此先改成static8080將滑鼠游標放在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成變數。8181選擇$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沒將程式改壞掉。8282GitHub Commit : 重構 : Move Method : Movie->calculatePrice()

Replace Type Code with State/Stratgey

重構的技巧中,有一招叫做Replace Type Code with State/Strategy,簡單的說,當你的程式會使用switch對同一個變數去做判斷時,可以改用物件導向的多型來處理,或者更白話的說,改用設計模式State模式Strategy模式去處理。
這樣的好處是會使你的程式符合SOLID開放封閉原則,將來若新的需求要新增,將不用去改原來程式的switch,只要去新增class即可。8383開放封閉原則 : 軟體中的類別、函式對於擴展是開放的,對於修改是封閉的。

Self Encapsulate Field

我們即將重構成State模式,因為State模式會將變化抽象化成一個物件,也就是需要將原本的字串,如RegularNewReleaseChildren最後抽象化成物件,因此重構教我們要使用Replace Type Code with State/Strategy前,先執行另一招重構 : Self Encapsulate Field
Self Encapsulate Field簡單的說,就是將field全部改用setter的方式寫入,這樣我們就可以在setter內將字串抽象化成物件8484將滑鼠游標放在$type之後,按熱鍵⌘ + N,會出現Generate選單,選擇Setters,可幫我們自動建立$type的setter。
選擇所要建立setter的field。
PhpStorm自動幫我們加上$type的setter : setType()
將setType()的參數加上type hint。
建立setter只是Self Encapsulate Field的第一步,接下來就將程式所有地方改用setter去寫入$type field。8585GitHub Commit : 重構 : Self Encapsulate Field

將變化封裝在class

State模式會將變化封裝在class內,也就是以物件導向的多型取代switch,無論將來怎麼變化,對使用者看起來都是相同的abstract class
建立Abstract Class
VideoRental目錄下建立AbstractMovieType8686在左側選擇VideoRental目錄,按熱鍵⌃ + N,會出現New選單,選擇PHP Class,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入AbstractMovieType,namespace選擇VideoRental
PhpStorm會幫我們建立AbstractMovieType
因為我們要建立的是abstract class,所以在class前面加上abstract
另外定義一個abstract method : calculatePrice()8787GitHub Commit : 重構 : 建立AbstractMovieType
建立RegularMovieType Class
接著我們要將各種影片類型的計費方式,封裝在class內。
新增RegularMovieType,負責普通片的計費方式。8888在左側選擇VideoRental目錄,按熱鍵⌃ + N,會出現New選單,選擇PHP Class,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入RegularMovieType,namespace選擇VideoRental
PhpStorm會幫我們建立RegularMovieType
繼承AbstractMovieType,使用PhpStorm幫我們建立abstract class所定義的method。8989將滑鼠游標放在AbstractMovieType之後,按熱鍵⌥ + ↩,會出現Add method stubs,按下可自動根據所繼承的abstract class建立method。
PhpStorm會幫我們建立calculatePrice(),連註解也會一併建立。
將原本在Movie->calculatePrice()內的普通片計費方式剪下
貼到RegularMovieTypecalculatePrice()內。
直接將$price的初始值指定為100即可。9090GitHub Commit : 重構 : 建立RegularMovieType
建立NewReleaseMovieType Class
新增NewReleaseMovieType,負責新片的計費方式。9191在左側選擇VideoRental目錄,按熱鍵⌃ + N,會出現New選單,選擇PHP Class,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入NewReleaseMovieType,namespace選擇VideoRental
PhpStorm會幫我們建立NewReleaseMovieType
繼承AbstractMovieType,使用PhpStorm幫我們建立abstract class所定義的method。9292將滑鼠游標放在AbstractMovieType之後,按熱鍵⌥ + ↩,會出現Add method stubs,按下可自動根據所繼承的abstract class建立method。
PhpStorm會幫我們建立calculatePrice(),連註解也會一併建立。
將原本在Movie->calculatePrice()內的新片計費方式剪下
貼到NewReleaseMovieTypecalculatePrice()內。
直接將$price的初始值指定為150即可。9393GitHub Commit : 重構 : 建立NewReleaseMovieType
建立ChildrenMovieType Class
新增ChildrenMovieType,負責兒童片的計費方式。9494在左側選擇VideoRental目錄,按熱鍵⌃ + N,會出現New選單,選擇PHP Class,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入ChildrenMovieType,namespace選擇VideoRental
PhpStorm會幫我們建立ChildrenMovieType
繼承AbstractMovieType,使用PhpStorm幫我們建立abstract class所定義的method。9595將滑鼠游標放在AbstractMovieType之後,按熱鍵⌥ + ↩,會出現Add method stubs,按下可自動根據所繼承的abstract class建立method。
PhpStorm會幫我們建立calculatePrice(),連註解也會一併建立。
將原本在Movie->calculatePrice()內的兒童片計費方式剪下
貼到ChildrenMovieTypecalculatePrice()內。
直接將$price的初始值指定為40即可。9696GitHub Commit : 重構 : 建立ChildrenMovieType
重構setType()
之前的setType()只是單純的$type field的setter,不過使用State模式之後,setType()的角色就有了改變,不再只是單存的setter,而是要建立適當的AbstractMovieType物件。
calculatePrice()剩下的switch剪下
貼到setType()內。
$this->getType()改成$type
$this->typenew我們剛剛建立,用來封裝計費方式的物件。
private $type的PHPDoc註解型別,也從原來的string改成AbstractMovieType
重構getType()
因為$type field的型別已經從原本的string改成AbstractMovieType,因此getType()的回傳型別與PHPDoc註解也要更新。
重構calculatePrice()
由於setType()已經幫我們切換計費方式物件,據SOLID里氏替換原則,且這些物件都是繼承於AbstractMovieType,根我們可以直接呼叫子類別的calculatePrice()
馬上跑測試,確認重構沒將程式改壞掉。9797GitHub Commit : 重構 : Movie->setType(), getType()與calculatePrice()

Replace Constructor with Factory Method

重構的技巧中,有一招叫做Replace Constructor with Factory Method,簡單的說,當你使用new去建立物件時,就直接相依了該物件,我們可將new物件的邏輯封裝在Simple Factory模式內,如此我們就只相依工廠物件,而不會直將相依於計費方式物件。
新增MovieTypeFactory,負責建立計費方式物件。9898在左側選擇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
馬上跑測試,確認重構沒將程式改壞掉。9999GitHub 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型別的物件,這就是物件導向的多型100100詳細請參考深入探討Service Provider
馬上跑測試,確認重構沒將程式改壞掉。101101GitHub Commit : 重構 : 多型App::bind()

開放封閉原則

影片種類租期租金逾期費
普通片7天10010
新片3天15030
兒童片7天4010
國片10天8010
 現在需求改變,為了鼓勵國片,決定調降租金,並且延長租期
測試案例 
  1. 普通片1支,10天
     100 + (10-7) * 10 = 130
  2. 新片1支,5天
     150 + (5-3) * 30 = 210
  3. 兒童片1支,8天
     40 + (8-7) * 10 = 50
  4. 國片1支,12天
     80 + (12-10) * 10 = 100
新增國片測試案例,得到第一個紅燈
新增TaiwanMovieType,負責國片的計費方式。102102在左側選擇VideoRental目錄,按熱鍵⌃ + N,會出現New選單,選擇PHP Class,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入TaiwanMovieType,namespace選擇VideoRental
PhpStorm會幫我們建立TaiwanMovieType
繼承AbstractMovieType,使用PhpStorm幫我們建立abstract class所定義的method。103103將滑鼠游標放在AbstractMovieType之後,按熱鍵⌥ + ↩,會出現Add method stubs,按下可自動根據所繼承的abstract class建立method。
PhpStorm會幫我們建立calculatePrice(),連註解也會一併建立。
補上國片計費方式
馬上跑測試,確認新增的國片測試案例是否正常。104104GitHub Commit : 重構 : 開放封閉原則
 若使用原本switch的方式,無論switch寫在哪裡,只要新增功能,就一定要去改switch,這就違反了SOLID開放封閉原則,若使用物件導向的多型之後,若新增功能,只要新增class,繼承abstract class即可,原來的程式完全不用修改,完全達到開放封閉原則的要求。
 我們做了哪些重構?
  1. Extract Method : 將原本很長的函式利用Extract Method拆解成數個小小的method。
  2. Move Method : 利用Move Method將method搬到它適合的class內。
  3. 使用多型取代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前會停下來。105105在欲中斷的程式之處按熱鍵 : ⌘ + F8,可設定取消中斷點。
啟動偵錯模式。106106在欲啟動偵錯的測試案例內,按熱鍵 : ⌃ + ⇧ + D啟動偵錯模式,程式會停在剛剛建立的中斷點
Step Into進Customer->calculateTotalPrice()107107Step Into : 熱鍵 : F7
Step Over : 熱鍵 : F8
Step Out : 熱鍵 : ⇧ + F8
找到root cause在TaiwanMovieTypecalculatePrice()的18行有問題。

學習資源


學習方式



  • 直接由設計模式學習物件導向的學習曲線較為陡峭,很吃天份,失敗機率較高。
  • 學習測試重構達成設計模式的學習曲線較為平緩,適合常人,成功機率較高。

Conclusion


  • 好程式不是設計出來的,而是重構出來的。
  • 重構一定要搭配測試。TDD讓我們以Top Down方式,以需求出發,幫助我們抽象化思考,不用太早就去思考細節,可以更容易設計出符合SOLID的物件導向程式。
  • 測試重構會多花一點時間,因此我們要選擇更強悍的工具將時間省回來。

Sample Code


完整的範例可以在我的GitHub上找到。

from : http://oomusou.io/phpstorm/phpstorm-tdd-refactor/

沒有留言:

wibiya widget