Maison > Article > développement back-end > Refactoriser le code existant : Partie 5 - Un moyen de rendre votre jeu testable
Ancien code. Code laid. Code complexe. Code des spaghettis. absurdité. En bref, code hérité. Il s'agit d'une série pour vous aider à travailler et à résoudre vos problèmes.
Dans le tutoriel précédent, nous avons testé la fonction Runner. Dans cette leçon, il est temps de reprendre là où nous nous sommes arrêtés après avoir testé la classe Game
. Désormais, lorsque vous commencez avec un gros morceau de code comme celui que nous avons ici, il est facile de commencer à tester méthode par méthode de manière descendante. La plupart du temps, ce n'est pas possible. Il est préférable de commencer à le tester avec une approche courte et testable. C'est ce que nous allons faire dans cette leçon : trouver et tester ces méthodes.
Pour tester une classe, nous devons initialiser un objet de ce type spécifique. Nous pouvons considérer notre premier test comme la création d’un tel nouvel objet. Vous seriez surpris du nombre de secrets qu'un constructeur peut cacher.
require_once __DIR__ . '/../trivia/php/Game.php'; class GameTest extends PHPUnit_Framework_TestCase { function testWeCanCreateAGame() { $game = new Game(); } }
À notre grande surprise, Game
实际上可以很容易地创建。仅运行 new Game()
时没有问题。没有任何损坏。这是一个非常好的开始,特别是考虑到 Game
est en fait assez facile à créer. Il n'y a aucun problème en exécutant simplement new Game()
. Il n'y a aucun dommage. C'est un très bon début, d'autant plus que le constructeur de
Maintenant, je veux vraiment simplifier le constructeur. Mais nous n'avons que les bailleurs de fonds pour être sûrs de ne rien casser. Avant d’entrer dans le constructeur, nous devons tester la plupart du reste de la classe. Alors, par où devrions-nous commencer ?
Recherchez la première méthode qui renvoie une valeur et demandez-vous : « Puis-je appeler et contrôler la valeur de retour de cette méthode ? Si la réponse est oui, alors c'est un bon candidat pour nos tests.
function isPlayable() { $minimumNumberOfPlayers = 2; return ($this->howManyPlayers() >= $minimumNumberOfPlayers); }
howManyPlayers()
Que diriez-vous de cette méthode ? Cela semble être un bon candidat. Il n’y a que deux lignes et renvoie une valeur booléenne. Mais attendez, cela appelle une autre méthode, .
function howManyPlayers() { return count($this->players); }
players
数组中元素的方法。好的,所以如果我们不添加任何玩家,它应该为零。 isPlayable()
Il s'agit essentiellement d'une classe de calcul qui devrait renvoyer false. Voyons si nos hypothèses sont correctes.
function testAJustCreatedNewGameIsNotPlayable() { $game = new Game(); $this->assertFalse($game->isPlayable()); }Nous avons renommé la méthode de test précédente pour refléter ce que nous voulions réellement tester. Nous avions alors affirmé que le jeu était injouable. Test réussi. Mais dans de nombreux cas, les faux positifs sont courants. Ainsi, pour des raisons de sécurité, nous pouvons affirmer vrai et nous assurer que le test échoue.
$this->assertTrue($game->isPlayable());En effet !
PHPUnit_Framework_ExpectationFailedException : Failed asserting that false is true.
Game
Jusqu’à présent, très prometteur. Nous avons réussi à tester la valeur de retour initiale de la méthode, qui est représentée par le state initial de la classe. Notez le mot accentué : « état ». Nous devons trouver un moyen de contrôler l’état du jeu. Nous devons le modifier pour qu'il ait le nombre minimum de joueurs.
Game
的 add()
Si nous analysons la méthode
, nous voyons qu’elle ajoute des éléments à notre tableau. RunnerFunctions.php
中使用 add()
array_push($this->players, $playerName);Nos hypothèses sont appliquées dans la méthode
. add()
两次,我们应该能够使 Game
function run() { $aGame = new Game(); $aGame->add("Chet"); $aGame->add("Pat"); $aGame->add("Sue"); // ... // }Sur la base de ces observations, nous pouvons conclure qu'en utilisant
deux fois, nous devrions pouvoir mettre isPlayable()
dans un état à deux joueurs.
function testAfterAddingTwoPlayersToANewGameItIsPlayable() { $game = new Game(); $game->add('First Player'); $game->add('Second Player'); $this->assertTrue($game->isPlayable()); }
En ajoutant une deuxième méthode de test, nous pouvons garantir que add()
方法!我们练习的不仅仅是最少的代码。我们可以只将元素添加到 $players
数组中,而根本不依赖 add()
renvoie vrai si la condition est remplie.
Mais vous pourriez penser qu’il ne s’agit pas exactement d’un test unitaire. Nous utilisons la méthode add()
.
Tests de refactorisation
Nous sommes en statut vert et nous sommes en train de refactoriser. Pouvons-nous améliorer nos tests ? Oui, nous pouvons. Nous pouvons modifier notre premier test pour vérifier toutes les conditions sans suffisamment de joueurs.
function testAGameWithNotEnoughPlayersIsNotPlayable() { $game = new Game(); $this->assertFalse($game->isPlayable()); $game->add('A player'); $this->assertFalse($game->isPlayable()); }Vous avez peut-être entendu parler du concept « une assertion par test ». Je suis fondamentalement d'accord avec cela, mais si vous avez un test qui valide un seul concept et nécessite plusieurs assertions pour effectuer la validation, je pense qu'il est acceptable d'utiliser plusieurs assertions. Ce point de vue est également fortement défendu dans les enseignements de Robert C. Martin.
Mais qu’en est-il de notre deuxième méthode de test ? Est-ce suffisant ? J'ai dit non. 🎜
$game->add('First Player'); $game->add('Second Player');🎜Ces deux appels me dérangent un peu. Ce sont des implémentations détaillées qui ne sont pas explicitement expliquées dans notre approche. Pourquoi ne pas les extraire dans des méthodes privées ? 🎜
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
类中的一些更困难的方法。感谢您的阅读。
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!