使用正確的工具將會事半功倍

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原則1 1詳細請參考大澤木小鐵的從實例學習設計模式 威力加強版 使用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曾有一段話 : 2 2詳細請參考Laravel之父 : 學習出色的Design Pattern

如果有人想成為更棒的PHP工程師,你會怎麼建議?

學習出色的Design Pattern。這不只適用在PHP。你可以在任何程式語言使用這些pattern。尤其是SOLID。把這五個徹底學好,它會把你帶到新的境界,我每次寫code幾乎都在想這五個。

Taylor Otwell

除了Design Pattern,重點在於更根本的SOLID,這5點才是物件導向的心法。

設計模式

設計模式其實就是大神們留下來好的物件導向設計範本3 3物件導向設計模式-可再利用物件導向軟體之要素

優點 :

  1. 具體方案 : 至少是個具體的物件導向設計方式,不再流於抽象概念。
  2. 用得巧就很棒 : 只要在適當的場合,使用適當的模式,就會非常漂亮。

缺點 :

  1. 學習門檻高 : 理解設計模式已經不容易,要套用在實務上更難,很依賴天份
  2. 容易over design : 初學者容易一開始就套大量設計模式,導致系統提前過於複雜

重構

將既有的程式整形成符合物件導向精神的程式。4 4重構 : 改善既有程式的設計 (二版)

優點 :

  1. 學習門檻較低 : 重構招式較平易近人,容易學習。
  2. 可套用在legacy code : 不再只有新的專案才能物件導向。

缺點 :

  1. 需要依賴測試做保障 : 重構需要頻繁的測試,也需要測試保證重構沒有出錯。

TDD

既然重構需要測試,到底要寫測試,還是寫測試呢?

TDD的全名為Test Driven Development (測試驅動開發) 顛覆大家以往的習慣,強調先寫測試,再寫程式,整個流程是 :

優點 :

  1. 提供重構堅固的屏障 : 有寫測試,我們才敢放膽重構。
  2. 避免over design : 只為紅燈變成綠燈寫程式,不會寫出額外的程式。
  3. Top Down思維 : 因為測試先寫,會以測試好寫的角度去寫程式, 會比較接近使用者,也符合物件導向的精神。5 5詳細請參考使用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專案。6 6GitHub Commit : composer create-project

安裝Laravel Elixir

1
oomusou@mac:~/MyProject$ npm install

由於我們會使用Laravel Elixir在背後自動執行測試,因此要使用npm install7 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 | Directories10 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設定為App11 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. 普通片1支,10天16 16寫測試的第一步,就是要將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 Layer17 17詳細請參考Laravel的中大型專案架構

首先,在app目錄下建立VideoRental子目錄。

輸入VideoRental18 18在左側選擇app目錄,按下⌃ + N,出現下拉選單,選擇Directory建立新目錄。

由於新目錄會有自己的namespace名稱,因此要修改composer.jsonpsr-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設定為VideoRental21 21GitHub Commit : 新增domain目錄

第一個測試

接下來會介紹3種測試方式。

第一種測試方式 : 使用Gulp TDD

在命令列使用php artisan make:test建立測試class,預設會繼承tests目錄下的TestCase

在命令列執行gulp tdd,讓Laravel Elixir在背後執行PHPUnit,將來只要我們一存檔就會自動執行測試。

建立PHPUnit Test Method22 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內加上arrangeact, assert,以3A原則寫測試。25 25因為每個test method都需要3A原則當架構,建議可以自行加入PhpStorm的Live Template

3A原則
Arrange

  • 建立物件 (待測物件,相依物件,Mock物件)。
  • 建立假資料。
  • 設定期望值

Act

  • 實際執行待測物件的method,獲得實際值

Assert

  • 使用PHPUnit提供的assertion,測試期望值實際值是否相等。

3A原則為骨架,依次將測試補上。26 26實務上第一個會將act先補上,也就是先決定要測試哪一個method。

先寫測試讓我們會以測試好寫為前提設計,會幫助我們以使用者需求抽象化角度去思考架構。

