Home  >  Article  >  Backend Development  >  Refactoring legacy code: Part 5 - A way to make your game testable

Refactoring legacy code: Part 5 - A way to make your game testable

WBOY
WBOYOriginal
2023-08-28 16:29:06927browse

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

Old code. Ugly code. Complex code. Spaghetti code. nonsense. In short, Legacy Code. This is a series to help you work and deal with your problems.

In the previous tutorial, we tested the Runner function. In this lesson, it's time to pick up where we left off from testing the Game class. Now, when you start with a big chunk of code like we have here, it's easy to start testing method by method in a top-down fashion. Most of the time, this isn't possible. It's best to start testing it with a short, testable approach. That's what we'll do in this lesson: find and test these methods.

Create Game

In order to test a class, we need to initialize an object of that specific type. We can think of our first test as creating such a new object. You'd be surprised how many secrets a constructor can hide.

require_once __DIR__ . '/../trivia/php/Game.php';

class GameTest extends PHPUnit_Framework_TestCase {

	function testWeCanCreateAGame() {
		$game = new Game();
	}

}

To our surprise, Game can actually be created quite easily. There is no problem when running just new Game(). There is no damage. This is a pretty good start, especially considering that Game's constructor is quite large and does a lot of stuff.

Find the first testable method

Now I really want to simplify the constructor. But we only have the financial backers to make sure we don't break anything. Before we get into the constructor, we need to test most of the rest of the class. So, where should we start?

Look for the first method that returns a value and ask yourself, "Can I call and control the return value of this method?". If the answer is yes, then it's a good candidate for our testing.

function isPlayable() {
	$minimumNumberOfPlayers = 2;
	return ($this->howManyPlayers() >= $minimumNumberOfPlayers);
}

How about this method? This seems like a good candidate. There are only two lines and it returns a boolean value. But wait, it calls another method, howManyPlayers().

function howManyPlayers() {
	return count($this->players);
}

This is basically just a method that counts elements in an array of class players. Okay, so if we don't add any players, it should be zero. isPlayable() should return false. Let's see if our assumptions are correct.

function testAJustCreatedNewGameIsNotPlayable() {
	$game = new Game();
	$this->assertFalse($game->isPlayable());
}

We renamed the previous test method to reflect what we actually wanted to test. We then asserted that the game was unplayable. Test passed. But in many cases, false positives are common. So for safety reasons we can assert true and make sure the test fails.

$this->assertTrue($game->isPlayable());

Indeed!

PHPUnit_Framework_ExpectationFailedException :
Failed asserting that false is true.

So far, very promising. We managed to test the method's initial return value, which is represented by the initial state of the Game class. Note the emphasized word: "state." We need to find a way to control the state of the game. We need to change it so that it has the minimum number of players.

If we analyze the add() method of Game, we will see that it adds elements to our array.

array_push($this->players, $playerName);

Our assumptions are enforced by using the add() method in RunnerFunctions.php.

function run() {

	$aGame = new Game();
	$aGame->add("Chet");
	$aGame->add("Pat");
	$aGame->add("Sue");

	// ... //
}

Based on these observations, we can conclude that by using add() twice, we should be able to get the Game into a state with two players.

function testAfterAddingTwoPlayersToANewGameItIsPlayable() {
	$game = new Game();
	$game->add('First Player');
	$game->add('Second Player');
	$this->assertTrue($game->isPlayable());
}

By adding a second test method, we can ensure that isPlayable() returns true if the condition is met.

But you might think this is not exactly a unit test. We use the add() method! We practice more than just minimal code. We can just add elements to the $players array and not rely on the add() method at all.

Well, the answer is yes and no. From a technical perspective, we can do this. It would have the advantage of direct control of the array. However, it has the disadvantage of code duplication between code and tests. So pick one of the crappy options you think you can live with and use it. I personally prefer to reuse methods like add().

Refactoring Test

We are in green status, we are refactoring. Can we make our testing better? Yes we can. We can change our first test to verify all conditions without enough players.

function testAGameWithNotEnoughPlayersIsNotPlayable() {
	$game = new Game();
	$this->assertFalse($game->isPlayable());
	$game->add('A player');
	$this->assertFalse($game->isPlayable());
}

You may have heard of the concept of "one assertion per test". I basically agree with this, but if you have a test that validates a single concept and requires multiple assertions to do the validation, I think it's acceptable to use multiple assertions. This view is also strongly promoted in the teachings of Robert C. Martin.

But what about our second testing method? Is this enough? I said no.

$game->add('First Player');
$game->add('Second Player');

These two phone calls bother me a little. They are detailed implementations that are not explicitly explained in our approach. Why not extract them into private methods?

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 类中的一些更困难的方法。感谢您的阅读。

The above is the detailed content of Refactoring legacy code: Part 5 - A way to make your game testable. For more information, please follow other related articles on the PHP Chinese website!

Statement:
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn