重构遗留代码(4):第一个单元测试

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

重构一段完整的遗留代码的关键时机之一是,当我们开始抽出它的小片段,并且开始针对那些片段开始写的单元测试的时候。但这会相当难,特别是当你这么一段有缺失的代码时,是很难编译或者运行的。我们无法安全地在我们还不太了解代码上做大规模的调查,并且只有一个金牌大师测试程序让我们完全得打破这段代码。幸运的是,有些技术可以帮助我们。

 

什么是单元测试?

纵观自动化测试的历史,过去的二十年左右,单元测试这个术语通过许多方式定义。最初,它是关于测试中代码执行的范围的。单元测试时一个测试程序,用来测试特定编程语言可能的最小单元的。

在这个意义上,对于我们的PHP代码,单元测试是测试执行单个的功能或方法。当我们在面向对象编程风格下,其功能是组织在类中的。所有与单一类相关的测试通常称为测试用例。

还有大约25个其它的对于单元测试的定义,所以我们不每一个都看了。而这些定义是完全不同的,但它们都有两个共同点。这将使我们找到可能最公认的定义。

单元测试是毫秒级运行并且对代码块隔离测试的测试程序。

我们必须注意两个关键词的定义:毫秒级 – 我们的测试程序必须快速运行,非常快;隔离 – 我们必须尽可能地隔离测试代码。这两个关键词需同时满足,因为为了使测试程序更快我们必须缩小其范围。数据库,网络通信,用户界面,以这种方式测试的话,它们只是太慢了。我们需要找到并且隔离足够小的代码块,以便我们能够大约在毫秒级编译(如果需要)并且运行代码,换句话说,不到10毫秒,因为这真是百分之一秒。我们的测试代码将在代码的纯运行时间上增加一个轻微的开销,但它可忽略不计。

 

识别需要进行单元测试的代码

寻找隔离的方法

如果代码的结构允许,建议首先编写测试代码,无论我们实际能测试的程序是什么样。这将帮助我们开始建立覆盖率并且也会迫使我们专注并理解小块代码。记住,我们是在重构,我们不想改变代码行为。事实上,如果可能在这最初的阶段我们完全不想改变我们的生产代码。

我们需要分析三个文件,看看什么我们能测什么不能。

GameRunner.php已经基本没逻辑。我们创建它仅作为一个委托。我们可以测试它吗?当然可以。我们应该测试它吗?不,我们不应该。即使有些方法技术上来说可以被测试,但如果它们没有逻辑我们可能不希望测试它们。

RunnerFunctions.php是不同的情况。它有两个方法。run()是一个大方法,负责系统整体运行。这不是我们能容易测试的。并且它也没有返回值,它仅仅输出到屏幕,所以我们需要捕捉输出并且比较字符串。对于单元测试来说它不是很典型。另一方面,isCurrentAnswerCorrect()基于一些条件返回简单的true或false。我们可以测试它吗?

我们已经理解了这段代码产生一个随机数并且将它和wrongAnswerId进行比较。

步骤1 – 打开GoldenMasterTest.php并且标记所有的测试为跳过。我们暂时不想运行它们。当我们创建单元测试的时候,我们会更少地运行金牌大师程序。当我们写新的测试程序并且我们没有修改产品代码的时候,快速反馈是更重要的。

步骤2 – 在我们的Test目录下创建一个新的测试程序RunnerFuntionsTest.php,和GoldenMasterTest.php放在一起。现在考虑下你能够写出的尽简单的测试代码。让它最起码能运行起来的代码什么样?好吧,像这样:

我们引入RunnerFunctions.php文件,以便我们测试时它被引入而不产生错误。余下的代码是纯模板,只是一个类骨架和一个空的测试方法。但,现在做什么?我们接下来做什么?你知道我们怎样让rand()方法返回我们想要的?我还不知道。那么让我们现在来调查它是怎么工作的。

我们知道如何给随机生成器种子,那么我们试着用一些数字给它播种,会有效吗?我们可以在测试程序中写一些代码来弄明白它是如何工作的。

我们也知道我们的问题ID是介于0和9之间的。这会产生下面的输出。

好吧,那看起来并不明显。事实上,我看没什么逻辑,我们怎么决定rand()方法将产生的值。我们需要修改产品代码,以便能够插入一些我们需要的值。

 

依懒性和依赖注入

当许多人谈论“依赖性”的时候,他们考虑的是类之间的关联。这是最普遍的实例,特别是面向对象编程中。那么如果我们推广这个术语一点。忘掉类,忘掉对象,只专注于“依赖性”的含义。我们的rand(min,max)方法依赖于什么?它依赖两个值。一个min和一个max。