Arrange
因為我們的需求是:計算一位顧客所有訂單的金額,且金額會隨著電影種類而不同,因此最基本,我們會有MovieOrderCustomer三個class,且一位顧客會有多筆訂單,因此會有addOrder()提供新增訂單。27 27此時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 found29 29GitHub 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

白話就是:你必須先寫測試亮紅燈之後,才可以寫程式。30 30The Three Rules of TDD

目的:

  1. 先亮紅燈,表示你已經先寫了測試,只是因為沒寫程式所以紅燈。
  2. 先亮紅燈,表示你之前寫的程式沒有over design

測試錯誤訊息告訴我們 : Class Movie not found。因為我們還沒建立Movie

直接在PhpStorm內建立Movie31 31將滑鼠游標放在Movie之後,按熱鍵⌥ + ↩,會出現Create class,按下可自動建立Movie

出現Create New PHP Class對話框,選擇目錄在app/VideoRental下,並選擇namespace : VideoRental

PhpStorm會幫我們建立Movie

存檔後出現第二個紅燈,錯誤訊息為Class Order not found32 32GitHub 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

白話就是:測試出現紅燈之後,你就必須先改程式將紅燈變成綠燈,而不是寫其他的測試製造更多的紅燈33 33The Three Rules of TDD

目的 :

  1. 將程式聚焦在目前的需求,方便程式解決目前的紅燈
  2. 不會一開始就將架構想的太複雜,造成over design,而枉費我們分拆測試範例。34 34詳細請參考91哥The Three Laws of TDD - 從紅燈變綠燈的過程

測試錯誤訊息告訴我們 : Order not found。因為我們還沒建立Order

直接在PhpStorm內建立Order35 35將滑鼠游標放在Order之後,按熱鍵⌥ + ↩,會出現Create class,按下可自動建立Order

出現Create New PHP Class對話框,選擇目錄在app/VideoRental下,並選擇namespace : VideoRental

PhpStorm會幫我們建立Order

存檔後出現第三個紅燈,錯誤訊息為Class Customer not found36 36GitHub Commit : 第一個測試 : 第三個紅燈

不要因為在測試時看到紅燈而沮喪

事實上TDD的開發流程本來就是先有紅燈才去寫程式,這也是TDD能解決over design的關鍵,因為測試案例的紅燈來自於需求,由紅燈變成綠燈就是解決需求,若沒有紅燈而直接綠燈,就表示程式有over design

測試錯誤訊息告訴我們 : Class Customer not found。因為我們還沒建立Customer

直接在PhpStorm內建立Customer37 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 13042 42GitHub Commit : 第一個測試 : 第六個紅燈

既然測試要求130,我們就直接很無恥的回傳130

這樣我們就獲得了第一個測試的第一個綠燈。43 43GitHub Commit : 第一個測試 : 第一個綠燈

直接使用return也太無恥了吧!!

第一個測試為了剛好符合第一個測試案例的需求,我們可以先無恥的使用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 21045 45GitHub Commit : 第二個測試 : 第一個紅燈

別忘了Uncle Bob的叮嚀,每個測試案例都要先出現第一個紅燈,若一開始就出現綠燈,表示你之前程式有over design了。

之前我們只新增了addOrder(),並還沒有填入程式。

宣告一個$orders陣列,並將$orderpush進$orders46 46GitHub Commit : 第二個測試 : 補齊Customer->addOrder()

由於將來calculateTotalPrice()也要使用$orders陣列,因此我們想將$orders從method內的變數變成class的field。47 47將滑鼠游標放在$orders之後,按⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Field…

出現兩種重構方式,選擇第一種 : $orders

出現Introduce field對話框,預設field已經幫我們填入orders

可以自行選擇Initialize inVisibility的方式。

這裡我們選擇Field declaration,也就是會直接在field宣告時初始化陣列。

如我們所願,宣告成protected $orders = []

並且push部分也自動改成field。48 48GitHub Commit : 第二個測試:Customer->addOrder()將$orders重構成field

由於需求是計算一位顧客所有訂單的金額,所以勢必有$totalPrice變數負責累加,然後需要一個foreach將整個$ordersloop一次,計算每種影片種類的金額。

以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的型別為Order51 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 : 第二個測試 : 第一個綠燈

