舊程式碼,醜陋的程式碼,複雜的程式碼,義大利麵條似的程式碼,鬼話廢話……就是四個字:遺留程式碼。這是一個系列文章,將有助於你處理並解決它。
在我們之前的教程中,我們測試了我們的Runner功能。在這篇教程中,是時候繼續我們留在測試Game類的活了。現在,當你使用一大塊像我們這裡的程式碼時,傾向於以自上向下的方式逐方法開始測試。這種方式大部分時候下是不可能的。更好的方式是開始測試它的短的可測試的方法。這就是我們這課要做的:找到並測試這些方法。
建立一個遊戲
為了測試類,我們需要初始化該特定型別的物件。我們可以認為,我們的第一個測試就是要建立這樣的一個新物件。你將驚異於建構函式能隱藏多少祕密。
1 2 3 4 5 6 7 8 9 10 11 |
require_once __DIR__ . '/../trivia/php/Game.php'; class GameTest extends PHPUnit_Framework_TestCase { function testWeCanCreateAGame() { $game = new Game(); } } |
讓我們驚訝的是,Game類真的可以被很容易地建立。當只執行new Game()時沒問題。沒有東西被破壞。這是個很好的開始,特別是考慮到Game的建構函式相當大並且做了很多事。
找到第一個可測試的方法
此時我們傾向於簡化建構函式。但我們只有金牌大師能保證我們沒有破壞任何東西。在我們處理建構函式之前,我們需要測試類中餘下的大部分程式碼。那麼,我們應該如何開始?
查詢第一個有返回值的方法,並且問你自己,“我們呼叫這個方法並控制它的返回值嗎?”。如果回答是可以,那麼它對於我們的測試就是個不錯的候選者。
1 2 3 4 5 6 7 |
function isPlayable() { $minimumNumberOfPlayers = 2; return ($this->howManyPlayers() >= $minimumNumberOfPlayers); } |
這個方法怎麼樣?它看起來像個不錯的候選者。只有兩行並且它返回一個布林值。但等一下,它呼叫了另一個方法,howManyPlayers()。
1 2 3 4 5 |
function howManyPlayers() { return count($this->players); } |
這基本上是一個統計類中players陣列元素個數的方法。好的,那麼如果我們不增加任何玩家,那麼它將是0。isPlayable()應該返回false。讓我們看看我們的假設是否正確。
1 2 3 4 5 6 7 |
function testAJustCreatedNewGameIsNotPlayable() { $game = new Game(); $this->assertFalse($game->isPlayable()); } |
我們將之前的測試方法重新命名了以便反應我們真實想測的內容。然後我們只斷言遊戲是不可以玩的。測試通過了。但在許多情況下通常是誤報。因此對於這個想法,我們可以斷言真,並且確保測試失敗。
1 |
$this->assertTrue($game->isPlayable()); |
它真的可以!
1 2 3 |
PHPUnit_Framework_ExpectationFailedException : Failed asserting that false is true. |
到目前為止,相當有希望。我們試著測試方法的初期返回值,這個值代表了Game類的初始狀態,請注意強調詞:“狀態”。我們需要找到一種方式去控制遊戲的狀態。我們需要去修改它,以便它有最少數量的玩家。
如果我們分析Game的add()方法,我們將看到它往我們的陣列裡增加元素。
1 |
array_push($this->players, $playerName); |
我們的假設是通過RunnerFunctions.php的add()方法的方式來實施。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function run() { $aGame = new Game(); $aGame->add("Chet"); $aGame->add("Pat"); $aGame->add("Sue"); // ... // } |
基於這些觀察,我們可以認為通過兩次呼叫add()方法,應該能將我們的Game類設為有兩個玩家的狀態。
1 2 3 4 5 6 7 8 9 10 11 |
function testAfterAddingTwoPlayersToANewGameItIsPlayable() { $game = new Game(); $game->add('First Player'); $game->add('Second Player'); $this->assertTrue($game->isPlayable()); } |
通過追加這第二個測試方法,我們可以肯定如果條件滿足的話,isPlayable()返回true。
但你可能會想這不是一個完全的單元測試。我們使用了add()方法!我們執行了超過最低限度的程式碼。取而代之,我們完全可以只通過往$players陣列增加元素而不依賴add()方法。
好了,答案是是和不是。從技術角度講,我們可以這麼做。這將帶來直接控制陣列的優勢。然而,它將帶來程式碼和測試之間程式碼重複的劣勢。所以,從壞選擇中選一個你認為你能忍受的並且使用它。我個人更喜歡重用像add()的方法。
重構測試
我們測試通過了,我們重構。我們可以使測試程式更好嗎?好了,是的,我們可以。我們可以改變第一個測試來驗證沒有足夠玩家的所有條件。
1 2 3 4 5 6 7 8 9 10 11 |
function testAGameWithNotEnoughPlayersIsNotPlayable() { $game = new Game(); $this->assertFalse($game->isPlayable()); $game->add('A player'); $this->assertFalse($game->isPlayable()); } |
你可能聽過“每個測試一個斷言”這樣的概念。大部分時候我都認同它,但是如果你有一個驗證單一概念並且需要多斷言來完成驗證的測試時,我認為使用多於一個斷言是可接受的。這個觀點在Robert C. Martin的教學中也被強烈推薦。
但我們的第二個測試怎麼樣?它足夠了嗎?我認為沒有。
1 2 3 |
$game->add('First Player'); $game->add('Second Player'); |
這兩個呼叫有點困擾我。在我們的方法中它們是沒有明確解釋的具體實現。為什麼不把它們抽出到私有方法中呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function testAfterAddingEnoughPlayersToANewGameItIsPlayable() { $game = new Game(); $this->addEnoughPlayers($game); $this->assertTrue($game->isPlayable()); } private function addEnoughPlayers($game) { $game->add('First Player'); $game->add('Second Player'); } |
這樣好多了,並且它也帶給我們另一個我們忽視的概念。在兩個測試中,我們以一種或者另一種方式表達“足夠玩家”的概念。但多少為足夠?是2個嗎?是的,目前是的。但如果Game的邏輯至少需要三個玩家,我們還想測試程式失敗嗎?我們不想發生這樣的情況。我們可以為它引入一個公共靜態類的域。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Game { static $minimumNumberOfPlayers = 2; // ... // function __construct() { // ... // } function isPlayable() { return ($this->howManyPlayers() >= self::$minimumNumberOfPlayers); } // ... // } |
這將允許我們在測試中使用它。
1 2 3 4 5 6 7 8 9 |
private function addEnoughPlayers($game) { for($i = 0; $i < Game::$minimumNumberOfPlayers; $i++) { $game->add('A Player'); } } |
我們的小輔助方法只用來增加玩家直到足夠的玩家被加進來。我們甚至可以為第一個測試程式建立另一個這樣的方法,所以我們增加幾乎足夠的玩家。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function testAGameWithNotEnoughPlayersIsNotPlayable() { $game = new Game(); $this->assertFalse($game->isPlayable()); $this->addJustNothEnoughPlayers($game); $this->assertFalse($game->isPlayable()); } private function addJustNothEnoughPlayers($game) { for($i = 0; $i < Game::$minimumNumberOfPlayers - 1; $i++) { $game->add('A player'); } } |
但這引入了一些程式碼副本。我們兩個輔助方法非常相似。我們不能從它們中抽出第三個方法嗎?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
private function addEnoughPlayers($game) { $this->addManyPlayers($game, Game::$minimumNumberOfPlayers); } private function addJustNothEnoughPlayers($game) { $this->addManyPlayers($game, Game::$minimumNumberOfPlayers - 1); } private function addManyPlayers($game, $numberOfPlayers) { for ($i = 0; $i < $numberOfPlayers; $i++) { $game->add('A Player'); } } |
那樣好多了,但它造成了另一個不同的問題。在這些方法中我們減少了程式碼副本,但是我們的$game物件向下傳遞了三層。管理它變得困難了。是時候在測試程式的setUp()方法中初始化並且複用它了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
class GameTest extends PHPUnit_Framework_TestCase { private $game; function setUp() { $this->game = new Game; } function testAGameWithNotEnoughPlayersIsNotPlayable() { $this->assertFalse($this->game->isPlayable()); $this->addJustNothEnoughPlayers(); $this->assertFalse($this->game->isPlayable()); } function testAfterAddingEnoughPlayersToANewGameItIsPlayable() { $this->addEnoughPlayers($this->game); $this->assertTrue($this->game->isPlayable()); } private function addEnoughPlayers() { $this->addManyPlayers(Game::$minimumNumberOfPlayers); } private function addJustNothEnoughPlayers() { $this->addManyPlayers(Game::$minimumNumberOfPlayers - 1); } private function addManyPlayers($numberOfPlayers) { for ($i = 0; $i < $numberOfPlayers; $i++) { $this->game->add('A Player'); } } } |
更好了。所有不相關的程式碼都在私有方法中了,$game在setUp()方法中初始化,在測試方法中許多汙染也被清除了。但是,我們需要在這裡做個折中。在我們第一個測試中,我們開始於一個斷言。這裡假定setUp()總是建立一個空的game。對當前來說這沒問題。但到一天結束時,你必須意識到沒有完美程式碼這回事。只有你願意忍受的折中程式碼。
第二個可測試的方法
如果我們從頭到尾掃描我們的Game類,在我們列表上的下一個方法是add()。是的,是之前章節測試程式中的同一個方法。但我們可以測試它嗎?
1 2 3 4 5 6 7 |
function testItCanAddANewPlayer() { $this->game->add('A player'); $this->assertEquals(1, count($this->game->players)); } |
現在這是測試物件的不同方式。我們呼叫方法,然後我們驗證物件的狀態。正如add()始終返回true,我們沒辦法測試它的輸出。但我們可以以一個空的Game物件開始,然後檢查在我們增加了一個之後時候會有個單一使用者。但這夠驗證了嗎?
1 2 3 4 5 6 7 8 9 |
function testItCanAddANewPlayer() { $this->assertEquals(0, count($this->game->players)); $this->game->add('A player'); $this->assertEquals(1, count($this->game->players)); } |
我們也去驗證在呼叫add()之前沒有玩家不是更好嗎?好了,在這裡可能要求有點太多了,但正如你看到的上面的程式碼,我們可以這麼做。無論何時當你不確定初始狀態的時,你應該在上面做個斷言。對於未來程式碼修改可能改變你的物件的初始狀態這件事上也能保護你。
但我們測了所有add()方法所能做的事嗎?我說沒有。除了增加使用者,它也對其設定了許多設定。我們也要檢視那些程式碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function testItCanAddANewPlayer() { $this->assertEquals(0, count($this->game->players)); $this->game->add('A player'); $this->assertEquals(1, count($this->game->players)); $this->assertEquals(0, $this->game->places[1]); $this->assertEquals(0, $this->game->purses[1]); $this->assertFalse($this->game->inPenaltyBox[1]); } |
這好多了。我們驗證了add()方法的每一個動作。現在,我更願意直接測試$players陣列。為什麼?我們本可以使用基本做了相同事的howManyPlayers()方法,對嗎?好吧,在這種情況下我們認為,描述add()方法對於物件狀態影響的斷言更重要。如果我們需要修改add(),我們可以預料測試其嚴格行為的測試將會失敗。關於這點,我已經和我在Syneto的同事們有了無休止的爭論。特別是因為這種型別的測試在測試程式與add()方法如何真正實現之間造成了強耦合。所以,如果你更願意以另一種方式測試它,那並不表明你的主意是錯誤的。
我們可以安全地忽略輸出的測試,echoln()那行。它們只是在螢幕上輸出內容。我們還不想碰這些方法。我們的金牌大師完全依賴這個輸出。
重構測試程式(再次)
我們有另一個以全新的通過測試的已測方法。是時候重構兩個測試程式了,只做一點。讓我們開始測試程式。難道最後三行斷言不有點混亂?它們似乎不和嚴格的增加玩家相關。讓我們修改它:
1 2 3 4 5 6 7 8 9 10 11 |
function testItCanAddANewPlayer() { $this->assertEquals(0, count($this->game->players)); $this->game->add('A player'); $this->assertEquals(1, count($this->game->players)); $this->assertDefaultPlayerParametersAreSetFor(1); } |
這樣好多了。現在這個方法更抽象,可複用,有表達力的命名,並且隱藏了所有不重要的細節。
重構add()方法
對於我們的產品程式碼我們也可以做相似的事情。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function add($playerName) { array_push($this->players, $playerName); $this->setDefaultPlayerParametersFor($this->howManyPlayers()); echoln($playerName . " was added"); echoln("They are player number " . count($this->players)); return true; } |
我們把不重要的細節抽到setDefaultPlayerParametersFor()方法中。
1 2 3 4 5 6 7 8 9 |
private function setDefaultPlayerParametersFor($playerId) { $this->places[$playerId] = 0; $this->purses[$playerId] = 0; $this->inPenaltyBox[$playerId] = false; } |
事實上在我寫了測試程式之後才想到這個主意。這是另一個好例子,關於測試程式是如何迫使我們以不同的角度思考程式碼的。我們必須利用針對問題的不同角度,並且讓測試程式指導產品程式碼的設計。
第三個可測試的方法
讓我們找找第三個可測試的候選者。howManyPlayers()太簡單並且已經非直接地測試過了。roll()太複雜而不能直接測試。而且它返回null。askQuestions()第一眼看起來令人感興趣,但它只有表示而沒有返回值。
currentCategory()是可測試的,但它很難測試。它是一個有十個條件的巨大的選擇器。我們需要一個十行之多的測試程式,然後我們需要認真地重構這個方法和最確定的測試方法。我們應該對這個方法做個標記,在我們完成了更簡單的方法測試之後再回來。對我們來說,這將在我們下篇教程中出現。
wasCorrectlyAnswered()又是個複雜的方法。我們需要從中抽取,小段程式碼是可測試的。但是,wrongAnswer()看起來挺有希望。它在螢幕輸出東西,但也改變我們物件的狀態。讓我們看看能不能控制並測試它。
1 2 3 4 5 6 7 8 9 10 11 |
function testWhenAPlayerEntersAWrongAnswerItIsSentToThePenaltyBox() { $this->game->add('A player'); $this->game->currentPlayer = 0; $this->game->wrongAnswer(); $this->assertTrue($this->game->inPenaltyBox[0]); } |
哎呀,這個測試方法很難寫啊。wrongAnswer()依賴$this->currentPlayer作為其基本邏輯,但它也用$this->players在其表示部分。為什麼你不應該混淆邏輯與表示的一個醜陋的例子。我們會在未來的教程中處理這種情況。現在,我們測試使用者進禁區的情況。我們也必須注意到方法中有個if()表示式。這是我們還沒測試的條件,因為我們只有一個玩家,因此我們不滿足條件。但我們可以測試$currentPlayer的最後值。但在測試中增加這行程式碼將使其失敗。
1 |
$this->assertEquals(1, $this->game->currentPlayer); |
對私有方法shouldResetCurrentPlayer()更仔細的檢視揭示了一個問題。如果當前玩家的索引和玩家數相同的話,它將被重置為0。啊啊!我們實際上進入if()了!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
function testWhenAPlayerEntersAWrongAnswerItIsSentToThePenaltyBox() { $this->game->add('A player'); $this->game->currentPlayer = 0; $this->game->wrongAnswer(); $this->assertTrue($this->game->inPenaltyBox[0]); $this->assertEquals(0, $this->game->currentPlayer); } function testCurrentPlayerIsNotResetAfterWrongAnswerIfOtherPlayersDidNotYetPlay() { $this->addManyPlayers(2); $this->game->currentPlayer = 0; $this->game->wrongAnswer(); $this->assertEquals(1, $this->game->currentPlayer); } |
不錯。我們建立了第二個測試,去測試仍有沒玩的玩家這種具體的情況。對第二個測試來說,我們不關心inPenaltyBox的狀態。我們只對當前玩家的索引感興趣。
最後一個可測試的方法
最後一個我們可測試並重構的方法是didPlayerWin()。
1 2 3 4 5 6 7 |
function didPlayerWin() { $numberOfCoinsToWin = 6; return !($this->purses[$this->currentPlayer] == $numberOfCoinsToWin); } |
我們可以立即發現它的程式碼結構和我們第一個測試的isPlayable()方法很相似。我們的解決方案也應該有些相似。當你的程式碼如此之短,只有兩到三行,做不止一個小小的步驟不會是一個大的風險。在最糟糕的情況下,你回覆三行程式碼。那麼讓我們做一步修改。
1 2 3 4 5 6 7 8 9 |
function testTestPlayerWinsWithTheCorrectNumberOfCoins() { $this->game->currentPlayer = 0; $this->game->purses[0] = Game::$numberOfCoinsToWin; $this->assertTrue($this->game->didPlayerWin()); } |
但等一下!它失敗了。這怎麼可能?它不應該通過嗎?我們提供了硬幣的正確的數字。如果我們研究一下方法,我們將發現一點誤導性的事實。
1 |
return !($this->purses[$this->currentPlayer] == $numberOfCoinsToWin); |
返回值是否定的。所以該方法不是告訴我們玩家是否贏了,而是告訴玩家是否沒贏遊戲。我們可以進入並找到這個方法被呼叫的地方,將它的值否定。然後在這裡改變它的行為,不要錯誤得否定答案。但它在我們還沒做單元測試的wasCorrectlyAnswered()方法中使用。也許暫時,簡單的重新命名來突顯正確的功能就足夠了。
1 2 3 4 5 |
function didPlayerNotWin() { return !($this->purses[$this->currentPlayer] == self::$numberOfCoinsToWin); } |
思考與總結
那麼這個教程差不多了。既然我們不喜歡在名字上進行否定,在這點上我們做個妥協。這個名字當我們開始其他部分程式碼的時候必然改變。此外,如果你看下我們的測試程式,它們現在看起來奇怪:
1 2 3 4 5 6 7 8 9 |
function testTestPlayerWinsWithTheCorrectNumberOfCoins() { $this->game->currentPlayer = 0; $this->game->purses[0] = Game::$numberOfCoinsToWin; $this->assertFalse($this->game->didPlayerNotWin()); } |
通過在否定方法上測試false,執行返回true結果的方法,我們給程式碼的可讀性帶來了許多困惑。但目前為止這是不錯的,正如我們需要在某些點停住,對吧?
在我們的下一篇教程中,我們將著手處理Game類當中更困難的方法。感謝你的閱讀。