重构遗留代码(5):游戏的可测试方法

旧代码,丑陋的代码,复杂的代码,意大利面条似的代码,鬼话废话……就是四个字:遗留代码。这是一个系列文章,将有助于你处理并解决它。

在我们之前的教程中,我们测试了我们的Runner功能。在这篇教程中,是时候继续我们留在测试Game类的活了。现在,当你使用一大块像我们这里的代码时,倾向于以自上向下的方式逐方法开始测试。这种方式大部分时候下是不可能的。更好的方式是开始测试它的短的可测试的方法。这就是我们这课要做的:找到并测试这些方法。

 

创建一个游戏

为了测试类,我们需要初始化该特定类型的对象。我们可以认为,我们的第一个测试就是要创建这样的一个新对象。你将惊异于构造函数能隐藏多少秘密。

让我们惊讶的是,Game类真的可以被很容易地创建。当只运行new Game()时没问题。没有东西被破坏。这是个很好的开始,特别是考虑到Game的构造函数相当大并且做了很多事。

 

找到第一个可测试的方法

此时我们倾向于简化构造函数。但我们只有金牌大师能保证我们没有破坏任何东西。在我们处理构造函数之前,我们需要测试类中余下的大部分代码。那么,我们应该如何开始?

查找第一个有返回值的方法,并且问你自己,“我们调用这个方法并控制它的返回值吗?”。如果回答是可以,那么它对于我们的测试就是个不错的候选者。

这个方法怎么样?它看起来像个不错的候选者。只有两行并且它返回一个布尔值。但等一下,它调用了另一个方法,howManyPlayers()。

这基本上是一个统计类中players数组元素个数的方法。好的,那么如果我们不增加任何玩家,那么它将是0。isPlayable()应该返回false。让我们看看我们的假设是否正确。

我们将之前的测试方法重命名了以便反应我们真实想测的内容。然后我们只断言游戏是不可以玩的。测试通过了。但在许多情况下通常是误报。因此对于这个想法,我们可以断言真,并且确保测试失败。

它真的可以!

到目前为止,相当有希望。我们试着测试方法的初期返回值,这个值代表了Game类的初始状态,请注意强调词:“状态”。我们需要找到一种方式去控制游戏的状态。我们需要去修改它,以便它有最少数量的玩家。

如果我们分析Game的add()方法,我们将看到它往我们的数组里增加元素。

我们的假设是通过RunnerFunctions.php的add()方法的方式来实施。

基于这些观察,我们可以认为通过两次调用add()方法,应该能将我们的Game类设为有两个玩家的状态。

通过追加这第二个测试方法,我们可以肯定如果条件满足的话,isPlayable()返回true。

但你可能会想这不是一个完全的单元测试。我们使用了add()方法!我们执行了超过最低限度的代码。取而代之,我们完全可以只通过往$players数组增加元素而不依赖add()方法。

好了,答案是是和不是。从技术角度讲,我们可以这么做。这将带来直接控制数组的优势。然而,它将带来代码和测试之间代码重复的劣势。所以,从坏选择中选一个你认为你能忍受的并且使用它。我个人更喜欢重用像add()的方法。

重构测试

我们测试通过了,我们重构。我们可以使测试程序更好吗?好了,是的,我们可以。我们可以改变第一个测试来验证没有足够玩家的所有条件。

你可能听过“每个测试一个断言”这样的概念。大部分时候我都认同它,但是如果你有一个验证单一概念并且需要多断言来完成验证的测试时,我认为使用多于一个断言是可接受的。这个观点在Robert C. Martin的教学中也被强烈推荐。

但我们的第二个测试怎么样?它足够了吗?我认为没有。

这两个调用有点困扰我。在我们的方法中它们是没有明确解释的具体实现。为什么不把它们抽出到私有方法中呢?

这样好多了,并且它也带给我们另一个我们忽视的概念。在两个测试中,我们以一种或者另一种方式表达“足够玩家”的概念。但多少为足够?是2个吗?是的,目前是的。但如果Game的逻辑至少需要三个玩家,我们还想测试程序失败吗?我们不想发生这样的情况。我们可以为它引入一个公共静态类的域。

这将允许我们在测试中使用它。

我们的小辅助方法只用来增加玩家直到足够的玩家被加进来。我们甚至可以为第一个测试程序创建另一个这样的方法,所以我们增加几乎足够的玩家。

但这引入了一些代码副本。我们两个辅助方法非常相似。我们不能从它们中抽出第三个方法吗?

那样好多了,但它造成了另一个不同的问题。在这些方法中我们减少了代码副本,但是我们的$game对象向下传递了三层。管理它变得困难了。是时候在测试程序的setUp()方法中初始化并且复用它了。

更好了。所有不相关的代码都在私有方法中了,$game在setUp()方法中初始化,在测试方法中许多污染也被清除了。但是,我们需要在这里做个折中。在我们第一个测试中,我们开始于一个断言。这里假定setUp()总是创建一个空的game。对当前来说这没问题。但到一天结束时,你必须意识到没有完美代码这回事。只有你愿意忍受的折中代码。

第二个可测试的方法

如果我们从头到尾扫描我们的Game类,在我们列表上的下一个方法是add()。是的,是之前章节测试程序中的同一个方法。但我们可以测试它吗?

现在这是测试对象的不同方式。我们调用方法,然后我们验证对象的状态。正如add()始终返回true,我们没办法测试它的输出。但我们可以以一个空的Game对象开始,然后检查在我们增加了一个之后时候会有个单一用户。但这够验证了吗?