我们可以通过这两个参数来控制rand()方法吗?如果min和max相同,可以预见的rand()不将返回相同的数字吗?让我们来看看。

如果我们是对的,那么每一行将以预期的方式转储从0到4的数字。

对我来说这看起来是可预见的。通过给rand()方法参数min和max传递相同的数字,我们可以确定产生我们期待的数字。现在,对于我们的方法我们怎么做到这点呢?它没参数啊!

对依赖注入方法来说可能最普遍的做法是使用带默认值的参数。这将保留方法的当前功能,但当我们测试它的时候允许我们控制它的流程。

按这种方式修改isCurrentAnswerCorrect()方法将同时维持它目前的行为并允许我们测试它。现在你可以使你的金牌大师再次有效并运行它了。产品代码已经改变了,我们需要确保没破坏它。

至于isCurrentAnswerCorrect()现在看起来,测试它只是送入十个值给rand()方法可能返回的每一个数字。

该测试功能是基于我们的测试程序的每一行来构建的。既然我们的测试程序很快了,我们可以几乎连续运行它们。事实上有一旦文件改变就运行测试的工具,我已经听说有的连续运行他们的测试程序的开发者仅仅在每个命令的最后瞥一眼测试状态栏。至于你的程序,你知道会发生什么,如果测试结果没有变绿而你认为它应该的时候,你就做错了什么。它们的反馈显得那么紧凑,几乎可以肯定在他们写的最后一行或命令中的某些方面出错了。

虽然听起来有点极限测试驱动开发的意味,我想当你开发算法的时候是非常有用的。我个人喜欢通过点击快捷方式运行我的测试程序,一个快捷键。正如测试帮助开发我的程序,我运行测试程序的快捷方式是F1。

让我们回到我们的业务。该测试,有十个断言,运行了66毫秒,大概6.6毫秒一个断言。每个断言执行一段我们的代码。这看起来正如我们在这篇教程开始定义的单元测试。

你注意到对数字7使用的assertFlase()了吗?我打赌你们中的一半忽略了它。它深深埋在另一个断言的分支中。很难发现。我认为它值得拥有自己的测试程序,所以我们做一个明确的单一错误答案的案例。

 

重构测试程序

正如我们在重构的任务,使代码更优秀,更容易理解,我们一定不能忘记我们的测试程序。它们和我们的产品代码一样重要。我们也需要保持测试程序干净并易于理解。一旦我们发现测试程序有错,在测试通过时我们就需要重构测试程序并且我们应该这么做。这么做,产品代码就能验证我们的测试程序。如果测试程序变绿,我们重构它,当它变红时我们就打破测试。我们可以只撤销几步并再试一次。

我们可以抽取正确的答案数字到数组中并使用它生成正确的答案。

这会通过,但也会引入一些逻辑。也许我们可以将它抽到自定义的断言中。这对于如此简单的一个测试可能有点极端了,但这是个理解这种观念的好机会。

现在,它在两方面帮助我们。首先,我们将循环验证数组元素的逻辑放到一个私有方法中。正如我们通常将私有方法放在类最后,淡出视线,给公共方法中更高层次的逻辑让道,我们设法将测试程序抽象。在测试方法中我们不关心答案是如何验证正确性的。我们关心代表正确答案的ID。第二点得益是将实现从功能准备中分离出来。保持测试中的正确答案ID帮助我们从需要测试的前提中分离了实现的细节。

 

对产品代码依赖做测试

我们任何一个人会犯的最普遍的错误是当写测试程序时去重复产品代码。通常,对于一些值或者常量,这是一个既是代码重复又是隐藏依赖的案例。我们的案例中,依赖是基于代表错误答案的回答ID。

但如何证明这个依赖?起初它似乎仅仅是对一个单一值的简单重复。要回答你的困境问你自己这个问题:“如果我决定改变错误答案的ID,我的测试程序会失败吗?”。当然答案是不会。改变产品代码中一个简单的常量并不会影响其行为,或者逻辑。因此,测试程序应该不会失败。

这听起来棒极了!但怎么做到呢?好了,最简单的方式是将所需的变量公开为公共类变量,最好是静态的或常量。我们的案例中,因为我们没有类,我们只将其作为全局变量或常量。

首先修改RunnerFunctions.php文件以便isCurrentAnswerCorrect()方法使用常量而不用本地变量。然后运行你的单元测试。这确保我们对产品代码的改变不破坏任何东西。现在,是时候测试了。

修改testItCanFindWrongAnswer()方法使用相同的常量。因为文件RunnerFunctions.php在测试程序开始处引用了,声明的常量将能访问测试程序。

 

重构测试程序(再一次)

