目前为止,我们已经在Point类和Vector类上实现了一些方法,且已经在控制台中手动测试了其中一些方法。但现在我们面临一些大问题:如何说服其他人相信我们的代码能够正常工作?我们怎么才能确定所写的东西一直有效呢?我们如何才能确保在我们修改现有的代码或添加新代码时不会破坏任何东西呢?
通常情况下,你需要找到很久以前编写的一些代码来修复错误。当你想修改代码时,问题就来了,你不知道这些变更是否会破坏其他正常运行的代码。事实上,你可能不知道所有代码的功能,所以你最终改变了一些你不应该更改的东西,从而破坏了其他代码。这种现象经常发生,因而有自己的名称:倒退(regression)。
在控制台中手工测试代码是非常累而无聊的,因而你可能不会测试所有需要测试的代码。此外,这个过程不可重复:你会忘记为每个方法执行了哪些测试,或者如果其他人需要运行代码,他们必须自己确定要测试什么以及如何测试。但是,我们确实需要确保我们的更改不会破坏其他代码。如果代码无法完成相应的工作,那么它是完全无用的。
如果有一个自动化的测试——只需要运行几毫秒,输出结果能够清楚地说明问题是否出现、何处出现以及原因,那我们的生活会轻松很多。这是单元测试(unit testing)的基本思想,这对任何专业的开发者来说都至关重要。你的代码如果没有附带良好的单元测试,就不能认为已经完成。这部分内容非常重要,因此我想尽早对其进行讲解,并广泛地使用。给代码编写自动的单元测试很简单,我想不出理由不去做它。
为代码创建单元测试很简单:创建一个新文件,在其中添加一个新类,然后创建一些对测试对象进行小范围测试的方法。每个测试方法都有一个assertion函数,以确保在给定一组输入后,获得特定的结果。若断言成功,则测试通过;反之,则通不过。当测试类被执行时(我们接下来会看到),对应的方法也被执行,断言被检验。
如果你还没理解,也不用担心,我们将在后面大量使用单元测试,你会慢慢理解的。
我们为Point类编写的第一个方法是distance_to,让我们从它开始尝试单元测试。在geom2d包中,创建一个名为point_test.py的新文件。整个项目的结构应如下所示:
在point_test.py中,输入清单4-28中的代码。
清单4-28 测试点距离计算的方法
我们首先导入Python附带的unittest模块。这个模块为我们提供了编写和执行单元测试所需的大多数基础代码。导入Point类之后,我们定义TestPoint类,它继承自unittest.TestCase❶。TestCase类定义的断言方法的集合非常好,当我们继承它时,我们可以在类中访问这些方法。
接下来定义test_distance_to方法❷。方法名以单词test_开头是很重要的,因为这是类识别测试方法的方式。你也可以在类中定义其他方法,但只要它们的名称不以test开头,它们就不会作为测试被执行。在方法内部,我们创建了相距5个单位的两个点,并断言它们的距离p.distance_to(q)接近这个值。
注意 :unittest模块的命名可能会令人困惑。类名是UnitTest,但实际上类中的方法才是测试。我们的类对UnitTest进行了扩展,是为了对相关测试进行分组。
断言方法assertAlmostEqual❸(定义在我们引用的类unittest.TestCase中)用指定的公差来检查浮点数是否相等,公差用小数点后的位数表示,默认是7。在上面的测试中,我们使用了默认值(因为我们没有提供其他值)。请记住,在比较浮点数时,必须有公差,或者像上述例子,给定小数点后的位数(参见4.1节)。
有几种运行测试的方法。让我们学习下如何从PyCharm和控制台中做到这一点。
如果在PyCharm中查看测试文件,你会看到在类和方法定义的左边有一个小的绿色的运行按钮。类旁边的按钮会执行类中所有的测试(目前只有一个),而方法旁边的按钮只会运行一个测试。单击一个类旁边的按钮,从弹出的菜单中选择Run‘Unittest for point'。运行窗格会出现在IDE的下方,并显示测试结果。如果操作完全正确,你应该会看到以下内容:
现在让我们学习如何从控制台运行同样的测试。
PyCharm之外的其他IDE可能有自己运行测试的方式。但是,不管用什么IDE,你总是可以从控制台运行测试。打开控制台或shell,确认在Mechanics项目的目录中。然后执行以下命令:
你应该会看到以下结果:
我们将在IDE中运行书中的大部分测试,但如果你愿意,随时可以从控制台运行它们。
让我们看看,如果断言检测到一个错误的结果会发生什么。在point_test.py文件中,修改距离的期望值:
断言预计点(1,2)和点(4,6)相距567个单位,这显然完全错误。单击类旁边的绿色运行按钮,再次执行该测试。你应该看到如下结果:
最有价值的信息是最后一行。它告诉我们有一个断言错误,也就是说,当它期望得到567,结果却得到5.0时,该断言失败了。它计算到小数点后7位,仍然发现两个值相差562。
断言错误出现之前的信息是回溯(traceback),也就是Python执行时的路径,直到出现错误。如信息所言,最接近失败的调用出现在清单后面。可以看到,测试执行失败出现在point_test.py文件中(毫不奇怪)的第14行(你的可能有所不同),在一个名为test_distance_to的测试中。当你修改代码并运行测试以发现测试是否失败时,这些信息将被证明超级有用,因为它会告诉你具体而准确的失败信息。
不要忘记把单元测试的代码改回到最初的形式,并确保它仍然能成功运行。
为了确保+和-操作对向量正常工作(对Point类的相同操作的代码留给你作为练习),让我们使用以下测试用例:
和
在软件包geom2d中创建一个新文件,用于测试Vector类。将其命名为vector_test,并输入清单4-29中的代码。
清单4-29 对加减运算的测试
使用类定义左侧的绿色运行按钮运行所有测试。如果你的操作没有问题,那这两个新测试应该会成功。我们的操作符代码完全正确。如果代码中存在错误,这些测试会指出具体位置和原因。
值得注意的是,这次我们使用的是断言方法assertEqual,它会使用==操作符来比较这两个参数。如果我们在Vector类中没有重载这个操作符,即使结果是正确的,测试也会失败。尝试一下:在Vector类中注释掉__eq__(self,other)方法(在该行的开头添加#字符),然后重新运行测试。
你会得到两个测试的失败信息,如下:
感到熟悉吗?类中的两个对象只有在位于相同的内存地址时,Python才会认为它们相等。__eq__操作符则向Python解释了确定两个对象相同的规则。别忘了取消这个方法的注释。
让我们使用在测试类中定义的两个向量,为点乘和叉乘添加两个新的测试用例:
和
代码如清单4-30所示。
清单4-30 测试向量的点乘和叉乘
运行所有测试以确保新测试也成功。注意,这次比较数字时,使用的是断言方法assertAlmostEqual。
接下来,我们将测试is_parallel_to和is_perpendicular_to方法。因为我们检查的是布尔表达式,所以我们希望有两个测试,一个检验两个向量是平行的(正向测试),另一个检验它们不平行(反向测试)。对于正向测试的情况,我们将基于这样一个事实,即向量总是平行于它自身。在TestVector中输入清单4-31中的代码。
清单4-31 测试向量的平行性
这个清单中有两个新的断言方法很有趣:assertTrue,它检验给定表达式的计算结果是否为True;assertFalse,它检验给定表达式的计算结果是否为False。
我们按照同样的模式来检查垂直性。在上面两个测试之后,输入清单4-32中的两个测试代码。
清单4-32 测试向量的垂直性
运行TestVector类中的所有测试,以确保它们成功。恭喜!你已经完成了第一部分的单元测试。这些测试将确保几何类中的方法按预期工作。此外,如果你为以上测试过的方法找到了更好的代码,为确保其能按预期工作,请同样执行测试。测试还可以记录代码的预期结果。有时候你可能需要一个提醒,关于某段代码的功能,这时单元测试也会有所帮助。
写出优秀的测试并不简单。熟能生巧,但有一些原则,我们可以遵循。让我们来看看三个简单的规则,它们将使我们的测试更有弹性。
我们已经涵盖了Point类和Vector类的部分方法的测试。现在你已经有了所需的知识,请尝试测试Point类和Vector类中的所有方法。我将把这个留给你作为练习。如果需要帮助,你可以看看本书提供的代码:它包括很多单元测试。找出所有我们没有测试的方法,并编写你认为需要的测试,以确保它们正常工作。我鼓励你去尝试,但是如果你仍然觉得单元测试对你来说很陌生,不要担心,我们将在其他章节中继续编写单元测试。
如前所述,我认为编写单元测试是编程的一个组成部分,运行未进行过单元测试的软件是很糟糕的。此外,为开放的源代码社区编写代码也需要进行良好的单元测试。你必须给社区一个理由去相信你所做的确实有效。通过所有人都可以轻松运行和发现的自动测试来证明,这总是一个好方法,因为他们不太可能花时间去考虑如何测试你的代码,然后在控制台手动尝试。
练习越多,就越能更好地编写可靠的单元测试。现在,我想给你一些基本规则。不要期望现在能完全理解它们的意思,记得在后面的学习当中要不时地回顾这些规则。
单元测试应该有且仅有一个失败的原因。这听起来很简单,但在多数情况下,测试对象(test subject)(你要测试的内容)是复杂的,并且由几个组件一起工作组成。
如果测试失败只有一个原因,那么很容易找到代码中的错误。想象一下相反的情况:如果一个测试失败可能有五个不同的原因。当测试失败时,你会发现自己花费太多时间去阅读错误消息和调试代码,试图理解每次失败的原因。
一些开发人员和测试专业人员(测试本身就是一个职业,我花了几年时间从事这份工作)声明一个测试应该只有一个断言。说实话,有时不止一个的断言并没有那么有害,但如果只有一个,那肯定好得多。
让我们来分析一个特殊的例子。用我们之前写的检查两个向量是否垂直的测试。如果代码不是如下这样:
而是这样:
那么测试可能会因为is_perpendicular_to方法中的错误或perpendicular代码(我们用它来计算垂直于 的向量)中的错误而失败。发现区别了吗?
我们使用背景这个词来指代运行测试的环境。环境包括围绕测试相关的所有数据和测试对象本身的状态,这些都可能影响测试结果。此规则规定,你应该完全控制测试运行的环境。
测试的输入和输出应该是已知的。发生在测试中的一切都应该是确定的(deterministic),也就是说,不应该出现随机性或依赖任何你无法控制的东西:日期或时间、操作系统、未在测试中设置的机器环境变量,等等。
如果测试似乎随机失败,那么它们是无用的,你应该摆脱它们。人们可以快速习惯随机失败的测试,并开始忽略它们。当他们也忽略了由于代码错误而失败的测试时,问题就出现了。
测试不应依赖于其他测试。每个测试都应该独立运行,绝不能依赖于其他测试所设置的背景。
这至少有三个原因。首先,你需要独立地运行或调试测试。其次,许多测试框架并不能保证测试的执行顺序。最后,不依赖于其他测试的测试要易读得多。
让我们用清单4-33中的TestSwitch类来说明这一点。
清单4-33 取决于其他测试的测试
看到test_switch_off是如何依赖于test_switch_on了吗?通过使用一种名为toggle的方法,如果测试以不同顺序运行,并且switch在测试运行时处于off状态,那么我们可能会得到错误的结果。
永远不要依赖于测试的执行顺序,这会带来麻烦。测试应该始终独立运行:无论执行的顺序如何,它们都应该以相同的方式工作。