卓越飞翔博客卓越飞翔博客

卓越飞翔 - 您值得收藏的技术分享站
技术文章16539本站已运行3318

重构遗留代码:第五部分 - 游戏可测试的方法

重构遗留代码:第五部分 - 游戏可测试的方法

旧代码。丑陋的代码。复杂的代码。意大利面条代码。胡言乱语。简而言之,遗留代码。这是一个可以帮助您工作和处理问题的系列。

在之前的教程中,我们测试了 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 类的初始状态表示。请注意强调的词:“状态”。我们需要找到一种方法来控制游戏的状态。我们需要更改它,使其具有最少的玩家数量。

如果我们分析 Gameadd() 方法,我们会看到它向我们的数组添加了元素。

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');
		}
	}

}

好多了。所有不相关的代码都在私有方法中,$gamesetUp()中初始化,并且从测试方法中去除了很多污染。然而,我们确实必须在这里做出妥协。在我们的第一个测试中,我们从一个断言开始。这假设 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() 太复杂,无法直接测试。另外它返回 nullaskQuestions() 乍一看似乎很有趣,但它都是演示,没有返回值。

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中的zip_close()函数
下一篇: 返回列表
留言与评论(共有 0 条评论)
   
验证码:
隐藏边栏