我们也去验证在调用add()之前没有玩家不是更好吗?好了,在这里可能要求有点太多了,但正如你看到的上面的代码,我们可以这么做。无论何时当你不确定初始状态的时,你应该在上面做个断言。对于未来代码修改可能改变你的对象的初始状态这件事上也能保护你。

但我们测了所有add()方法所能做的事吗?我说没有。除了增加用户,它也对其设置了许多设定。我们也要查看那些代码。

这好多了。我们验证了add()方法的每一个动作。现在,我更愿意直接测试$players数组。为什么?我们本可以使用基本做了相同事的howManyPlayers()方法,对吗?好吧,在这种情况下我们认为,描述add()方法对于对象状态影响的断言更重要。如果我们需要修改add(),我们可以预料测试其严格行为的测试将会失败。关于这点,我已经和我在Syneto的同事们有了无休止的争论。特别是因为这种类型的测试在测试程序与add()方法如何真正实现之间造成了强耦合。所以,如果你更愿意以另一种方式测试它,那并不表明你的主意是错误的。

我们可以安全地忽略输出的测试,echoln()那行。它们只是在屏幕上输出内容。我们还不想碰这些方法。我们的金牌大师完全依赖这个输出。

 

重构测试程序(再次)

我们有另一个以全新的通过测试的已测方法。是时候重构两个测试程序了,只做一点。让我们开始测试程序。难道最后三行断言不有点混乱?它们似乎不和严格的增加玩家相关。让我们修改它:

这样好多了。现在这个方法更抽象,可复用,有表达力的命名,并且隐藏了所有不重要的细节。

 

重构add()方法

对于我们的产品代码我们也可以做相似的事情。

我们把不重要的细节抽到setDefaultPlayerParametersFor()方法中。

事实上在我写了测试程序之后才想到这个主意。这是另一个好例子,关于测试程序是如何迫使我们以不同的角度思考代码的。我们必须利用针对问题的不同角度,并且让测试程序指导产品代码的设计。

 

第三个可测试的方法

让我们找找第三个可测试的候选者。howManyPlayers()太简单并且已经非直接地测试过了。roll()太复杂而不能直接测试。而且它返回null。askQuestions()第一眼看起来令人感兴趣,但它只有表示而没有返回值。

currentCategory()是可测试的,但它很难测试。它是一个有十个条件的巨大的选择器。我们需要一个十行之多的测试程序,然后我们需要认真地重构这个方法和最确定的测试方法。我们应该对这个方法做个标记,在我们完成了更简单的方法测试之后再回来。对我们来说,这将在我们下篇教程中出现。

wasCorrectlyAnswered()又是个复杂的方法。我们需要从中抽取,小段代码是可测试的。但是,wrongAnswer()看起来挺有希望。它在屏幕输出东西,但也改变我们对象的状态。让我们看看能不能控制并测试它。

哎呀,这个测试方法很难写啊。wrongAnswer()依赖$this->currentPlayer作为其基本逻辑,但它也用$this->players在其表示部分。为什么你不应该混淆逻辑与表示的一个丑陋的例子。我们会在未来的教程中处理这种情况。现在,我们测试用户进禁区的情况。我们也必须注意到方法中有个if()表达式。这是我们还没测试的条件,因为我们只有一个玩家,因此我们不满足条件。但我们可以测试$currentPlayer的最后值。但在测试中增加这行代码将使其失败。

对私有方法shouldResetCurrentPlayer()更仔细的查看揭示了一个问题。如果当前玩家的索引和玩家数相同的话,它将被重置为0。啊啊!我们实际上进入if()了!

不错。我们创建了第二个测试,去测试仍有没玩的玩家这种具体的情况。对第二个测试来说,我们不关心inPenaltyBox的状态。我们只对当前玩家的索引感兴趣。

 

最后一个可测试的方法

最后一个我们可测试并重构的方法是didPlayerWin()。

我们可以立即发现它的代码结构和我们第一个测试的isPlayable()方法很相似。我们的解决方案也应该有些相似。当你的代码如此之短,只有两到三行,做不止一个小小的步骤不会是一个大的风险。在最糟糕的情况下,你回复三行代码。那么让我们做一步修改。

但等一下!它失败了。这怎么可能?它不应该通过吗?我们提供了硬币的正确的数字。如果我们研究一下方法,我们将发现一点误导性的事实。

返回值是否定的。所以该方法不是告诉我们玩家是否赢了,而是告诉玩家是否没赢游戏。我们可以进入并找到这个方法被调用的地方,将它的值否定。然后在这里改变它的行为,不要错误得否定答案。但它在我们还没做单元测试的wasCorrectlyAnswered()方法中使用。也许暂时,简单的重命名来突显正确的功能就足够了。

 

思考与总结

那么这个教程差不多了。既然我们不喜欢在名字上进行否定,在这点上我们做个妥协。这个名字当我们开始其他部分代码的时候必然改变。此外,如果你看下我们的测试程序,它们现在看起来奇怪:

通过在否定方法上测试false,执行返回true结果的方法,我们给代码的可读性带来了许多困惑。但目前为止这是不错的,正如我们需要在某些点停住,对吧?

在我们的下一篇教程中,我们将着手处理Game类当中更困难的方法。感谢你的阅读。

收藏 评论

关于作者:EluQ

南京土著,程序员。(新浪微博:@EluQ) 个人主页 · 我的文章 · 11

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部