其實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

白話就是:若沒有測試案例,就不要自作聰明去寫程式。64 64The 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()65 65不用擔心在test code使用複製貼上,test code不用擔心duplicated code問題,只有production code才必須考慮。

直接在PhpStorm執行測試。66 66在左側選擇CustomerTest.php,按熱鍵⌃ + ⇧ + R。

我們發現前兩個測試案例都是綠燈,只有第三個測試案例是紅燈

這是第三個測試案例的第一個紅燈,錯誤訊息為Failed asserting that 130 matches expected 21067 67GitHub Commit : 第三個測試 : 第一個紅燈

錯誤訊息告訴我們 : Failed asserting that 130 matches expected 210,因為我們還沒寫Childer的計費方式演算法。

補上Children的計費方式演算法。

這樣我們就獲得了第三個測試案例的第一個綠燈68 68GitHub 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,會讓程式碼比較容易閱讀。69 69這並不是重構這本書所談的重構手法,不過switch的確比if...elseif容易閱讀,所以實務上也常常會將if...elseif重構成switch

重構成switch之後,馬上跑測試,確認重構沒將程式改壞掉。70 70GitHub Commit : 重構 : if else改switch

Extract Method

改成switch之後,雖然程式碼已經比較容易閱讀了,但是在calculateTotalPrice()內還是顯得很臃腫,因此想使用重構Extract Method將此switch重構成一個method71 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 MethodcalculatePrice()Customer搬到Order內。75 75將滑鼠游標放在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->76 76加上註解描述$order的型別為Order這種寫法雖然可行,但不是最漂亮的寫法,最漂亮的寫法應該是直接在field註解型別,也就使在protected $order = []之前直接@var Order[],之後所有的foreach內都不用再加上註釋。

PhpStorm雖然已經幫我們把calculatePrice()搬到Order,但顯然static是多餘的,且因為已經在Order,所以不需再傳入$order了。

static刪除。

因為已經搬到Order,所以如getMovie()getDays()都不在需要$order,而是需要改成$this

使用重構Rename,將$order改成$this77 77將滑鼠游標放在$order之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Rename…

將全部的$order都改成$this

剛才我們只是借用重構Rename$order改成$this,事實上calculatePrice()根本不需要任何參數,將Order $this刪除。

馬上跑測試,確認PhpStorm的Rename沒將程式改壞掉。78 78GitHub Commit : 重構 : Move Method : Order->calculatePrice()

在剛剛重構產生的OrdercalculatePrice()內,我們發現switch竟然是去判斷MoviegetType(),這是不合理的,似乎暗示著應該將這個switch判斷搬到Movie內。79 79使用滑鼠選擇想要重構的程式碼,按熱鍵⌃ + 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,因此先改成static80 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模式會將變化抽象化成一個物件,也就是需要將原本的字串,如RegularNewReleaseChildren最後抽象化成物件,因此重構教我們要使用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目錄下建立AbstractMovieType86 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()內的普通片計費方式剪下

貼到RegularMovieTypecalculatePrice()內。

直接將$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()內的新片計費方式剪下

貼到NewReleaseMovieTypecalculatePrice()內。

直接將$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()內的兒童片計費方式剪下

貼到ChildrenMovieTypecalculatePrice()內。

直接將$price的初始值指定為40即可。96 96GitHub 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()

馬上跑測試,確認重構沒將程式改壞掉。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. 普通片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,負責國片的計費方式。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 : 重構 : 開放封閉原則

若使用原本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前會停下來。105 105在欲中斷的程式之處按熱鍵 : ⌘ + F8,可設定取消中斷點。

啟動偵錯模式。106 106在欲啟動偵錯的測試案例內,按熱鍵 : ⌃ + ⇧ + D啟動偵錯模式,程式會停在剛剛建立的中斷點

Step Into進Customer->calculateTotalPrice()107 107Step Into : 熱鍵 : F7
Step Over : 熱鍵 : F8
Step Out : 熱鍵 : ⇧ + F8

找到root cause在TaiwanMovieTypecalculatePrice()的18行有問題。

學習資源


學習方式


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

Conclusion


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

Sample Code


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