오래된 코드. 추악한 코드. 복잡한 코드. 스파게티 코드. 무의미한 말. 간단히 말해서 레거시 코드입니다. 이 시리즈는 귀하의 문제를 해결하고 해결하는 데 도움이 되는 시리즈입니다.
이전 튜토리얼에서는 Runner 기능을 테스트했습니다. 이번 강의에서는 Game
수업 테스트를 중단한 부분부터 다시 시작하겠습니다. 이제 여기에 있는 것처럼 큰 코드 덩어리로 시작하면 하향식 방식으로 메서드별로 테스트를 쉽게 시작할 수 있습니다. 대부분의 경우 이는 불가능합니다. 짧고 테스트 가능한 접근 방식으로 테스트를 시작하는 것이 가장 좋습니다. 이것이 바로 이번 강의에서 우리가 할 일입니다. 바로 이러한 메서드를 찾아 테스트하는 것입니다.
클래스를 테스트하려면 해당 특정 유형의 개체를 초기화해야 합니다. 우리는 첫 번째 테스트를 그러한 새로운 객체를 생성하는 것으로 생각할 수 있습니다. 생성자가 얼마나 많은 비밀을 숨길 수 있는지 놀라실 것입니다.
으아악놀랍게도 Game
实际上可以很容易地创建。仅运行 new Game()
时没有问题。没有任何损坏。这是一个非常好的开始,特别是考虑到 Game
는 실제로 만들기가 꽤 쉽습니다. new Game()
만 실행하면 문제가 없습니다. 손상이 없습니다. 이것은 정말 좋은 시작입니다. 특히
이제 생성자를 단순화하고 싶습니다. 하지만 우리에게는 아무것도 깨지지 않도록 재정적 후원자가 있을 뿐입니다. 생성자로 들어가기 전에 클래스의 나머지 대부분을 테스트해야 합니다. 그럼 어디서부터 시작해야 할까요?
값을 반환하는 첫 번째 메서드를 찾아 "이 메서드의 반환 값을 호출하고 제어할 수 있나요?"라고 자문해 보세요. 대답이 '예'라면 테스트하기에 좋은 후보입니다.
으아악
howManyPlayers()
이 방법은 어때요? 이 후보가 좋은 것 같습니다. 두 줄만 있으며 부울 값을 반환합니다. 하지만 잠깐만요. 또 다른 메소드인
으아악
players
数组中元素的方法。好的,所以如果我们不添加任何玩家,它应该为零。 isPlayable()
이것은 기본적으로 false를 반환해야 하는 계산 클래스
으아악
실제로 테스트하고 싶은 내용을 반영하여 이전 테스트 방법의 이름을 변경했습니다. 그런 다음 우리는 게임을 플레이할 수 없다고 주장했습니다. 테스트가 통과되었습니다. 그러나 많은 경우 오탐(false positive)이 일반적입니다. 따라서 안전상의 이유로 우리는 true를 주장하고 테스트가 실패하는지 확인할 수 있습니다.으아악
그렇습니다!
으아악
Game
지금까지는 매우 유망합니다. 우리는 클래스의 초기 state
Game
的 add()
메소드를 분석해 보면 배열에 요소가 추가되는 것을 볼 수 있습니다. RunnerFunctions.php
中使用 add()
으아악
방법으로 시행됩니다. add()
两次,我们应该能够使 Game
으아악
를 두 번 사용하면 isPlayable()
를 두 명의 플레이어가 있는 상태로 만들 수 있다는 결론을 내릴 수 있습니다.
두 번째 테스트 방법을 추가하면 조건이 충족되면 add()
方法!我们练习的不仅仅是最少的代码。我们可以只将元素添加到 $players
数组中,而根本不依赖 add()
가 true를 반환하는지 확인할 수 있습니다.
하지만 이것이 정확히 단위 테스트가 아니라고 생각할 수도 있습니다. 우리는 add()
방법을 사용합니다.
리팩토링 테스트
그린 상태이며 리팩토링 중입니다. 테스트를 더 좋게 만들 수 있나요? 그래 우리는 할 수있어. 플레이어가 충분하지 않은 상태에서 모든 조건을 확인하기 위해 첫 번째 테스트를 변경할 수 있습니다.
으아악
"테스트당 하나의 어설션"이라는 개념을 들어보셨을 것입니다. 나는 이것에 대부분 동의하지만, 단일 개념을 검증하고 검증을 수행하기 위해 여러 어설션이 필요한 테스트가 있는 경우 여러 어설션을 사용하는 것이 허용된다고 생각합니다. 이 견해는 로버트 C. 마틴(Robert C. Martin)의 가르침에서도 강력하게 주장됩니다.두 번째 테스트 방법은 어떨까요? 이것이 충분하나요? 나는 거절했다. 🎜 으아악 🎜이 두 번의 전화가 조금 귀찮습니다. 이는 우리의 접근 방식에서 명시적으로 설명되지 않은 상세한 구현입니다. 왜 비공개 메소드로 추출하지 않습니까? 🎜
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
类中的一些更困难的方法。感谢您的阅读。
위 내용은 레거시 코드 리팩토링: 5부 - 게임을 테스트 가능하게 만드는 방법의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!