单元测试的信任
在这个部分,我将略述出一些最通用的信任,这些信任来自于在使用大量单元测试获得的好处和解释为什么这些信任通常不是必须真实的。然后我们会帮助您在您的工程中拥有这些信任。
更加简单的跟踪Bug 当然这并不是必须的,那么您怎么知道您的测试是正确的? 是否存在在一些测试环节测试失败的情况?另外您又如何知道您的测试覆盖了系统中多少的代码量?是否测试到了程序中的错误,错误又在哪里等等的问题。
当你在你的单元测试中发现了bug后又会发生什么事情哪?你会突然间得到很多与愿意错误的反馈,bug被发现,但是问题并不在你测试的代码中。你的测试的逻辑存在一个bug,因此测试失败了。这些bug也是您最难被检查出来的,因为您通常会去检查您的应用程序而不会去检测你的测试环节。在这部分中,我会展示给你如何确认大量的单元测试,事实上就是使得跟踪bug变得更加容易。
代码更加便于维护 从最终点考虑,你可以倾向于认为这些信任并不是必须的,当然你是对的,让我们去说,代码中每个逻辑方法至少要有一个测试方法(当然,你可能拥有一个以上的方法)在一个好的测试覆盖的工程中,大概有百分之六十的代码是能够得到单元测试的,现在不得不考虑到测试也是要被维护的,如果针对一个复杂的逻辑方法你有20个测试,那么当你向这个方法添加一个参数时会发生什么事情哪?测试无法编译。当你修改了类的结构的时候同样会发生这样的事情。这时你突然发现为了能让你的应用程序继续工作你自己需要改变大量的测试。当然这会花费你大量的时间。
为了使这个信任确认下来,你需要确认你的测试是便于维护的。保持DRY规则写入:不要重复你自己。我们将更加接近的来看这个问题。
代码更加容易被理解 单元测试的好处通常并非是人们最初所期待的,在一个工程中考虑修改一些你之前从没有看过的代码(比方说,一个特殊的类或者方法).你将如何动手处理这些代码?你可能需要在项目中去浏览这些特定的类或者方法使用的代码,理所当然,单元测试就是这样例子的一个很好的场所。同时,当正确写入的时候,单元测试可以为工程提供一个API文件的容易读取的设置,使得文档的处理和代码的理解对于整个团队中的新老开发者一样的简单,便捷。然而,这些只能在测试是易读的和容易理解的情况下才能被确认,这个规则很多的单元测试开发者并不会遵循。我将详述这个信任,然后在这篇文章的易读测试的部分给你展现如何在去写易读的单元测试。
测试正确的事情
新来者在Test Driven Development (TDD)中一个最通常的错误就是他们通常会搞混"Fail by testing something illogical."中的"Fail first"要求。例如,你可以用下面的规格开始这个方法:
' returns the sum of the two numbers
Function Sum(ByVal a As Integer, ByVal b As Integer) As Integer
你可以向如下的方式写一个失败测试
<TestMethod()> _
Public Sub Sum_AddsOneAndTwo()
Dim result As Integer = Sum(1, 2)
Assert.AreEqual(4, result, "bad sum");
End Sub
初看上去这个处理像是一个写失败测试的好的方法,它完全错失了你写错误测试的初始点。
一个失败测试验证了在代码中存在一些错误,当你的测试完成后这个测试应该是通过的,现在的例子中,无论如何,测试都将会失败,即使是代码完成,因为测试逻辑上不是正确的。如果希望测试通过测需要测试自身进行修改――而不是程序的代码的改变(当程序代码改变的时候,是test-first规划的意图)简短来说,这个测试不会反映出程序代码完成后的最终的结果,因此这个不是一个好的测试。
TDD中一个好的测试要求你去修改代码,从而使它能够按照想要的方式工作,这一点要胜于强迫你去反映现在的真实情况或者一个非逻辑要求的渴望的结果。例如,当1+1返回0时就意味着测试失败。这个简单的例子和这种情况是相似的,在练习中,如果现在的需求是在工作的,测试应该可以反映你所期待的结果,然后你可以调整现在代码的情况去通过这个测试。
作为一个规则,一个已经调通的测试不应该被移除掉,因为这个测试在维护工作中可以用于恢复测试。他们在你改变代码时用来确定你没有损害到现在已经工作的函数。这就是为什么你不应该修改那些已经通过的测试,除非是一些很小的修改,例如增加它的可读性(换句话说,分解测试)
当一个测试非正常失败 有时你可能遇到失败的测试,而这时你对代码的改变是完全合理的。这通常是因为你遇到了冲突的需求。一般来说,可能是一个新的需求(一个改变的特性)与一个旧的可能已经不再有效的需求发生了冲突。这有两种可能:
1.在旧的需求或者无效或者在别处测试的情况下删除被验证本质上不再有效的失败的测试
2.改变旧的测试使你可以测试新的要求(本质上使用新的测试),然后在新的设置下(测试的逻辑状态相同,但是初始功能函数可能有所不同)测试旧的需求。
而有时候一个测试在使用不完整的技术去完成任务的时候也是有效的,例如,你有一个成员类带有一个FOO方法,它表现为某几种行为,它已经经由Test在X年前测试完成,然后现在一些其他的需求加了进来,方法的逻辑增强了,从而可以去处理一些类似于在获取数据时丢失一些参数的异常处理。但这时,突然Test X失败了,虽然在测试这个函数的时候只是使用了同样的类。这个测试的失败是因为在调用方法之前丢失了一些初始处理步骤。
这并不意味着你需要移除Test X,你将丢失对于一些重要功能的测试,这时你应该去关心那些初始化时的问题,而不是改变类的创建以用来适应你新的意图。
当然如果你那里有200个测试都是因为旧的结构导致的失败,你就应该找到这个问题来维护你的测试。这就是为什么你应该总是移除你测试中的副本尤其是在生产代码中。
测试覆盖和测试Angles 你如何知道是否你的新代码是一个好的覆盖?当试图移动一个链接或者一个约束检查后,如果所有的测试依然通过,那么你就没有足够的代码复制然后你可能需要添加其他的测试单元。
确认你添加正确测试的最好方法就是测试一些最平常的行和检查直到用非常的手段使它出错。这个也许很难,但是如果你不能考虑出一个让代码出错的方法,你就可能没有好的理由在最初的地方写下这行代码。
你不知道什么时候下一个开发者会试图运行你的程序,他可能优化或者错误的删除一些包含本质的行。如果你没有一个测试,它就会失败,其他的开发者可能不会知道他们犯了错误。
你也可能试图利用一些常量去替代一些已经通过了的测试中调用的各种各样的参数,例如,看下面的方法:
Public Function Sum(ByVal x As Integer, ByVal y As Integer, _
ByVal allowNegatives As Boolean) As Integer
If Not allowNegatives Then Throw New Exception()
Return x + y
End Function
你可以打乱代码去测试覆盖,这有一些关于如何测试的变化:
' Try this...
If Not True Then ' replace flag with const
If x < 0 OrElse y < 0 Then Throw New Exception()
End If
' Or this...
If Not allowNegatives Then
' replace check with const
If False OrElse y < 0 Then Throw New Exception()
End If
如果所有的测试依然通过,那么你缺少了一个测试,另外一个红色标志是在你为多种相同值测试的检查。如下:
Assert.AreEqual(3, retval)
一些方法的关系只看一次(在一个测试中)意味着你可以安全的返回3作为一个值,然后所有的针对这个方法的测试都将通过,这个当然意味着你丢失了一个测试。如果你在单元测试中检查一下代码,它就很容易被检查出来。
确保你的测试写的越简单越好,一个单元测试一般不包括一个if switch或者其他任何的逻辑声明。如果你发现你自己在你的测试中写了一些类似于逻辑声明的东西,这是一个好的机会来测试一个以上的事件,在做这样的操作的时候,你会使得你的测试比读和维护更加的困难,在生产代码中同样如此。保持你的测试简单,你在生产代码中发现bug要胜于在你的单元测试中。
使测试易于运行 如果你的测试并不容易运行,那么人们不会信任它。你的应用程序最有可能有下面两种类型的测试:
测试在没有任何配置的情况下平稳的运行(这种类型的测试,我们可以在任何的机器上,在代码的最终版上或者在源控制上测试,并且做到没有任何故障的测试)
在运行前需要一些配置.
第一种类型是你应该模仿的,第二种类型是你通常做的,尤其你如果你是一个新的单元测试。如果你发现你自己测试时有很多的特殊的需求,现在是正常的,但是重要的一点就是你要隔离出两个组让他们能够单独的去做测试。
我们的想法是任意一个开发者都应该有能力修改和运行一些不需要设置特殊的配置的测试进行测试。如果这有一些测试需要在运行前有特殊的关注,开发者应该知道他们,然后他可以花一些时间学习这些测试的方法。因为很多的开发者比较的懒(当然,不是你),你可以设想,他们不会去做那些特殊的设置,相反,他们会让测试失败因为他们有更好的事情去做。
当用户让测试失败时,他们开始考虑他们不能够信任这些测试了。很难说是否测试可以在一个中找到一个正式的bug或者只是一个错误的定位。开发者可能不明白为什么测试者会在一开始就执行失败。一旦他们不再信任你的测试,开发者将会停止运行它们,那么bug就会驻留在程序中,之后一连串的麻烦就来了。。。
为了避免这件事情,确认你总是有一个组准备好了去测试,测试程序则是可以安全运行,可信任的。把那些属于配置挑战组的测试放到不同的文件夹,树或者工程中,同时标记特殊的说明指明他们在运行前需要做什么。完成这些后,开发者可以不投入时间去配置就开始测试工作。当他们有时间和需要时,他们也可以配置,运行更多的测试环节。
创建维护测试
我们应该试着避免测试私有或保护成员。这篇文章也许能够帮助一些人解决一部分问题,但是我很坚决相信百分之九十九的时间,你可以全面的测试一个类,通过编写一些与它的独立公共接口相反的单元测试。测试私有成员可以使你的测试更加脆弱,如果这个需要被测试的类的一些内在方面略有改动的话。你应该使用通过调用一些代码里别处的公共功能这一方法去测试私有功能。当你依然能够确定全部功能并没有改变的时候,仅仅测试公共成员会导致测试遭受常量代码的因式分解以及内部的执行情况改变。
在可能的时候,应该重新使用你的创造物,处理过程,和声明代码。不要在一个单元测试中直接的创建类的实例。如果你在任何并不包含在此单元测试框架中的类前面看到这个单词“new”,你应该考虑一下将你创造的代码放在一个特殊的整体方法之中,它可以为你创建一个对象实例。你可以到时再重新使用这个方法来获得你的测试在其他测试之中的最新实例。这样可以帮助你来保持这个测试维护所需的时间,然后在测试进行的时候,从对代码无法预料的改变之中保护你的测试。作为一个例子,Figure 1展示了一对简单的测试,它使用了一个Calc类。
假设你有20,或者你甚至有100,与Calc类做相反测试,所有这些看起来令人吃惊的相似。现在一个计划的改变迫使你不得不删除默认的Calc构造器并且使用一个含有一些参数的不同的构造器。马上,你所有的测试就被暂停了。你可能可以很轻易的发现问题的关键并修复它,但你也可能做不到。最主要的问题是你将会浪费很多宝贵时间在修理你的测试上面。如果你在你的测试类之中使用一个整体的方法去创建Calc 实例,就像Figure 2所显示的那样,这些就并不是个问题。
我已经对测试做了一些改变已使它们能够具有更多可维护性。首先,我将新创建的代码迁移至可以再度使用的整体方法之中。这就意味着我只需仅仅改变一个简单的方法以使得在这个测试类中的所有测试在一个新的构造器中的能够正常的工作。另外一个为创造问题而设的简单解决方法是把创作物迁移到测试类的<TestInitialize()>方法之中。不幸运的是,这个能够很好的工作仅仅在你重新使用一个对象并在一些测试中把它当作一个局部类变量。如果你仅仅为一些测试使用它(部分相关成员),你倒不如在测试中将它们实例化,并且使它们更具易读性。
顺便一提的是,请注意,我已经将方法命名为Factory_CreateDefaultCalc 。我很喜欢将我测试中的任何帮助方法用特殊的前缀来命名,这样我就能很轻易的掌握它是做什么用的。这样对易读性也是非常有帮助的。
我的第二个改变是重新使用测试中的声明代码,并将这段代码迁移到一个确认方法之中。所谓确认方法是你测试中的一个可再度使用的方法, 这个方法包含了一个声明语句但是它可以接受不同输入和在输入的基础上进行校验。当你在不同输入或者不同的初始状态下一次又一次的声明同一事物时,你可以使用确认方法。这一方法的优点是既使在一个不同的方法里面声明,如果这个声明失败了你将可以继续保有一个异常处理,而且原始调用测试将会显示在测试失败输出窗口之中。
我也在Calc 中传递实例而不是使用一个局部变量,因此我知道我经常传递一个实例,而且这个实例是调用测试将其初始化的。当你想要改变对象状态时你可能想要做同样的事情,举个例子来说,当在测试下或者在将会传递给测试的对象下配置特殊对象时,可以使用特殊的Configure_XX方法。这些方法应该能够解释他们配置一个对象将会用来做什么用。Figure 3之中的代码就是以上方法的实例。
这个测试拥有很多设置代码可以用来处理向注册管理器对象中添加初始状态,它是这个测试类之中的成员。在此的确也有一些重复。Figure 4显示了在初始代码之外这些事例在因式分解之后将会如何变化。
修订测试具有非常高的可读性和稳定性。仅仅需要注意的是不要那么的refactor你的测试,他们可能会以一个单一的,不可读的代码行作为结束。应该注意的是我在这里可能依然使用一个Verify_XX 方法,但是这并不是我真正要在这里加以说明的。
消除测试之间的依赖关系
一个测试应该能够自我独立。它不应该与其他测试相关联,也不应该依赖任何具有特殊运行顺序的测试,它应该能够获得你所写的所有测试,可以随意运行所有测试或者只运行其中的一部分,并且是以任何顺序,而且要能够确保它们无论怎样都应该正确的运行。如果你不能够执行这个规则,你将会只在某种特殊的情况下按照预期的表现来运行的状况下结束你的测试。这样子的话,当你在最终期限下与此同时你还想确定你没有向系统之中引进新的问题的时候,当然就会出现问题。你可能很困惑而且考虑着是不是你的代码出现问题,这时,在事实上,问题其实仅仅是你的测试运行顺序所引起的。因此,你可能开始错过了一些在测试中失败的结果而且使它越写越少。这将会是个长期的过程。
如果你从一个测试调出至另一个测试之中,你应该在它们之间创建一个从属关系。你本质上说是在一个测试中测试两个事物(我将会在下一章中解释为什么这会成为一个问题)。就另一方面来说,如果你有测试B,它与测试A 所产生的状态是不相关的,那么你会陷入“顺序”陷阱之中。如果你或者其他人想要改变测试A,测试B将会暂停而且你不知它暂停的原因。对这些故障进行故障处理会浪费很多时间。
使用<TestInitialize()> 和<TestCleanup()>方法是本质上能够获得更好的测试隔离。确定你的测试数据时刻是最新的,而且测试下对象的也具有新的实例,而且所有的状态可以提前预知,而且无论你的测试在任何地方或者任何时间被运行,运行的情况都是相同的。
在一个单独单元测试中避免多重声明
我们将声明故障看作一个程序弊病的象征且声明被当作软件体的指示点或者“血液检查”。你可以找到越多的症状,程序弊病就越可以轻松的被诊断和排除掉。如果你在一个测试中定义了多重声明,只有第一个故障声明将会以抛出异常的方式显示出来。请参考下面插图之中的测试代码:
<TestMethod()> _
Public Sub Sum_AnyParamBiggerThan1000IsNotSummed()
Assert.AreEqual(3, Sum(1001, 1, 2)
Assert.AreEqual(3, Sum(1, 1001, 2) ' Assert fails
Assert.AreEqual(3, Sum(1, 2, 1001) ' This line never executes
End Sub
你可能没有发现以上代码之中其他可能的征兆。在一个故障之后,并发的声明不会被执行。这些不能生效的声明可能提供了有价值的数据(或者征兆)可能能够帮助你很快的集中的焦点而且发现潜在的问题。因此在一个独立的测试中运行多重声明增加了具有很少价值复杂性。另外,声明应该被独立的运行,我们应该设置自我独立的单元测试以使得你具有能够很好的发现错误的机会。
创建易读性测试
如果你以前写过单元测试,你是否在单元测试上写了一个好的声明行?可许不是这样的,大多数开发者并不厌烦去写一个好的声明因为他们更加关心去写测试。
假设你是团队中的一个新的开发者,你试图读一个单元测试。连接这个:
<TestMethod()> _
Public Sub TestCalcParseNegative()
Dim c As New Calc
Assert.AreEqual(1000, c.Parse("-1, -1000")
End Sub
作为一个简单的练习,如果你理解了上例中Calc分列方法的用法,你很可能可以进行很好的推测,但是他可以简单的作为人员数量的用例使得输出结果为1000:
在组中返回最大的负数作为一个正数。
如果数字是负数且返回值为剩下几个数的总和作为一个正数,那么忽略第一个数字。
返回相互作乘积运算而得的数字。
现在请参考下面在单元测试之中的小改动:
<TestMethod()> _
Public Sub Parse_NegativeFirstNum_ReturnsSumOfTheRestAsPositive()
Dim c As New Calc
Dim parsedSumResult As Integer = c.Parse("-1", "-1000")
Const SUM_WITH_IGNORED_FIRST_NUM As Integer = 1000
Assert.AreEqual(SUM_WITH_IGNORED_FIRST_NUM, parsedSumResult)
End Sub
这个是不是比较容易理解呢?当声明消息消失之后,表达意图最合适的地方就是测试的名字。 如果你广泛的使用了它,你将会发现你不再需要读测试代码就能明白代码测试的目的所在。事实上,你经常根本不需要写任何注释,因为代码,特别是那些带着实例的,他们自己是证明自己的。
名字包含了三部分内容: 测试下方法的名字(解析),测试下的状态或者规则(带着第一个负数传递一个字符串),以及预期的输出或者运行情况(剩余数字的总和以一个正数的形式返回)。需要注意的是我从名称中将Test以及Calc这两个词删除。我已经知道这是一个属性的测试因此在此没有重复此信息的必要。我也知道这是一个在Calc类中的测试因为测试类经常是写给一个特殊类的(这个类也许已经被命名为CalcTests)。
名字也许会很长,但是又有谁在乎呢?它读起来更像是一个标准英语的句子而且它使得一个新来的开发者更容易明白测试的内容。更是这样,当这个测试发生故障时,我们甚至不需要调试代码就可以知道问题究竟出在哪里。
需要注意的是,我已经在前面分别实际演示了通过在不同行中创建一个结果变量的方法从声明操作中进行分解操作。这样做至少有两个理由。第一个理由是,你可以为一个变量分配一个可读性强的名字,它可以包含结果,这样可以使你的声明行非常易于理解以及易于读。第二点是,测试下与对象相反的invocation 可能非常的长,它可能会使你的声明行延伸出屏幕的边缘之外,这样导致测试者向右滚屏。就我个人而言,我认为这个是非常恼人的。
我在我的测试中使用了很多常量以确保我的声明读起来像一本书。在先前的例子之中,你可以读到声明中说:“确保分解总数是与忽略第一个数后所得总和是相等的。” 为你的变量取一个很好的名字能够在某些程度上弥补对于测试的命名不足。
当然,有时一个声明 消息是在一个单元测试中传递intent的最好的方法。 一个好的声明消息始终能够解释什么因该会发生或者什么发生了而且为什么会出错。举个例子来说,“分列应该忽略掉第一个数字如果这个数字是个负数的话”,“分列不能够忽略掉第一个负数”,还有“X调用对象Y标记错误”这些都是有用的声明消息,它们很清晰的描述了结果的情况。
在你的设置方法中避免部分相关的代码
一个<TestInitialize()> 方法是样例成员变量在测试中使用的一个好地方。你所有的测试,只有在一部分的测试中避免变量。他们可以为测试设置本地变量。如果你创建了部分相关的实例作为类的成员,用来在测试中简单的避免创建的副本,你应该使用在文章前面解释的工厂方法,使用部分相关变量使得你的代码和设置方法缺少易读性。一旦变量在一个或者每个测试中使用,那么他应该是<TestInitialize()> 方法的一个成员和变量。
Figure 5 展现了一个拥有两个成员变量的类的测试。但是他们中的一个(cxNum)只被部分使用。Figure 6 展现了如何在测试中替换代码从而使它更加易读的方法。
总结
就像你所看到的,写单元测试并不是一个微不足道的任务,如果步骤正确,单元测试可以为开发者的生产力和代码的质量带来令人惊讶的提高,他可以帮助你去创建的应用程序含有更少的错误,同时也可以便于其他的开发者去洞察你的代码,但是他也需要在之前承担一个义务,确认遵循一些简单的规则。当方法并不是很好时,单元测试则可能达到一个相反的结果,从而浪费您的时间,并且使测试过程更加复杂
本文作者:网友 来源:网络收集
CIO之家 www.ciozj.com 微信公众号:imciow