舊程式碼。醜陋的代碼。複雜的程式碼。義大利麵代碼。胡言亂語。簡而言之,遺留程式碼。這是一個可以幫助您工作和處理問題的系列。
#在先前的教學中,我們測試了 Runner 函數。在本課中,是時候從測驗 Game
類別的地方繼續我們上次停下的地方了。現在,當您從像我們這裡這樣的一大塊程式碼開始時,很容易開始以自上而下的方式逐一方法進行測試。大多數時候,這是不可能的。最好透過簡短的、可測試的方法開始測試它。這就是我們在本課中要做的事情:尋找並測試這些方法。
為了測試一個類,我們需要初始化該特定類型的物件。我們可以認為我們的第一個測試是創建這樣一個新物件。您會驚訝地發現建構函數可以隱藏多少秘密。
require_once __DIR__ . '/../trivia/php/Game.php'; class GameTest extends PHPUnit_Framework_TestCase { function testWeCanCreateAGame() { $game = new Game(); } }
令我們驚訝的是,Game
實際上可以輕鬆創建。僅執行 new Game()
時沒有問題。沒有任何損壞。這是一個非常好的開始,特別是考慮到 Game
的建構子相當大並且它做了很多事情。
現在很想簡化建構子。但我們只有金主來確保我們不會破壞任何東西。在我們進入建構函數之前,我們需要測試類別的大部分其餘部分。那麼,我們該從哪裡開始呢?
尋找第一個傳回值的方法並問自己,「我可以呼叫並控制這個方法的回傳值嗎?」。如果答案是肯定的,那麼它就是我們測試的良好候選者。
function isPlayable() { $minimumNumberOfPlayers = 2; return ($this->howManyPlayers() >= $minimumNumberOfPlayers); }
這個方法怎麼樣?這似乎是一個不錯的候選人。只有兩行,它傳回一個布林值。但等等,它呼叫另一個方法,howManyPlayers()
。
function howManyPlayers() { return count($this->players); }
這基本上只是一個計算類別 players
陣列中元素的方法。好的,所以如果我們不添加任何玩家,它應該為零。 isPlayable()
應該回傳 false。讓我們看看我們的假設是否正確。
function testAJustCreatedNewGameIsNotPlayable() { $game = new Game(); $this->assertFalse($game->isPlayable()); }
我們重新命名了先前的測試方法以反映我們真正想要測試的內容。然後我們就斷言遊戲無法玩。測試通過。但在許多情況下,誤報很常見。因此,出於安全考慮,我們可以斷言 true 並確保測試失敗。
$this->assertTrue($game->isPlayable());
確實如此!
PHPUnit_Framework_ExpectationFailedException : Failed asserting that false is true.
到目前為止,還是很有希望的。我們設法測試了該方法的初始回傳值,該值由 Game
類別的初始狀態表示。請注意強調的字:「狀態」。我們需要找到一種方法來控制遊戲的狀態。我們需要更改它,使其具有最少的玩家數量。
如果我們分析 Game
的 add()
方法,我們會看到它在我們的陣列中加入了元素。
array_push($this->players, $playerName);
我們的假設是透過在 RunnerFunctions.php
中使用 add()
方法來強制執行的。
function run() { $aGame = new Game(); $aGame->add("Chet"); $aGame->add("Pat"); $aGame->add("Sue"); // ... // }
根據這些觀察,我們可以得出結論,透過使用 add()
兩次,我們應該能夠使 Game
進入有兩個玩家的狀態。
function testAfterAddingTwoPlayersToANewGameItIsPlayable() { $game = new Game(); $game->add('First Player'); $game->add('Second Player'); $this->assertTrue($game->isPlayable()); }
透過新增第二個測試方法,我們可以確保 isPlayable()
在滿足條件的情況下傳回 true。
但您可能認為這不完全是單元測試。我們使用 add()
方法!我們練習的不僅僅是最少的程式碼。我們可以只將元素加入到 $players
陣列中,而完全不依賴 add()
方法。
嗯,答案是肯定的,也不是。從技術角度來看,我們可以做到這一點。它將具有直接控制陣列的優點。但是,它會存在程式碼和測試之間程式碼重複的缺點。因此,選擇您認為可以忍受的糟糕選項之一併使用它。我個人比較喜歡重複使用 add()
這樣的方法。
我們處於綠色狀態,我們進行重構。我們可以讓我們的測試變得更好嗎?是的,我們可以。我們可以改變我們的第一個測試來驗證沒有足夠玩家的所有條件。
function testAGameWithNotEnoughPlayersIsNotPlayable() { $game = new Game(); $this->assertFalse($game->isPlayable()); $game->add('A player'); $this->assertFalse($game->isPlayable()); }
您可能聽過「每個測試一個斷言」的概念。我基本上同意這一點,但是如果您有一個測試來驗證單個概念並需要多個斷言來進行驗證,我認為使用多個斷言是可以接受的。這一觀點在 Robert C. Martin 的教學中也得到了大力提倡。
但是我們的第二種測試方法呢?這樣就夠了嗎?我說不。
$game->add('First Player'); $game->add('Second Player');
這兩通電話讓我有點困擾。它們是我們的方法中沒有明確解釋的詳細實作。為什麼不將它們提取到私有方法中?
function testAfterAddingEnoughPlayersToANewGameItIsPlayable() { $game = new Game(); $this->addEnoughPlayers($game); $this->assertTrue($game->isPlayable()); } private function addEnoughPlayers($game) { $game->add('First Player'); $game->add('Second Player'); }
这要好得多,它也让我们想到了另一个我们错过的概念。在这两次测试中,我们都以这样或那样的方式表达了“足够多的玩家”的概念。但多少才够呢?是两个吗?是的,目前是这样。但是,如果 Game
的逻辑需要至少三个玩家,我们是否希望测试失败?我们不希望这种情况发生。我们可以为其引入一个公共静态类字段。
class Game { static $minimumNumberOfPlayers = 2; // ... // function __construct() { // ... // } function isPlayable() { return ($this->howManyPlayers() >= self::$minimumNumberOfPlayers); } // ... // }
这将使我们能够在测试中使用它。
private function addEnoughPlayers($game) { for($i = 0; $i < Game::$minimumNumberOfPlayers; $i++) { $game->add('A Player'); } }
我们的小助手方法只会添加玩家,直到添加足够的玩家为止。我们甚至可以为我们的第一次测试创建另一个这样的方法,因此我们添加了几乎足够的玩家。
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'); } }
但这引入了一些重复。我们的两个辅助方法非常相似。我们不能从中提取第三个吗?
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()
方法中初始化它并重用它了。
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
类,列表中的下一个方法是 add()
。是的,与我们在上一段测试中使用的方法相同。但我们可以测试一下吗?
function testItCanAddANewPlayer() { $this->game->add('A player'); $this->assertEquals(1, count($this->game->players)); }
现在这是测试对象的不同方式。我们调用我们的方法,然后验证对象的状态。由于 add()
总是返回 true
,因此我们无法测试其输出。但是我们可以从一个空的 Game
对象开始,然后在添加一个用户后检查是否有单个用户。但这足够验证吗?
function testItCanAddANewPlayer() { $this->assertEquals(0, count($this->game->players)); $this->game->add('A player'); $this->assertEquals(1, count($this->game->players)); }
在调用 add()
之前先验证一下是否没有玩家不是更好吗?好吧,这里可能有点太多了,但正如您在上面的代码中看到的,我们可以做到。当你不确定初始状态时,你应该对其进行断言。这还可以保护您免受将来可能会更改对象初始状态的代码更改的影响。
但是我们是否测试了 add()
方法所做的所有事情?我拒绝。除了添加用户之外,它还为其设置了很多设置。我们还应该检查这些。
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()
行。他们只是在屏幕上输出内容。我们还不想触及这些方法。我们的金主完全靠这个输出。
我们有另一种经过测试的方法,通过了全新的测试。是时候重构它们了,只是一点点。让我们从测试开始。最后三个断言是不是有点令人困惑?它们似乎与添加玩家没有严格关系。让我们改变它:
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()
方法我们可以用我们的生产代码做类似的事情。
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()
中。
private function setDefaultPlayerParametersFor($playerId) { $this->places[$playerId] = 0; $this->purses[$playerId] = 0; $this->inPenaltyBox[$playerId] = false; }
其实这个想法是我写完测试之后就产生的。这是另一个很好的例子,说明测试如何迫使我们从不同的角度思考我们的代码。我们必须利用这种看待问题的不同角度,并让我们的测试指导我们的生产代码设计。
让我们找到第三个候选者进行测试。 howManyPlayers()
太简单并且已经间接测试过。 roll()
太复杂,无法直接测试。另外它返回 null
。 askQuestions()
乍一看似乎很有趣,但它都是演示,没有返回值。
currentCategory()
是可测试的,但测试起来非常困难。这是一个巨大的选择器,有十个条件。我们需要一个十行长的测试,然后我们需要认真地重构这个方法,当然还有测试。我们应该记下这个方法,并在完成更简单的方法后再回来使用它。对于我们来说,这将出现在我们的下一个教程中。
wasCorrectlyAnswered()
又变得复杂了。我们需要从中提取可测试的小段代码。然而, wrongAnswer()
似乎很有前途。它在屏幕上输出内容,但它也会改变对象的状态。让我们看看是否可以控制它并测试它。
function testWhenAPlayerEntersAWrongAnswerItIsSentToThePenaltyBox() { $this->game->add('A player'); $this->game->currentPlayer = 0; $this->game->wrongAnswer(); $this->assertTrue($this->game->inPenaltyBox[0]); }
Grrr...编写这个测试方法相当困难。 wrongAnswer()
的行为逻辑依赖于 $this->currentPlayer
,但在其表示部分也使用了 $this->players
。一个丑陋的例子说明了为什么你不应该混合逻辑和表示。我们将在以后的教程中处理这个问题。现在,我们测试了用户进入惩罚框。我们还必须观察到该方法中有一个 if()
语句。这是我们尚未测试的条件,因为我们只有一个玩家,因此我们不满足该条件。不过,我们可以测试 $currentPlayer
的最终值。但是将这行代码添加到测试中将会导致测试失败。
$this->assertEquals(1, $this->game->currentPlayer);
仔细看看私有方法 shouldResetCurrentPlayer()
就会发现问题。如果当前玩家的索引等于玩家数量,则将其重置为零。啊啊啊!我们实际上输入的是if()
!
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()
。
function didPlayerWin() { $numberOfCoinsToWin = 6; return !($this->purses[$this->currentPlayer] == $numberOfCoinsToWin); }
我们立即可以观察到它的代码结构与我们首先测试的方法 isPlayable()
非常相似。我们的解决方案也应该类似。当你的代码如此短,只有两到三行时,执行不止一个小步骤并不是那么大的风险。在最坏的情况下,您将恢复三行代码。因此,让我们一步完成此操作。
function testTestPlayerWinsWithTheCorrectNumberOfCoins() { $this->game->currentPlayer = 0; $this->game->purses[0] = Game::$numberOfCoinsToWin; $this->assertTrue($this->game->didPlayerWin()); }
但是等等!那失败了。这怎么可能?不应该过去吗?我们提供了正确数量的硬币。如果我们研究我们的方法,我们会发现一些误导性的事实。
return !($this->purses[$this->currentPlayer] == $numberOfCoinsToWin);
返回值实际上是取反的。因此,该方法并不是告诉我们玩家是否获胜,而是告诉我们玩家是否没有赢得比赛。我们可以进去找到使用该方法的地方,并在那里否定它的价值。然后在此处更改其行为,以免错误地否定答案。但它是在 wasCorrectlyAnswered()
中使用的,我们还无法对这个方法进行单元测试。也许暂时,简单的重命名以突出显示正确的功能就足够了。
function didPlayerNotWin() { return !($this->purses[$this->currentPlayer] == self::$numberOfCoinsToWin); }
教程到此就结束了。虽然我们不喜欢名称中的否定,但这是我们目前可以做出的妥协。当我们开始重构代码的其他部分时,这个名称肯定会改变。此外,如果您看一下我们的测试,它们现在看起来很奇怪:
function testTestPlayerWinsWithTheCorrectNumberOfCoins() { $this->game->currentPlayer = 0; $this->game->purses[0] = Game::$numberOfCoinsToWin; $this->assertFalse($this->game->didPlayerNotWin()); }
通过在否定方法上测试 false,并使用表明真实结果的值来执行,我们给代码的可读性带来了很多混乱。但这目前来说很好,因为我们确实需要在某个时候停下来,对吗?
在下一个教程中,我们将开始研究 Game
类中的一些更困难的方法。感谢您的阅读。
以上是重構遺留程式碼:第五部分 - 遊戲可測試的方法的詳細內容。更多資訊請關注PHP中文網其他相關文章!