Twisted 中的单元测试

每个单元测试都测试软件中的一个功能点。单元测试完全自动化,并且执行速度很快。整个系统的单元测试被收集到一个测试套件中,并且可以全部在一个批次中运行。单元测试的结果很简单:要么通过,要么不通过。所有这些意味着您可以随时测试整个系统,而不会造成不便,并且可以快速查看哪些测试通过,哪些测试失败。

Twisted 哲学中的单元测试

Twisted 开发团队坚持极限编程(XP)的实践,而单元测试的使用是 XP 实践的基石。单元测试是一种工具,可以让你更加自信。你改变了一个算法——你是否破坏了什么?运行单元测试。如果测试失败,你就会知道该去哪里寻找问题,因为每个测试只覆盖一小部分代码,并且你知道它与你刚刚做的更改有关。如果所有测试都通过,那么你就可以放心地继续工作,并且不需要怀疑自己,也不需要担心你是否不小心破坏了其他人的程序。

测试什么,不测试什么

你不需要为每个你编写的函数都编写一个测试,只需要为可能出现错误的生产函数编写测试。

– Kent Beck,极限编程解释

运行测试

如何

从 Twisted 源代码树的根目录运行 Trial

$ bin/trial twisted

你会发现,在你的 emacs 初始化文件中添加以下内容非常方便

(defun runtests () (interactive)
  (compile "python /somepath/Twisted/bin/trial /somepath/Twisted"))

(global-set-key [(alt t)] 'runtests)

何时

始终,始终,始终确保在提交任何代码之前所有测试都通过。如果其他人在一开始开发时就检出了代码,发现测试失败,他们会很不高兴,并且可能会决定追捕你

由于这是一个地理位置分散的团队,能够帮助你解决代码问题的人可能不在你的房间里。你可能想通过网络共享你的正在进行的工作,但你想将主 Git 树保持在良好的工作状态。所以使用分支,只有在你解决问题并且所有单元测试再次通过后才将你的更改合并回来。

添加测试

请不要在没有为它们添加测试的情况下向 Twisted 添加新模块。否则,我们可能会更改一些内容,从而破坏你的模块,并且直到以后才会发现,这使得很难确切地知道导致破坏的更改是什么,或者直到发布之后才会发现,而没有人希望在发布中出现错误的代码。

测试放在专门的测试包中,例如 twisted/test/twisted/conch/test/,并且命名为 test_foo.py,其中 foo 是正在测试的模块或包的名称。在下面的链接部分中可以找到有关使用 PyUnit 框架编写单元测试的详细文档。

与标准 PyUnit 文档的一个偏差:为了确保测试结果的任何差异都是由于代码或环境的差异造成的,而不是测试过程本身造成的,Twisted 附带了自己的兼容测试框架。这仅仅意味着当你导入 unittest 模块时,你将使用 from twisted.trial import unittest 而不是标准的 import unittest

只要你遵循了模块命名和放置约定,trial 就足够聪明,可以找到你编写的任何新测试。

PyUnit 提供了许多断言方法,可以在编写测试时使用。其中许多是冗余的。为了保持一致性,Twisted 单元测试应该使用 assert 形式而不是 fail 形式。此外,使用 assertEqualassertNotEqualassertAlmostEqual 而不是 assertEqualsassertNotEqualsassertAlmostEqualsassertTrue 也比 assert_ 更受欢迎。你可能会注意到,在 Twisted 代码库中并非所有地方都遵循此约定。如果你正在更改一些测试代码,并且注意到附近代码中使用了错误的方法,请随时进行调整。

当你添加单元测试时,确保所有方法都具有文档字符串,以高层次地指定测试的意图。也就是说,用户能够理解的描述。

测试实现指南

以下是一些在为 Twisted 测试套件编写测试时需要遵循的指南。许多测试是在这些指南之前编写的,因此没有遵循它们。如有疑问,请遵循此处给出的指南,而不是旧单元测试的示例。

命名测试类

在为 Twisted 测试套件编写测试时,测试类命名为 FooTests,其中 Foo 是正在测试的组件的名称。以下是一个示例

class SSHClientTests(unittest.TestCase):
    def test_sshClient(self):
        foo() # the actual test

真实 I/O

大多数单元测试应该避免执行真实的、平台实现的 I/O 操作。真实的 I/O 速度慢、不可靠且难以处理。

在实现协议时,可以使用twisted.internet.testing.StringTransport 而不是真实的 TCP 传输。 StringTransport 速度快、确定性强,并且可以轻松地用于测试所有可能的网络行为。

如果你需要将客户端与服务器配对,并让它们相互通信,请使用 twisted.test.iosim.connecttwisted.test.iosim.FakeTransport 传输。

实时

大多数单元测试也应该避免等待真实时间过去。构造并推进twisted.internet.task.Clock 的单元测试速度快且确定性强。

在设计代码时,允许在测试期间注入反应器。

from twisted.internet.task import Clock

def test_timeBasedFeature(self):
    """
    In this test a Clock scheduler is used.
    """
    clock = Clock()
    yourThing = SomeThing()
    yourThing._reactor = clock

    state = yourThing.getState()

    clock.advance(10)

    # Get state after 10 seconds.
    state = yourThing.getState()

测试数据

应尽可能避免将测试数据保存在源代码树中。

在某些情况下,这是不可避免的,但在明显可以避免的情况下,请这样做。测试数据可以在运行时生成,或者作为常量存储在测试模块中。

当需要访问文件系统时,在测试运行期间将数据转储到临时路径可以提供更多测试机会。在临时路径中,你可以控制各种路径属性或权限。

你应该设计你的代码,以便可以从任意输入流中读取数据。

即使测试是在安装的 Twisted 副本中运行,也应该能够运行。

publicRSA_openssh = ("ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEArzJx8OYOnJmzf4tf"
"vLi8DVPrJ3/c9k2I/Az64fxjHf9imyRJbixtQhlH9lfNjUIx+4LmrJH5QNRsFporcHDKOTwTT"
"h5KmRpslkYHRivcJSkbh/C+BR3utDS555mV comment")

def test_loadOpenSSHRSAPublic(self):
    """
    L{keys.Key.fromStrea} can load RSA keys serialized in OpenSSH format.
    """
    keys.Key.fromStream(StringIO(publicRSA_openssh))

全局反应器

由于单元测试避免了真实的 I/O 和真实时间,因此它们通常可以避免使用真实的反应器。唯一的例外是针对真实反应器实现的单元测试。针对协议实现或其他应用程序代码的单元测试不应该使用反应器。针对真实反应器实现的单元测试不应该使用全局反应器,而应该使用 twisted.internet.test.reactormixins.ReactorBuilder,这样它们就可以自动应用于所有反应器实现。在任何情况下,新的单元测试都不应该使用全局反应器。

跳过测试

Trial 是 Twisted 单元测试框架,它有一些扩展,旨在鼓励开发人员添加新的测试。一种常见的情况是,测试会测试一些可选的功能:也许它依赖于某些外部库的可用性,也许它只在某些操作系统上工作。重要的共同因素是,没有人认为这些限制是错误。

为了尽可能方便测试,某些情况下可能会跳过一些测试。单个测试用例可以抛出 SkipTest 异常来指示它们应该被跳过,并且测试的其余部分将不会运行。在摘要中(最后打印的内容,位于测试输出的底部),测试将被计为“跳过”,而不是“成功”或“失败”。这应该在查找必要先决条件的条件语句中使用。

class SSHClientTests(unittest.TestCase):
    def test_sshClient(self):
        if not ssh_path:
            raise unittest.SkipTest("cannot find ssh, nothing to test")
        foo() # do actual test after the SkipTest

您也可以在方法上设置 .skip 属性,并使用字符串来指示跳过测试的原因。这对于临时关闭测试用例很方便,但也可以根据条件设置(通过在定义类属性后操作它们)。

class SomeThingTests(unittest.TestCase):
    def test_thing(self):
        dotest()
    test_thing.skip = "disabled locally"
class MyTests(unittest.TestCase):
    def test_one(self):
        ...
    def test_thing(self):
        dotest()

if not haveThing:
    MyTests.test_thing.im_func.skip = "cannot test without Thing"
    # but test_one() will still run

最后,您可以通过在类上设置 .skip 属性来一次性关闭整个 TestCase。如果您根据测试依赖的功能来组织测试,这是一种方便的方法,可以仅禁用无法运行的测试。

class TCPTests(unittest.TestCase):
    ...
class SSLTests(unittest.TestCase):
    if not haveSSL:
        skip = "cannot test without SSL support"
    # but TCPTests will still run
    ...

测试新功能

“XP” 开发流程中产生的两个良好实践有时会相互冲突。

  • 单元测试是一件好事。优秀的开发人员在看到失败的单元测试时会感到恐惧。他们应该放下所有事情,直到测试修复为止。

  • 优秀的开发人员会先编写单元测试。测试完成后,他们会编写实现代码,直到单元测试通过。然后他们停止。

这两个目标有时会发生冲突。在任何实现完成之前编写的单元测试肯定会失败。我们希望开发人员经常提交他们的代码,以确保可靠性并改善多人协作解决同一问题的协调。在编写代码时,其他开发人员(那些没有参与新功能的开发人员)不应该关注新代码中的错误。我们不应该因为不完整的模块尚未开始通过其单元测试而“狼来了”地哭喊,从而削弱我们根深蒂固的“失败测试恐惧症”。这样做要么会教导模块作者在所有功能都正常工作后才编写或提交他们的单元测试,要么会教导其他开发人员忽略失败的测试用例。这两种情况都不好。

“.todo”旨在解决这个问题。当开发人员第一次开始为尚未实现的功能编写单元测试时,他们可以在预期会失败的测试方法上设置 .todo 属性。这些方法仍然会运行,但它们的失败不会被视为正常的失败:它们将进入“预期失败”类别。开发人员应该学会将此类别视为第二个优先级队列,排在实际测试失败之后。

随着开发人员实现该功能,测试最终将开始通过。这令人惊讶:毕竟所有这些测试都被标记为预期会失败。尽管如此,仍然通过的 .todo 测试将被放入“意外成功”类别。开发人员应该从这些测试中删除 .todo 标签。此时,它们将成为正常的测试,它们的失败再次成为整个开发团队立即采取行动的原因。

因此,测试的生命周期如下。

  1. 创建测试,标记为 .todo 。测试失败:“预期失败”。

  2. 编写代码,测试开始通过。“意外成功”。

  3. .todo 标签被移除。测试通过。“成功”。

  4. 代码被破坏,测试开始失败。“失败”。开发人员立即采取行动。

  5. 代码被修复,测试再次通过。“成功”。

.todo 在您开发功能时可能有用,但当您准备好提交任何内容时,您编写的所有测试都应该通过。换句话说,**永远**不要提交标记为 .todo 的主干测试。对于未完成的测试,您应该创建一个后续票证并将测试添加到票证的描述中。

您也可以忽略 .todo 标记,只需确保您先编写测试,以便在开始修复之前看到它们失败。

行覆盖率信息

Trial 提供行覆盖率信息,这对于确保旧代码具有良好的覆盖率非常有用。将 --coverage 选项传递给 Trial 将在名为 coverage 的文件中生成覆盖率信息,该文件可以在 _trial_temp 文件夹中找到。

将测试用例与源文件关联

请在您的新测试所覆盖的源文件中添加一个 test-case-name 标签。这是一个位于文件开头的注释,看起来像以下之一。

# -*- test-case-name: twisted.test.test_defer -*-

#!/usr/bin/env python
# -*- test-case-name: twisted.test.test_defer -*-

此格式被 emacs 理解为标记“文件变量”。目的是接受 test-case-name 在 emacs 接受文件的第一个或第二个行上的任何位置(但不在 emacs 接受的文件末尾的 File Variables: 块中)。如果您需要定义其他 emacs 文件变量,您可以将它们放在 File Variables: 块中,或者使用分号分隔的变量定义列表。

# -*- test-case-name: twisted.test.test_defer; fill-column: 75; -*-

如果代码由多个测试用例执行,则可以使用逗号分隔的测试列表来标记它们,如下所示:(注意:并非所有工具都支持此功能,但 trial --testmodule 支持)

# -*- test-case-name: twisted.test.test_defer,twisted.test.test_tcp -*-

test-case-name 标签将允许 trial --testmodule twisted/dir/myfile.py 确定需要运行哪些测试用例来执行 myfile.py 中的代码。几个工具(以及 https://launchpad.net/twisted-emacs’s twisted-dev.el 的 F9 命令)使用此功能来自动运行正确的测试。