现在,我们依赖testItCanFindWrongAnswer()方法的WRONG_ANSWER_ID,难道我们不该重构测试程序以便testItCanFindCorrectAnswer()方法也依赖同样的常量吗?好吧我们应该这么做。这不仅会使我们的测试程序更易于理解,也将使它更健壮。是的,因为如果我们选择已经定义在测试程序中的正确答案列表中的错误答案ID,这样的案例会使测试失败但产品代码仍然是正确的。

当在测试方法中有正确答案的数字,这本身在某种程度上就是个好主意,当我们修改测试程序越来越多地依赖产品代码提供的值时,我们也希望隐藏关于数字的细节。第一步是提供一个提取方法的重构,将其放入它自己的方法中。

我们显著修改了getGoodAnswerIDs()方法。首先我们使用range()方法生成列表,取代手输所有可能的ID。然后,我们从包含WRONG_ANSWER_ID的数组中将其剔除。现在正确答案ID的列表仍然独立于错误答案ID设置的值。但这么做足够吗?minimum和maximum ID

怎么样?我们不能也将它们以相似的方式抽出吗?好吧,让我们看下。

这看起来很不错。常量仅仅作为了方法isCurrentAnswerCorrect()参数的默认值。它仍然允许我们在测试时插入需要的值,并且也使得那些变量的意义很明确。作为一个很好的副作用,文件开头的一小块常量开始高亮我们RunnerFunctions.php中的用到的关键值了。漂亮!

只是不要忘了重新使金牌大师测试程序testOutputMatchesGoldenMaster()方法启用。我们引入的常量仅仅在金牌大师测试程序中使用。我们的单元测试实际上始终引用这些值。

现在我们需要更新单元测试来使用这些常量了。

它很简单容易。我们仅需要修改range()方法的参数。

最后一步对于测试程序我们能做的,是去清理我们留在testItCanFindCorrectAnswer()方法后面的残局。

我们可以看到这段代码的两个主要问题。第一个是前后矛盾的命名。我们曾命名答案correct,然后我们命名其为good。我们必须在二者之间决定一个。Correct从语法上来看更合适。正如correct是wrong的反义词,而good的反义词是bad。

我们基于上面所述的原因给私有方法重命名了。但这还不够。我们需要解决另一个问题。我们定义了一个私有方法的返回值给一个变量只是在下一行就用了相同的变量。而这是这个变量唯一的使用的地方。我们的案例中,该变量保留是因为它提供了数字数组含义额外的声明。它有自己的用途和作用域。但既然我们有了一个几乎相同名字的方法,表达相同的意思,变量失去了它的效用。这就是一个没必要的分配。

我们可以看到内联变量重构移除了变量并且直接调用方法,取代了在下一行使用该变量。

现在,在这里真正酷的事是我们开始于仅仅两行并不清晰的代码,并且这个代码还被代码重复和隐藏依赖污染了。经过了几个步骤的变化之后,我们也以两行代码结束,但我们打破了数字型的ID数字的依懒性。这是不是很酷还是什么?

中断运行

我们与RunnerFunctions.php的事完了吗?好了,如果我看到if()那表明有逻辑处理。如果有逻辑处理意味着需要单元测试来验证它。我们在run()方法的do-while()循环中有一个if()。是时候用我们IDE的重构工具抽出一个方法并且测试它了。

但我们应该抽出那一块代码呢?乍看抽出条件表达式似乎是个好主意。这将得到如下代码。

这看起来很合适,它只是通过我们的IDE选择合适的菜单项目生成的,还有个问题困扰着我。对象aGame在do-while循环和抽出方法中都用到了。这样处理怎么样?

这种方案将aGame对象从循环中移除了。但它也带来了其他类型的问题。我们参数的数量增加了。现在我们需要传入$dice。而参数的绝对数量,2个,足够少而不至于引起任何问题,我们也必须思考下这些参数怎么在方法本身中使用。$dice只在roll()方法被调用给aGame赋值的时候使用。而roll()方法在Game类中有着重要的意义,它不是能决定我们是否胜出或者失败的方法。通过分析Game的代码,我们知道胜出者状态为真只能通过调用wasCorrectAnswered()方法。这很奇怪,并且它高亮了Game类中一些严重的命名问题,而这我们将在即将到来的一课中讨论。

基于以上所有观察,可能更好的是使用我们抽出方法的第一个版本。

我们可以想信IDE,仅通过查看代码我们可以相当肯定没有什么被破坏了。如果你感到不确定,只要运行你的金牌大师测试程序。现在让我们把重点放在为这个漂亮的方法创建一些测试程序吧。

我想出了这个名字,通过传送我想测试的到测试方法名中。根据测试程序应该测试的行为而不是它们会做什么来命名你的测试方法是很重要的。这将帮助从现在起的六个月之后的其他人或者你自己,去理解这一小段代码真正应该做什么。

但我们有个问题。我们的测试方法需要一个对象。我们需要像这样运行它:

我们需要一个Game类型的$aGame对象。但我们在做单元测试,并不想要使用真实的,复杂的和难于理解的Game类。这将把我们指向一个新篇章,我们将在另一个教程中谈到的测试:仿制,存根和作伪。这就是通过使用预定义方式中其他对象的行为来创建和测试对象的所有技术。而使用框架或甚至是PHPUnit内建的能力都会有所帮助,就我们目前的知识来说,对于我们的非常简单测试我们可以做许多人忘记了的事。

我们可以在测试文件中只创建一个和Game类相似的类,并且只定义两个我们感兴趣的方法。很简单。

这使得我们的测试程序通过,并且我们仍然在毫秒区。注意两个跳过的测试是金牌大师中的测试。

即使我们必须将我们的类命名得和Game不一样,因为我们不能两次声明同一个类,但代码是十分简单的。我们仅仅定义了两个我们感兴趣的方法。下一步就是真正返回些什么并且测试它。但这也许比我们期望的更困难些,因为下面这行代码:

我们的方法调用无参数的isCurrentAnswerCorrect()方法。这对我们来说不好。我们无法控制它的输出。它将只生产随机数。在我们能继续之前需要重构我们的代码。我们需要将对这个方法的调用放入循环,并且将它的结果作为参数传递给getNotWinner()方法。这将允许我们控制上面if表达式的输出结果,从而控制我们的代码向下执行的路径。对于我们的第一个测试,我们需要它进入if并调用wasCorrectlyAnswerd()方法。

现在我们有了控制,所有的依赖都被打破了。是时候测试了。

这是个通过测试,相当不错。当然我们从重载的方法返回了true。

我们也需要通过if()来测试另一条分支。

这次我们只测false的案例,所以我们更容易区分两种案例。

而我们队FakeGame做了相应的修改。

 

最后的清理

重构抽出的方法

我们快做完了。抱歉这篇教程这么长,我希望你喜欢它还有别睡着了。这是对RunnerFunctions.php文件和它的测试程序的总结前最后的变更了。

我们的方法中有一些非必须的变量,我们需要清理它。我们的单元测试将使得这次变更很安全。

我们使用了相同的内联变量重构,并导致了变量的消失。测试仍然是通过的,并且所有的单元测试一起仍然在100毫秒以下。我说这相当不错啊。

重构测试程序(再次,再次)

是的,是的,我们还可以让我们的测试程序更好一点。既然我们只有几行代码,我们的重构将是简单的。问题是下面这段代码。

我们在每个方法中重复调用了new FakeGame()。是时候抽出方法了。

现在,这使得$aGame变量十分没用了。是时候内联变量了。

这使得我们的代码更短同时也更具表达力。当我们读取一个断言时它读起来像散文。当我们通过提供的正确答案使用假类试图得到非胜出者时,断言我们得到true。我仍不喜欢的是我们使用了相同的变量并且依赖测试将其赋值为true或者false。我想还应该有更具表达力的方式改变它。

哇哦!我们的测试程序变成单行的了,对于我们测试的它们也很具有表达力。所有的细节都隐藏在私有方法中,在测试程序最后。99%的情况下你都不会关心它们的实现,而当你需要查看的时候,只要简单的按住CTRL的同时在方法名上点击一下,IDE就会跳转到实现了。

 

回到产品代码

如果我们看下循环,我们可以看到,有一个变量我们一眨眼的功夫就能够除掉。

那将变成这样:

再见了$notAWinner变量。但我们的方法名好可怕。我们知道我们总是喜欢积极的命名和行为,当需要有条件时使它无效。这个命名怎么样?

但用那个名字,我们需要在while()中使它无效,并且也要改变它的行为。我们开始修改测试程序。

但用那个名字,我们需要在while()中使它无效,并且也要改变它的行为。我们首先修改测试程序。

事实上只修改我们的假game类更好。用新的方法名使得测试程序真正可读。

使测试程序通过

当然现在程序是失败的。我们必须修改方法的实现。

 

修改金牌大师

单元测试通过了,但运行我们的金牌大师将失败。我们需要使while表达式中的登录无效。

 

完成了!

现在,这使得金牌大师再次通过,我们的do-while读起来也像写的不错的散文了。现在真的是停止的时候了。感谢你的阅读。

收藏 评论

关于作者:EluQ

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

相关文章

可能感兴趣的话题



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