古いコード。醜いコード。複雑なコード。スパゲッティコード。ナンセンス。一言で言えば、レガシーコードです。これは、仕事や問題の解決に役立つシリーズです。
前のチュートリアルでは、Runner 関数をテストしました。このレッスンでは、Game
クラスのテストを中断したところから再開します。ここで示したような大きなコードの塊から始めると、トップダウン方式でメソッドごとのテストを開始するのが簡単になります。ほとんどの場合、これは不可能です。短くてテスト可能なアプローチからテストを始めるのが最善です。このレッスンでは、これらのメソッドを見つけてテストします。
驚いたことに、
Game は実際には非常に簡単に作成できます。 new Game()
だけを実行する場合は問題ありません。ダメージはありません。特に Game
のコンストラクターが非常に大きく、多くの処理を実行することを考慮すると、これはかなり良いスタートです。
最初のテスト可能なメソッドを見つける
値を返す最初のメソッドを探して、「このメソッドを呼び出して戻り値を制御できるか?」と自問してください。答えが「はい」の場合、それはテストの良い候補です。
リーリーこの方法はどうでしょうか?これは良い候補のようです。行は 2 行だけで、ブール値を返します。ただし、待ってください。別のメソッド
howManyPlayers() が呼び出されます。
リーリー
これは基本的に、クラス
の配列内の要素を数えるメソッドです。プレーヤーを追加しない場合は、ゼロになるはずです。 isPlayable()
は false を返す必要があります。私たちの仮定が正しいかどうか見てみましょう。
リーリー
実際にテストしたい内容を反映するために、前のテスト メソッドの名前を変更しました。その後、私たちはそのゲームはプレイ不可能であると主張しました。テストに合格しました。しかし、多くの場合、誤検知がよく発生します。したがって、安全上の理由から、true をアサートし、テストが失敗することを確認できます。
リーリー
これまでのところ、非常に有望です。メソッドの初期戻り値をテストすることができました。これは、Game
クラスの初期state によって表されます。 「状態」という単語が強調されていることに注意してください。ゲームの状態を制御する方法を見つける必要があります。最小限のプレイヤー数になるように変更する必要があります。
Game の
メソッドを分析すると、要素が配列に追加されることがわかります。
リーリー
私たちの仮定は、RunnerFunctions.php
の
メソッドを使用して適用されます。
リーリー
これらの観察に基づいて、add()
を 2 回使用することで、
を 2 人のプレイヤーがいる状態にできるはずであると結論付けることができます。
リーリー
2 番目のテスト メソッドを追加することで、条件が満たされた場合に isPlayable()
が true を返すようにすることができます。
しかし、これは正確には単体テストではないと思うかもしれません。 add()
メソッドを使用します。私たちは最小限のコード以上のものを練習します。
配列に要素を追加するだけでよく、add()
メソッドにはまったく依存しません。
そうですね、答えはイエスでもありノーでもあります。技術的な観点から言えば、これは可能です。これには、アレイを直接制御できるという利点があります。ただし、コードとテストの間でコードが重複するという欠点があります。したがって、あなたが耐えられると思うくだらないオプションの1つを選択して、それを使用してください。私は個人的に add()
のようなメソッドを再利用することを好みます。
リファクタリングテスト
私たちはグリーンステータスにあり、リファクタリング中です。テストを改善できるでしょうか?はい、できます。最初のテストを変更して、十分なプレイヤーがいない状態ですべての条件を検証することができます。
では、2 番目のテスト方法についてはどうでしょうか?これで十分ですか?私はノーと言った。
リーリーこの 2 つの電話は少し気になります。これらは詳細な実装ですが、私たちのアプローチでは明示的に説明されていません。それらをプライベート メソッドに抽出してみてはいかがでしょうか?
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 中国語 Web サイトの他の関連記事を参照してください。