Skip to content

Latest commit

 

History

History
999 lines (695 loc) · 63.7 KB

File metadata and controls

999 lines (695 loc) · 63.7 KB

十二、面向对象程序测试

熟练的 Python 程序员同意测试是软件开发最重要的方面之一。即使这一章放在书的末尾,也不是事后想的;到目前为止,我们所研究的一切都将有助于我们编写测试。我们将研究:

  • 单元测试和测试驱动开发的重要性
  • 标准unittest模块
  • py.test自动测试套件
  • mock模块
  • 代码覆盖率
  • 使用tox进行跨平台测试

为什么要测试?

一大批程序员已经知道测试他们的代码有多么重要。如果你是其中之一,请随意浏览本节。在下一节中,我们将看到如何在 Python 中进行测试,这将更加精彩。如果你不相信测试的重要性,我保证你的代码是错误的,你只是不知道而已。读下去!

有些人认为测试在 Python 代码中更重要,因为它是动态的;编译语言如 java 和 C++有时被认为是“安全的”,因为它们在编译时强制执行类型检查。然而,Python 测试很少检查类型。他们正在检查值。他们确保在正确的时间设置了正确的属性,或者序列具有正确的长度、顺序和值。这些更高层次的东西需要用任何语言进行测试。Python 程序员比其他语言的程序员测试更多的真正原因是,用 Python 进行测试非常容易!

但为什么要测试呢?我们真的需要测试吗?如果我们不测试呢?为了回答这些问题,从头开始编写一个 tic-tac-toe 游戏,根本不需要任何测试。在完成编写之前不要运行它,从开始到结束。如果你让两个玩家都成为人类玩家(没有人工智能),那么 Tic-tac-toe 的实现相当简单。你甚至不需要计算谁是赢家。现在运行你的程序。并修复所有错误。有多少人?我在我的 tic-tac-toe 实现中记录了八个,我不确定我是否都抓到了。是吗?

我们需要测试我们的代码,以确保它的工作。正如我们刚才所做的那样,运行程序并修复错误是一种粗糙的测试形式。Python 程序员能够编写几行代码并运行程序,以确保这些代码行符合他们的预期。但是更改几行代码可能会影响开发人员未意识到的部分程序,这些更改会影响程序,因此不会对其进行测试。此外,随着程序的增长,解释器可以通过代码的各种路径也在增长,很快就不可能手动测试所有这些路径。

为了处理这个问题,我们编写了自动化测试。这些程序通过其他程序或程序的一部分自动运行某些输入。我们可以在几秒钟内运行这些测试程序,并且覆盖了比一个程序员在每次更改某些内容时认为要测试的更多可能的输入情况。

编写测试有四个主要原因:

  • 确保代码按照开发人员认为应该的方式工作
  • 确保代码在进行更改时继续工作
  • 确保开发人员理解需求
  • 确保我们正在编写的代码具有可维护的接口

第一点并不能证明编写测试所需的时间是合理的;我们可以直接在交互式解释器中测试代码。但是,当我们必须多次执行相同的测试操作序列时,自动化这些步骤一次并在必要时运行它们所需的时间就更少了。无论是在初始开发版本还是维护版本中,只要我们更改代码,就运行测试是一个好主意。当我们有一套全面的自动化测试时,我们可以在代码更改后运行它们,并且知道我们没有无意中破坏任何已测试的内容。

最后两点更有趣。当我们为代码编写测试时,它帮助我们设计代码所采用的 API、接口或模式。因此,如果我们误解了需求,那么编写一个测试有助于突出这种误解。另一方面,如果我们不确定如何设计一个类,我们可以编写一个与该类交互的测试,这样我们就知道最自然的测试方法是什么。事实上,在编写正在测试的代码之前编写测试通常是有益的。

测试驱动开发

“先编写测试”是测试驱动开发的信条。测试驱动开发将“未测试的代码就是坏代码”的概念更进一步,并建议只有未编写的代码才应该未测试。在为该代码编写测试之前,不要编写任何代码。因此,第一步是编写一个测试来证明代码可以工作。显然,测试将失败,因为代码尚未编写。然后编写确保测试通过的代码。然后为下一段代码编写另一个测试。

测试驱动开发很有趣。它可以让我们制造一些小难题来解决。然后我们实现代码来解决这些难题。然后我们制作一个更复杂的谜题,我们编写代码来解决新的谜题,而不解决前一个谜题。

测试驱动方法有两个目标。第一个是确保真正编写测试。在我们编写代码之后,很容易会说:“嗯,它似乎起作用了。我不必为此编写任何测试。这只是一个小小的改变,任何东西都不可能破坏。”如果在我们编写代码之前已经编写了测试,我们将确切地知道它何时起作用(因为测试将通过),我们将知道,在未来,它是否会被我们或其他人所做的改变打破。

其次,编写测试首先迫使我们仔细考虑代码将如何交互。它告诉我们对象需要什么方法以及如何访问属性。它帮助我们将最初的问题分解为更小的、可测试的问题,然后将经过测试的解决方案重新组合为更大的、也经过测试的解决方案。因此,编写测试可以成为设计过程的一部分。通常,如果我们正在为一个新的对象编写测试,我们发现设计中的异常迫使我们考虑软件的新方面。

作为一个具体的例子,想象一下编写使用对象关系映射器在数据库中存储对象属性的代码。在这样的对象中使用自动分配的数据库 ID 是很常见的。我们的代码可能出于各种目的使用此 ID。如果我们正在为这样的代码编写测试,在编写之前,我们可能会意识到我们的设计是错误的,因为对象在保存到数据库之前没有这些 ID。如果我们想在测试中操作一个对象而不保存它,那么在我们编写基于错误前提的代码之前,它会突出这个问题。

测试使软件更好。在我们发布软件之前编写测试可以让最终用户在看到或购买有缺陷的版本之前做得更好(我为那些奉行“用户可以测试”理念的公司工作过。这不是一种健康的商业模式!)。在我们编写软件之前先编写测试,这会使它在第一次编写时变得更好。

单元测试

让我们从 Python 的内置测试库开始探索。该库为单元测试提供了一个通用接口。单元测试的重点是在任何一个测试中测试尽可能少的代码。每个测试都测试可用代码总量的一个单位。

毫不奇怪,用于此的 Python 库被称为unittest。它提供了几个用于创建和运行单元测试的工具,其中最重要的是TestCase类。这个类提供了一组方法,允许我们比较值,设置测试,并在测试完成后进行清理。

当我们想要为特定任务编写一组单元测试时,我们创建了一个子类TestCase,并编写了单独的方法来进行实际测试。这些方法都必须以名称test开头。当遵循此约定时,测试将作为测试过程的一部分自动运行。通常,测试在对象上设置一些值,然后运行一个方法,并使用内置的比较方法来确保计算出正确的结果。下面是一个非常简单的示例:

import unittest

class CheckNumbers(unittest.TestCase):
    def test_int_float(self):
 self.assertEqual(1, 1.0)

if __name__ == "__main__":
    unittest.main()

这段代码只是对TestCase类进行了子类化,并添加了一个调用TestCase.assertEqual方法的方法。此方法将成功或引发异常,具体取决于两个参数是否相等。如果我们运行此代码,unittest中的main函数将为我们提供以下输出:

.
--------------------------------------------------------------
Ran 1 test in 0.000s

OK

你知道浮点数和整数可以相等比较吗?让我们添加一个失败的测试:

    def test_str_float(self):
        self.assertEqual(1, "1")

此代码的输出更加险恶,因为整数和字符串不相等:

.F
============================================================
FAIL: test_str_float (__main__.CheckNumbers)
--------------------------------------------------------------
Traceback (most recent call last):
 File "simplest_unittest.py", line 8, in test_str_float
 self.assertEqual(1, "1")
AssertionError: 1 != '1'

--------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)

第一行上的点表示第一个测试(我们之前编写的测试)成功通过;字母F表示第二次测试失败。然后,在最后,它为我们提供了一些信息输出,告诉我们测试失败的方式和地点,以及失败次数的摘要。

我们可以在一门TestCase课上有任意多个测试方法;只要方法名称以test开头,测试运行程序就会将每个方法作为单独的测试执行。每个测试应完全独立于其他测试。先前测试的结果或计算应不会对当前测试产生影响。编写好的单元测试的关键是使每个测试方法尽可能短,用每个测试用例测试一小部分代码。如果您的代码似乎没有自然地分解成这样的可测试单元,那么这可能是您的设计需要重新思考的迹象。

断言方法

测试用例的总体布局是将某些变量设置为已知值,运行一个或多个函数、方法或流程,然后使用TestCase断言方法“证明”返回或计算了正确的预期结果。

有几种不同的断言方法可用于确认已经实现了特定的结果。我们刚才看到了assertEqual,如果这两个参数没有通过相等性检查,将导致测试失败。如果这两个参数的比较结果相同,则反向assertNotEqual将失败。assertTrueassertFalse方法各自接受一个表达式,如果表达式未通过if测试,则失败。这些测试不检查布尔值TrueFalse。相反,它们测试相同的条件,就好像使用了一个if语句:FalseNone0或者一个空列表、字典、字符串、集合或元组将传递一个对assertFalse方法的调用,而在调用assertTrue方法时,非零数字、包含值的容器或值True将成功。

有一个assertRaises方法可用于确保特定的函数调用引发特定的异常,或者,可以选择将其用作上下文管理器来包装内联代码。如果with语句中的代码引发了正确的异常,则测试通过;否则,它将失败。以下是两个版本的示例:

import unittest

def average(seq):
    return sum(seq) / len(seq)

class TestAverage(unittest.TestCase):
    def test_zero(self):
 self.assertRaises(ZeroDivisionError,
 average,
 [])

    def test_with_zero(self):
 with self.assertRaises(ZeroDivisionError):
            average([])

if __name__ == "__main__":
    unittest.main()

上下文管理器允许我们以通常编写代码的方式编写代码(通过调用函数或直接执行代码),而不必在另一个函数调用中包装函数调用。

下表总结了其他几种断言方法:

|

方法

|

描述

| | --- | --- | | assertGreater``assertGreaterEqual``assertLess``assertLessEqual | 接受两个可比较的对象并确保命名的不等式成立。 | | assertIn``assertNotIn | 确保元素是(或不是)容器对象中的元素。 | | assertIsNone``assertIsNotNone | 确保元素是(或不是)精确值None(但不是另一个虚假值)。 | | assertSameElements | 确保两个容器对象具有相同的元素,忽略顺序。 | | assertSequenceEqualassertDictEqual``assertSetEqual``assertListEqual``assertTupleEqual | 确保两个容器具有相同顺序的相同元素。如果出现故障,则显示一个代码差异,比较两个列表以查看它们的差异。最后四种方法也测试列表的类型。 |

每个断言方法都接受一个名为msg.的可选参数(如果提供),如果断言失败,它将包含在错误消息中。这对于澄清预期内容或解释导致断言失败的 bug 可能发生在何处非常有用。

减少样板和清理

在编写了一些小测试之后,我们经常发现我们必须为几个相关的测试执行相同的设置代码。例如,下面的list子类有三种统计计算方法:

from collections import defaultdict

class StatsList(list):
    def mean(self):
        return sum(self) / len(self)

    def median(self):
        if len(self) % 2:
            return self[int(len(self) / 2)]
        else:
            idx = int(len(self) / 2)
            return (self[idx] + self[idx-1]) / 2

    def mode(self):
        freqs = defaultdict(int)
        for item in self:
            freqs[item] += 1
        mode_freq = max(freqs.values())
        modes = []
        for item, value in freqs.items():
            if value == mode_freq:
                modes.append(item)
        return modes

显然,我们要用这三种输入非常相似的方法中的每一种来测试情况;我们想看看空列表、包含非数值的列表或包含普通数据集的列表会发生什么。我们可以使用TestCase类上的setUp方法对每个测试进行初始化。此方法不接受任何参数,并允许我们在运行每个测试之前执行任意设置。例如,我们可以在相同的整数列表上测试所有三种方法,如下所示:

from stats import StatsList
import unittest

class TestValidInputs(unittest.TestCase):
    def setUp(self):
        self.stats = StatsList([1,2,2,3,3,4])

    def test_mean(self):
        self.assertEqual(self.stats.mean(), 2.5)

    def test_median(self):
        self.assertEqual(self.stats.median(), 2.5)
        self.stats.append(4)
        self.assertEqual(self.stats.median(), 3)

    def test_mode(self):
        self.assertEqual(self.stats.mode(), [2,3])
        self.stats.remove(2)
        self.assertEqual(self.stats.mode(), [3])

if __name__ == "__main__":
    unittest.main()

如果我们运行这个例子,它表明所有测试都通过了。首先请注意,setUp方法从未在三个test_*方法中显式调用。测试套件代表我们这样做。更重要的是,请注意test_median是如何通过向列表中添加一个额外的4来更改列表的,但是当调用test_mode时,列表已返回到setUp中指定的值(如果没有返回,列表中将有四分之二,mode方法将返回三个值)。这表明在每次测试之前都会单独调用setUp,以确保测试类以一个干净的状态开始。测试可以按任何顺序执行,一个测试的结果不应依赖于任何其他测试。

除了setUp方法之外,TestCase还提供了一个无参数tearDown方法,该方法可用于在类上的每个测试运行后进行清理。如果清理需要垃圾收集对象以外的任何东西,这将非常有用。例如,如果我们正在测试执行文件 I/O 的代码,我们的测试可能会创建新文件作为测试的副作用;tearDown方法可以删除这些文件,并确保系统处于与测试运行前相同的状态。测试用例不应该有副作用。通常,我们根据测试方法的共同设置代码,将它们分为不同的TestCase子类。需要相同或类似设置的多个测试将放在一个类中,而需要不相关设置的测试将放在另一个类中。

组织和运行测试

不需要很长时间就可以让单元测试集合变得非常庞大和笨拙。一次加载并运行所有测试很快就会变得复杂。这是单元测试的主要目标;在我们的程序上运行所有测试,并对“我最近的更改是否破坏了任何现有测试?”的问题快速给出“是或否”的答案,这应该是很简单的。

Python 的discover模块主要查找当前文件夹或子文件夹中名称以test字符开头的任何模块。如果在这些模块中发现任何TestCase对象,则执行测试。这是一种无痛的方式,可以确保我们不会错过运行任何测试。要使用它,请确保您的测试模块名为test_<something>.py,然后运行命令python3 -m unittest discover

忽略破损测试

有时,我们知道测试失败,但我们不希望测试套件报告失败。这可能是因为一个坏的或未完成的特性已经编写了测试,但我们目前并不专注于改进它。更常见的情况是,这是因为功能仅在特定平台、Python 版本或特定库的高级版本上可用。Python 为我们提供了一些修饰符,用于将测试标记为在已知条件下预期失败或跳过的测试。

装饰师是:

  • expectedFailure()
  • skip(reason)
  • skipIf(condition, reason)
  • skipUnless(condition, reason)

这些都是使用 Python decorator 语法应用的。第一个不接受任何参数,只是告诉测试运行者在测试失败时不要将其记录为失败。skip方法更进一步,甚至不需要运行测试。它需要一个字符串参数来描述跳过测试的原因。另外两个 decorator 接受两个参数,一个是指示是否应该运行测试的布尔表达式,另一个是类似的描述。在使用中,这三个装饰器的应用方式如下:

import unittest
import sys

class SkipTests(unittest.TestCase):
 @unittest.expectedFailure
    def test_fails(self):
        self.assertEqual(False, True)

 @unittest.skip("Test is useless")
    def test_skip(self):
        self.assertEqual(False, True)

 @unittest.skipIf(sys.version_info.minor == 4,
 "broken on 3.4")
    def test_skipif(self):
        self.assertEqual(False, True)

 @unittest.skipUnless(sys.platform.startswith('linux'),
 "broken unless on linux")
    def test_skipunless(self):
        self.assertEqual(False, True)

if __name__ == "__main__":
    unittest.main()

第一次测试失败,但报告为预期失败;第二个测试永远不会运行。其他两个测试可能运行,也可能不运行,这取决于当前的 Python 版本和操作系统。在运行 Python 3.4 的 Linux 系统上,输出如下所示:

xssF
=============================================================
FAIL: test_skipunless (__main__.SkipTests)
--------------------------------------------------------------
Traceback (most recent call last):
 File "skipping_tests.py", line 21, in test_skipunless
 self.assertEqual(False, True)
AssertionError: False != True

--------------------------------------------------------------
Ran 4 tests in 0.001s

FAILED (failures=1, skipped=2, expected failures=1)

第一行的x表示预期故障;两个s字符表示跳过的测试,F表示真正的失败,因为在我的系统上skipUnless的条件是True

用 py.test 测试

Pythonunittest模块需要大量样板代码来设置和初始化测试。它基于非常流行的 JUnit Java 测试框架。它甚至使用相同的方法名(您可能已经注意到它们不符合 PEP-8 命名标准,该标准建议在方法名中用下划线而不是 CamelCase 来分隔单词)和测试布局。虽然这对于 Java 测试是有效的,但它不一定是 Python 测试的最佳设计。

因为 Python 程序员喜欢他们的代码优雅而简单,所以在标准库之外开发了其他测试框架。其中两个比较流行的是py.testnose。前者更为健壮,支持 Python3 的时间更长,因此我们将在这里讨论它。

由于py.test不是标准库的一部分,您需要自己下载并安装它;您可以从py.test主页获取 http://pytest.org/ 。该网站为各种解释器和平台提供了全面的安装说明,但您通常可以使用更常见的 python 包安装程序 pip。只需在命令行中键入pip install pytest,您就可以开始了。

py.testunittest模块的布局有很大不同。它不要求测试用例是类。相反,它利用了 Python 函数是对象这一事实,并允许任何正确命名的函数像测试一样运行。它没有提供一系列用于断言相等性的自定义方法,而是使用assert语句来验证结果。这使得测试更具可读性和可维护性。当我们运行py.test时,它将在当前文件夹中启动,并搜索该文件夹或子包中名称以字符test_开头的任何模块。如果本模块中的任何功能也以test开头,则它们将作为单独的测试执行。此外,如果模块中存在名称以Test开头的任何类,则该类上以test_开头的任何方法也将在测试环境中执行。

让我们将前面编写的最简单的unittest示例移植到py.test

def test_int_float():
    assert 1 == 1.0

对于完全相同的测试,我们编写了两行可读性更强的代码,与第一个unittest示例中所需的六行代码相比。

然而,我们并没有被禁止编写基于类的测试。类可用于将相关测试分组在一起,或用于需要访问类上相关属性或方法的测试。这个示例显示了一个扩展类,测试通过,测试失败;我们将看到错误输出比unittest模块提供的更全面:

class TestNumbers:
    def test_int_float(self):
        assert 1 == 1.0

    def test_int_str(self):
        assert 1 == "1"

请注意,该类不必扩展任何特殊对象以作为测试(尽管py.test将运行标准unittest TestCases很好)。如果我们运行py.test <filename>,输出如下:

============== test session starts ==============
python: platform linux2 -- Python 3.4.1 -- pytest-2.6.4
test object 1: class_pytest.py

class_pytest.py .F

=================== FAILURES====================
___________ TestNumbers.test_int_str ____________

self = <class_pytest.TestNumbers object at 0x85b4fac>

 def test_int_str(self):
>       assert 1 == "1"
E       assert 1 == '1'

class_pytest.py:7: AssertionError
====== 1 failed, 1 passed in 0.10 seconds =======

输出从一些关于平台和解释器的有用信息开始。这对于跨不同系统共享 bug 非常有用。第三行告诉我们正在测试的文件的名称(如果有多个测试模块被选中,它们都将被显示),然后是我们在unittest模块中看到的熟悉的.F.字符表示通过测试,而字母F表示失败。

所有测试运行后,将显示每个测试的错误输出。它提供了局部变量的摘要(本例中只有一个:传递到函数中的self参数)、发生错误的源代码以及错误消息的摘要。此外,如果引发了AssertionError以外的异常,py.test将向我们提供完整的回溯,包括源代码引用。

默认情况下,如果测试成功,py.test将抑制print语句的输出。这有助于测试调试;当测试失败时,我们可以在测试中添加print语句,在测试运行时检查特定变量和属性的值。如果测试失败,则输出这些值以帮助诊断。但是,一旦测试成功,print语句输出将不显示,它们很容易被忽略。我们不必通过删除print语句来“清理”输出。如果由于将来的更改,测试再次失败,调试输出将立即可用。

设置和清理的一种方法

py.test支持设置和拆卸方法,与unittest中使用的方法类似,但它提供了更大的灵活性。我们将简要讨论这些功能,因为它们很熟悉,但是它们没有在unittest模块中广泛使用,因为py.test为我们提供了强大的 funcargs 功能,我们将在下一节中讨论。

如果我们正在编写基于类的测试,那么我们可以使用两种称为setup_methodteardown_method的方法,基本上与unittest中称为setUptearDown的方法相同。在类中的每个测试方法之前和之后调用它们,以执行设置和清理任务。不过,与unittest方法有一个区别。两个方法都接受一个参数:表示被调用方法的函数对象。

此外,py.test还提供了其他设置和拆卸功能,使我们能够更好地控制何时执行设置和清理代码。setup_classteardown_class方法预计为类方法;他们接受一个单独的参数(没有self参数)来表示所讨论的类。

最后,我们有setup_moduleteardown_module函数,它们在该模块中的所有测试(在函数或类中)之前和之后立即运行。这些对于“一次性”设置非常有用,例如创建模块中所有测试都将使用的套接字或数据库连接。注意这个问题,因为如果正在设置的对象存储状态,它可能会意外地在测试之间引入依赖关系。

这个简短的描述并不能很好地解释这些方法何时被调用,所以让我们看一个例子,它确切地说明了何时发生:

def setup_module(module):
    print("setting up MODULE {0}".format(
        module.__name__))

def teardown_module(module):
    print("tearing down MODULE {0}".format(
        module.__name__))

def test_a_function():
    print("RUNNING TEST FUNCTION")

class BaseTest:
 def setup_class(cls):
        print("setting up CLASS {0}".format(
            cls.__name__))

 def teardown_class(cls):
        print("tearing down CLASS {0}\n".format(
            cls.__name__))

 def setup_method(self, method):
        print("setting up METHOD {0}".format(
            method.__name__))

 def teardown_method(self, method):
        print("tearing down  METHOD {0}".format(
            method.__name__))

class TestClass1(BaseTest):
    def test_method_1(self):
        print("RUNNING METHOD 1-1")

    def test_method_2(self):
        print("RUNNING METHOD 1-2")

class TestClass2(BaseTest):
    def test_method_1(self):
        print("RUNNING METHOD 2-1")

    def test_method_2(self):
        print("RUNNING METHOD 2-2")

BaseTest类的唯一目的是提取与测试类相同的四个方法,并使用继承来减少重复代码的数量。因此,从py.test的角度来看,这两个子类不仅各有两个测试方法,还有两个设置和两个拆卸方法(一个在类级别,一个在方法级别)。

如果我们在禁用print功能输出抑制的情况下使用py.test运行这些测试(通过传递-s--capture=no标志),它们会显示与测试本身相关的各种功能何时被调用:

py.test setup_teardown.py -s
setup_teardown.py
setting up MODULE setup_teardown
RUNNING TEST FUNCTION
.setting up CLASS TestClass1
setting up METHOD test_method_1
RUNNING METHOD 1-1
.tearing down  METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 1-2
.tearing down  METHOD test_method_2
tearing down CLASS TestClass1
setting up CLASS TestClass2
setting up METHOD test_method_1
RUNNING METHOD 2-1
.tearing down  METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 2-2
.tearing down  METHOD test_method_2
tearing down CLASS TestClass2

tearing down MODULE setup_teardown

模块的设置和拆卸方法在会话开始和结束时执行。然后运行单独的模块级测试功能。接下来,执行第一个类的设置方法,然后执行该类的两个测试。这些测试分别被包装在单独的setup_methodteardown_method调用中。执行测试后,调用类 teardown 方法。在teardown_module方法最终被调用之前,第二个类也会发生同样的顺序,只发生一次。

设置变量的方式完全不同

各种设置和拆卸功能最常见的用途之一是确保在运行每个测试方法之前,某些类或模块变量具有已知值。

py.test提供了一种完全不同的方法,使用称为funcargs,是函数参数的缩写。Funcargs 基本上是在测试配置文件中预定义的命名变量。这允许我们将配置与测试的执行分开,并允许在多个类和模块之间使用 funcargs。

为了使用它们,我们将参数添加到测试函数中。参数的名称用于查找特殊命名函数中的特定参数。例如,如果我们想测试我们在演示unittest时使用的StatsList类,我们还需要重复测试有效整数列表。但是我们可以这样编写测试,而不是使用设置方法:

from stats import StatsList

def pytest_funcarg__valid_stats(request):
    return StatsList([1,2,2,3,3,4])

def test_mean(valid_stats):
    assert valid_stats.mean() == 2.5

def test_median(valid_stats):
    assert valid_stats.median() == 2.5
    valid_stats.append(4)
    assert valid_stats.median() == 3

def test_mode(valid_stats):
    assert valid_stats.mode() == [2,3]
    valid_stats.remove(2)
    assert valid_stats.mode() == [3]

三种测试方法均接受一个名为valid_stats的参数;此参数是通过调用文件顶部定义的pytest_funcarg__valid_stats函数创建的。如果多个模块需要 funcarg,也可以在名为conftest.py的文件中定义。py.test解析conftest.py文件,加载任何“全局”测试配置;它是定制py.test体验的一种总括。

与其他py.test功能一样,返回函数的工厂名称很重要;funcargs 是名为pytest_funcarg__<identifier>的函数,其中<identifier>是一个有效的变量名,可以用作测试函数中的参数。此函数接受一个神秘的request参数,并将要作为参数传递给各个测试函数的对象返回。对单个测试函数的每次调用都会重新创建 funcarg;例如,这允许我们在一个测试中更改列表,并知道它将在下一个测试中重置为其原始值。

Funcargs 可以做的远不止返回基本变量。传递到 funcarg 工厂的request对象提供了一些非常有用的方法和属性来修改 funcarg 的行为。moduleclsfunction属性允许我们准确地看到哪个测试正在请求函数。config属性允许我们检查命令行参数和其他配置数据。

更有趣的是,request 对象提供了一些方法,允许我们对 funcarg 进行额外的清理,或者在测试中重用它,否则这些活动将降级为特定范围的 setup 和 teardown 方法。

request.addfinalizer方法接受一个回调函数,该函数在调用使用 funcarg 的每个测试函数后执行清理。这提供了相当于拆卸方法的功能,允许我们清理文件、关闭连接、空列表或重置队列。例如,下面的代码通过创建临时目录funcarg来测试os.mkdir功能:

import tempfile
import shutil
import os.path

def pytest_funcarg__temp_dir(request):
    dir = tempfile.mkdtemp()
    print(dir)

    def cleanup():
        shutil.rmtree(dir)
 request.addfinalizer(cleanup)
    return dir

def test_osfiles(temp_dir):
    os.mkdir(os.path.join(temp_dir, 'a'))
    os.mkdir(os.path.join(temp_dir, 'b'))
    dir_contents = os.listdir(temp_dir)
    assert len(dir_contents) == 2
    assert 'a' in dir_contents
    assert 'b' in dir_contents

函数为要在中创建的文件创建一个新的空临时目录。然后,它添加一个终结器调用,以在测试完成后删除该目录(使用shutil.rmtree,递归删除目录和其中的任何内容)。然后,文件系统将保持其启动时的状态。

我们可以使用request.cached_setup方法创建持续时间超过一次测试的函数参数变量。这在设置可由多个测试重用的昂贵操作时非常有用,只要资源重用不破坏测试的原子或单元性质(以便一个测试不依赖于前一个测试,也不受前一个测试的影响)。例如,如果要测试以下 echo 服务器,我们可能希望在单独的进程中只运行服务器的一个实例,然后让多个测试连接到该实例:

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('localhost',1028))
s.listen(1)

    while True:
        client, address = s.accept()
        data = client.recv(1024)
        client.send(data)
        client.close()

这段代码所做的就是监听特定端口并等待来自客户端套接字的输入。当它接收到输入时,它会将相同的值发送回。为了测试这一点,我们可以在单独的进程中启动服务器,并缓存结果以用于多个测试。以下是测试代码的外观:

import subprocess
import socket
import time

def pytest_funcarg__echoserver(request):
 def setup():
 p = subprocess.Popen(
 ['python3', 'echo_server.py'])
 time.sleep(1)
 return p

 def cleanup(p):
 p.terminate()

 return request.cached_setup(
 setup=setup,
 teardown=cleanup,
 scope="session")

def pytest_funcarg__clientsocket(request):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(('localhost', 1028))
    request.addfinalizer(lambda: s.close())
    return s

def test_echo(echoserver, clientsocket):
    clientsocket.send(b"abc")
    assert clientsocket.recv(3) == b'abc'

def test_echo2(echoserver, clientsocket):
    clientsocket.send(b"def")
    assert clientsocket.recv(3) == b'def'

我们已经在这里创建了两个函数。第一个在单独的进程中运行 echo 服务器,并返回 process 对象。第二个为每个测试实例化一个新的套接字对象,并在测试完成后使用addfinalizer将其关闭。第一个 funcarg 是我们目前感兴趣的函数。它看起来很像传统的单元测试设置和拆卸。我们创建一个不接受任何参数并返回正确参数的setup函数;在本例中,测试实际上忽略了一个流程对象,因为它们只关心服务器是否正在运行。然后,我们创建一个cleanup函数(函数的名称是任意的,因为它只是传递给另一个函数的对象),它接受一个参数:setup返回的参数。此清理代码终止该进程。

父函数不直接返回 funcarg,而是返回调用request.cached_setup的结果。它接受setupteardown函数(我们刚刚创建)的两个参数和一个scope参数。最后一个参数应该是三个字符串“function”、“module”或“session”中的一个;它决定了参数将被缓存多长时间。在本例中,我们将其设置为“session”,因此它在整个py.test运行期间都会被缓存。在所有测试运行之前,进程不会终止或重新启动。当然,“module”作用域仅为该模块中的测试缓存该对象,“function”作用域更像是将该对象视为普通的 funcarg,因为它在每个测试函数运行后都会重置。

使用 py.test 跳过测试

unittest模块一样,由于各种原因,经常需要跳过py.test中的测试:被测试的代码尚未编写,测试仅在某些解释器或操作系统上运行,或者测试非常耗时,只能在某些情况下运行。

我们可以使用py.test.skip函数跳过代码中任何一点的测试。它只接受一个参数:一个字符串,描述跳过它的原因。这个函数可以在任何地方调用;如果我们在测试函数中调用它,测试将被跳过。如果我们在模块级别调用它,那么将跳过该模块中的所有测试。如果我们在 funcarg 函数中调用它,则将跳过所有调用该 funcarg 的测试。

当然,在所有这些位置,通常只在满足或不满足某些条件时才希望跳过测试。因为我们可以在 Python 代码中的任何位置执行skip函数,所以我们可以在if语句中执行它。因此,我们可以编写一个如下所示的测试:

import sys
import py.test

def test_simple_skip():
    if sys.platform != "fakeos":
 py.test.skip("Test works only on fakeOS")

    fakeos.do_something_fake()
    assert fakeos.did_not_happen

这是一些相当愚蠢的代码,真的。没有名为fakeos的 Python 平台,因此此测试将在所有操作系统上跳过。它显示了我们如何有条件地跳过测试,而且由于if语句可以检查任何有效的条件,因此当跳过测试时,我们有很大的权限。通常,我们检查sys.version_info来检查 Python 解释器版本,sys.platform来检查操作系统,或者some_library.__version__来检查给定 API 的最新版本是否足够。

由于跳过基于特定条件的单个测试方法或函数是跳过测试最常见的用途之一,py.test提供了一个方便的装饰器,允许我们在一行中完成这项工作。decorator 接受单个字符串,该字符串可以包含任何计算为布尔值的可执行 Python 代码。例如,以下测试将仅在 Python 3 或更高版本上运行:

import py.test

@py.test.mark.skipif("sys.version_info <= (3,0)")
def test_python3():
    assert b"hello".decode() == "hello"

py.test.mark.xfail装饰器的行为类似,只是它将测试标记为预期失败,类似于unittest.expectedFailure()。如果测试成功,将记录为失败;如果失败,将作为预期行为报告。在xfail的情况下,条件参数是可选的;如果未提供,测试将被标记为在所有条件下预期失败。

模仿贵重物品

有时,我们想要测试需要提供昂贵或难以构建的对象的代码。虽然这可能意味着您的 API 需要重新思考,以拥有一个更可测试的接口(通常意味着一个更可用的接口),但我们有时会发现自己编写的测试代码中有大量样板文件,用于设置仅与被测代码偶然相关的对象。

例如,假设我们有一些代码跟踪键值存储中的航班状态(例如redismemcache,这样我们可以存储时间戳和最新状态。此类代码的基本版本可能如下所示:

import datetime
import redis

class FlightStatusTracker:
    ALLOWED_STATUSES = {'CANCELLED', 'DELAYED', 'ON TIME'}

    def __init__(self):
        self.redis = redis.StrictRedis()

    def change_status(self, flight, status):
        status = status.upper()
        if status not in self.ALLOWED_STATUSES:
            raise ValueError(
                   "{} is not a valid status".format(status))

        key = "flightno:{}".format(flight)
        value = "{}|{}".format(
            datetime.datetime.now().isoformat(), status)
        self.redis.set(key, value)

在这个方法中,我们有很多东西需要测试。我们应该检查,如果传入错误状态,它是否会引发相应的错误。我们需要确保它将状态转换为大写。我们可以看到,在redis对象上调用set()方法时,键和值的格式是正确的。

然而,我们不必在单元测试中检查的一件事是,redis对象正确地存储了数据。这绝对应该在集成或应用程序测试中进行测试,但在单元测试级别,我们可以假设 py redis 开发人员已经测试了他们的代码,并且该方法符合我们的要求。通常,单元测试应该是自包含的,而不依赖于外部资源的存在,例如运行中的 Redis 实例。

相反,我们只需要测试set()方法被调用的次数和参数是否合适。我们可以在我们的测试中使用Mock()对象,用一个我们可以反思的对象来代替麻烦的方法。以下示例说明了 mock 的使用:

from unittest.mock import Mock
import py.test
def pytest_funcarg__tracker():
    return FlightStatusTracker()

def test_mock_method(tracker):
 tracker.redis.set = Mock()
    with py.test.raises(ValueError) as ex:
        tracker.change_status("AC101", "lost")
    assert ex.value.args[0] == "LOST is not a valid status"
 assert tracker.redis.set.call_count == 0

此测试使用py.test语法编写,断言传入不适当的参数时会引发正确的异常。此外,它还为set方法创建了一个模拟对象,并确保从未调用过它。如果是的话,那就意味着我们的异常处理代码中有一个 bug。

简单地替换方法在这种情况下效果很好,因为被替换的对象最终被销毁。然而,我们通常只希望在测试期间替换函数或方法。例如,如果我们想在 mock 方法中测试时间戳格式,我们需要确切地知道datetime.datetime.now()将返回什么。但是,此值会随着运行的不同而变化。我们需要某种方法将它固定到一个特定的值,以便我们可以确定地测试它。

还记得猴子打补丁吗?临时将库函数设置为特定值是对它的一种很好的使用。模拟库提供了一个补丁上下文管理器,允许我们用模拟对象替换现有库上的属性。当上下文管理器退出时,会自动恢复原始属性,以免影响其他测试用例。下面是一个例子:

from unittest.mock import patch
def test_patch(tracker):
    tracker.redis.set = Mock()
 fake_now = datetime.datetime(2015, 4, 1)
 with patch('datetime.datetime') as dt:
 dt.now.return_value = fake_now
        tracker.change_status("AC102", "on time")
    dt.now.assert_called_once_with()
 tracker.redis.set.assert_called_once_with(
 "flightno:AC102",
 "2015-04-01T00:00:00|ON TIME")

在本例中,我们首先构造一个名为fake_now的值,将其设置为datetime.datetime.now函数的返回值。我们必须在修补datetime.datetime之前构造这个对象,否则我们将在构造它之前调用修补的now函数!

with语句邀请补丁用模拟对象替换datetime.datetime模块,模拟对象作为值dt返回。模拟对象的一个优点是,每当您访问该对象上的属性或方法时,它都会返回另一个模拟对象。因此,当我们访问dt.now时,它会给我们一个新的模拟对象。我们将该对象的return_value设置为我们的fake_now对象;这样,无论何时调用datetime.datetime.now函数,它都将返回我们的对象,而不是新的模拟对象。

然后,在使用已知值调用我们的change_status方法之后,我们使用 mock 类的assert_called_once_with函数来确保now函数确实被调用了一次,没有参数。然后我们再次调用它,以证明调用redis.set方法时使用的参数的格式与我们预期的一致。

前面的示例很好地说明了编写测试如何指导我们的 API 设计。FlightStatusTracker对象乍一看是有感觉的;当对象被构造时,我们构造一个redis连接,当我们需要它时,我们调用它。然而,当我们为这段代码编写测试时,我们发现即使我们模拟了一个FlightStatusTracker上的self.redis变量,redis连接仍然需要构建。如果没有运行 Redis 服务器,这个调用实际上会失败,我们的测试也会失败。

我们可以通过模拟redis.StrictRedis类在setUp方法中返回模拟来解决这个问题。然而,更好的办法可能是重新思考我们的例子。与其在__init__中构造redis实例,或许我们应该允许用户传入一个实例,如下例所示:

    def __init__(self, redis_instance=None):
        self.redis = redis_instance if redis_instance else redis.StrictRedis()

这允许我们在测试时通过一个 mock-in,因此永远不会构造StrictRedis方法。但是,它也允许与FlightStatusTracker对话的任何客户端代码在其自己的redis实例中传递。他们这样做的原因有很多。他们可能已经为代码的其他部分构建了一个。他们可能已经创建了一个优化的redisAPI 实现。也许他们有一个将度量记录到其内部监控系统的系统。通过编写单元测试,我们发现了一个用例,使我们的 API 从一开始就更加灵活,而不是等待客户要求我们支持他们的特殊需求。

本文简要介绍了模拟代码的奇妙之处。自 Python 3.3 以来,mock 是标准unittest库的一部分,但正如您从这些示例中看到的,它们也可以与py.test和其他库一起使用。mock 还有其他更高级的特性,随着代码变得更加复杂,您可能需要利用这些特性。例如,您可以使用spec参数邀请模拟来模拟现有类,这样,如果代码试图访问模拟类上不存在的属性,它就会引发错误。您还可以通过将列表作为side_effect参数传递,构造每次调用时都返回不同参数的模拟方法。side_effect参数用途广泛;您还可以使用它在调用 mock 时执行任意函数或引发异常。

总的来说,我们对嘲笑应该相当吝啬。如果我们发现自己在一个给定的单元测试中模拟了多个元素,那么我们最终可能会测试模拟框架,而不是真正的代码。这没有任何用处;毕竟,mock 已经过很好的测试了!如果我们的代码做了很多这方面的工作,那么这可能是我们正在测试的 API 设计糟糕的另一个迹象。模拟应该存在于被测试代码和它们所接口的库之间的边界上。如果没有发生这种情况,我们可能需要更改 API,以便在不同的位置重新绘制边界。

多少测试就足够了?

我们已经确定,未经测试的代码就是坏代码。但是我们如何判断我们的代码测试得有多好呢?我们如何知道有多少代码实际上正在测试,有多少代码被破坏?第一个问题更重要,但很难回答。即使我们知道我们已经测试了应用程序中的每一行代码,我们也不知道我们已经正确地测试了它。例如,如果我们编写一个 stats 测试,只检查当我们提供一个整数列表时会发生什么,那么如果在浮点、字符串或自制对象列表上使用,它可能仍然会失败。设计完整测试套件的责任仍然在于程序员。

第二个问题是,我们的代码中有多少实际上正在被测试,这很容易验证。代码覆盖率本质上是对程序执行的代码行数的估计。如果我们知道程序中的行数和行数,我们就可以估计出真正测试或覆盖代码的百分比。如果我们另外有一个关于哪些行没有被测试的指示器,我们就可以更容易地编写新的测试,以确保这些行很少被破坏。

最流行的测试代码覆盖率的工具被称为coverage.py,这一点值得记住。它可以像大多数其他第三方库一样使用命令pip install coverage进行安装。

我们没有足够的篇幅来介绍 coverage API 的所有细节,所以我们只看几个典型的例子。如果我们有一个 Python 脚本为我们运行所有单元测试(例如,使用unittest.main、自定义测试运行程序或discover,我们可以使用以下命令执行覆盖率分析:

coverage run coverage_unittest.py

此命令将正常退出,但它会创建一个名为.coverage的文件,用于保存运行中的数据。我们现在可以使用coverage report命令来分析代码覆盖率:

>>> coverage report

结果如下:

Name                           Stmts   Exec  Cover
--------------------------------------------------
coverage_unittest                  7      7   100%
stats                             19      6    31%
--------------------------------------------------
TOTAL                             26     13    50%

这个基本的报告列出了执行的文件(我们的单元测试和它导入的模块)。还列出了每个文件中的代码行数以及测试执行的代码行数。然后将这两个数字组合起来,以估计代码覆盖率。如果我们将-m选项传递给 report 命令,它将额外添加一列,如下所示:

Missing
-----------
8-12, 15-23

此处列出的行范围标识了stats模块中在测试运行期间未执行的行。

我们刚刚运行代码覆盖率工具的示例使用了我们在本章前面创建的相同统计模块。但是,它故意使用单个测试,无法测试文件中的大量代码。以下是测试:

from stats import StatsList
import unittest

class TestMean(unittest.TestCase):
    def test_mean(self):
        self.assertEqual(StatsList([1,2,2,3,3,4]).mean(), 2.5)

if __name__ == "__main__":

    unittest.main()

这段代码不测试中位数或模式函数,它们对应于覆盖率输出告诉我们丢失的行号。

文本报告就足够了,但是如果我们使用命令coverage html,我们可以得到一个更有趣的交互式 HTML 报告,我们可以在 web 浏览器中查看。该网页甚至突出显示了源代码中哪些行经过测试,哪些行没有经过测试。下面是它的外观:

How much testing is enough?

我们也可以将模块与py.test一起使用。我们需要使用pip install pytest-coverage安装py.test插件以覆盖代码。该插件为py.test添加了几个命令行选项,最有用的是--cover-report,可以设置为htmlreportannotate(后者实际上修改源代码以突出显示未涵盖的任何行)。

不幸的是,如果我们能够以某种方式在本章的这一部分运行一个覆盖率报告,我们会发现我们还没有涵盖关于代码覆盖率的大部分知识!可以使用 coverage API 从我们自己的程序(或测试套件)中管理代码覆盖率,并且coverage.py接受许多我们未涉及的配置选项。我们也没有讨论语句覆盖率和分支覆盖率之间的区别(后者更有用,在最近版本的coverage.py中是默认值)或其他类型的代码覆盖率。

请记住,虽然 100%的代码覆盖率是我们都应该努力实现的崇高目标,但 100%的覆盖率是不够的!测试语句并不意味着对所有可能的输入都进行了正确的测试。

案例研究

让我们通过编写一个小型的、经过测试的加密应用程序来完成测试驱动的开发。别担心,您不需要了解复杂的现代加密算法(如 Threefish 或 RSA)背后的数学原理。相反,我们将实现一种 16 世纪的算法,称为 Vigenère 密码。应用程序只需要能够在给定编码关键字的情况下,使用此密码对消息进行编码和解码。

首先,如果我们手动(没有计算机)应用密码,我们需要了解密码是如何工作的。我们从如下表格开始:

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
B C D E F G H I J K L M N O P Q R S T U V W X Y Z A
C D E F G H I J K L M N O P Q R S T U V W X Y Z A B
D E F G H I J K L M N O P Q R S T U V W X Y Z A B C
E F G H I J K L M N O P Q R S T U V W X Y Z A B C D
F G H I J K L M N O P Q R S T U V W X Y Z A B C D E
G H I J K L M N O P Q R S T U V W X Y Z A B C D E F
H I J K L M N O P Q R S T U V W X Y Z A B C D E F G
I J K L M N O P Q R S T U V W X Y Z A B C D E F G H
J K L M N O P Q R S T U V W X Y Z A B C D E F G H I
K L M N O P Q R S T U V W X Y Z A B C D E F G H I J
L M N O P Q R S T U V W X Y Z A B C D E F G H I J K
M N O P Q R S T U V W X Y Z A B C D E F G H I J K L
N O P Q R S T U V W X Y Z A B C D E F G H I J K L M
O P Q R S T U V W X Y Z A B C D E F G H I J K L M N
P Q R S T U V W X Y Z A B C D E F G H I J K L M N O
Q R S T U V W X Y Z A B C D E F G H I J K L M N O P
R S T U V W X Y Z A B C D E F G H I J K L M N O P Q
S T U V W X Y Z A B C D E F G H I J K L M N O P Q R
T U V W X Y Z A B C D E F G H I J K L M N O P Q R S
U V W X Y Z A B C D E F G H I J K L M N O P Q R S T
V W X Y Z A B C D E F G H I J K L M N O P Q R S T U
W X Y Z A B C D E F G H I J K L M N O P Q R S T U V
X Y Z A B C D E F G H I J K L M N O P Q R S T U V W
Y Z A B C D E F G H I J K L M N O P Q R S T U V W X
Z A B C D E F G H I J K L M N O P Q R S T U V W X Y

给定一个关键字 TRAIN,我们可以对用 PYTHON 编码的消息进行如下编码:

  1. 同时重复关键字和消息,以便轻松地将字母从一个映射到另一个:

    E N C O D E D I N P Y T H O N    T R A I N T R A I N T R A I N
  2. 对于纯文本中的每个字母,在表中查找以该字母开头的行。

  3. 查找包含与所选明文字母的关键字字母关联的字母的列。

  4. 编码字符位于此行和列的交点处。

例如,以 E 开头的行与以 T 开头的列在字符 X 处相交。因此,密文中的第一个字母是 X。以 N 开头的行与以 R 开头的列在字符 E 处相交,导致密文 XE。C 在 C 处与 A 相交,O 在 W 处与 I 相交。D 和 N 映射到 Q,而 E 和 T 映射到 X。完整的编码消息是 XECWQXUIVCRKHWA。

解码基本上遵循相反的过程。首先,找到带有共享关键字字符的行(T 行),然后在该行中找到编码字符(X)所在的位置。明文字符位于该行(E)列的顶部。

实施

我们的程序需要一个encode方法,该方法接受关键字和明文并返回密文,以及一个decode方法,该方法接受关键字和密文并返回原始消息。

但是,让我们遵循测试驱动的开发策略,而不仅仅是编写这些方法。我们将使用py.test进行单元测试。我们需要一种encode方法,我们知道它必须做什么;让我们先为该方法编写一个测试:

def test_encode():
    cipher = VigenereCipher("TRAIN")
    encoded = cipher.encode("ENCODEDINPYTHON")
    assert encoded == "XECWQXUIVCRKHWA"

这个测试自然会失败,因为我们没有在任何地方导入VigenereCipher类。让我们创建一个新模块来容纳该类。

让我们从以下VigenereCipher课程开始:

class VigenereCipher:
    def __init__(self, keyword):
        self.keyword = keyword

    def encode(self, plaintext):
 return "XECWQXUIVCRKHWA"

如果我们在测试类的顶部添加一个from vigenere_cipher import VigenereCipher行并运行py.test,前面的测试将通过!我们已经完成了第一个测试驱动的开发周期。

显然,返回硬编码字符串并不是密码类最明智的实现,因此让我们添加第二个测试:

def test_encode_character():
    cipher = VigenereCipher("TRAIN")
    encoded = cipher.encode("E")
    assert encoded == "X"

啊,现在那次试验要失败了。看来我们得更加努力了。但我想到了一件事:如果有人试图用空格或小写字符对字符串进行编码怎么办?在开始实现编码之前,让我们为这些情况添加一些测试,这样我们就不会忘记它们。预期的行为是删除空格,并将小写字母转换为大写:

def test_encode_spaces():
    cipher = VigenereCipher("TRAIN")
    encoded = cipher.encode("ENCODED IN PYTHON")
    assert encoded == "XECWQXUIVCRKHWA"

def test_encode_lowercase():
    cipher = VigenereCipher("TRain")
    encoded = cipher.encode("encoded in Python")
    assert encoded == "XECWQXUIVCRKHWA"

如果我们运行新的测试套件,我们会发现新的测试通过了(它们期望相同的硬编码字符串)。但如果我们忘记对这些案例进行解释,它们应该会失败。

现在我们有了一些测试用例,让我们考虑一下如何实现我们的编码算法。编写代码以使用我们在早期手动算法中使用的表是可能的,但考虑到每一行都只是一个字母表,由偏移数量的字符旋转,这似乎很复杂。事实证明(我问维基百科),我们可以使用模运算来组合字符,而不是进行表格查找。给定明文和关键字字符,如果我们将这两个字母转换为它们的数值(A 为 0,Z 为 25),将它们相加,取剩余的 mod 26,我们就得到了密文字符!这是一个简单的计算,但由于它是在逐个字符的基础上进行的,因此我们可能应该将它放在它自己的函数中。在此之前,我们应该为新函数编写一个测试:

from vigenere_cipher import combine_character
def test_combine_character():
    assert combine_character("E", "T") == "X"
    assert combine_character("N", "R") == "E"

现在我们可以编写代码使这个函数工作。老实说,在我得到这个函数完全正确之前,我必须运行测试好几次;首先我返回了一个整数,然后我忘了将字符从基于零的刻度移回正常的 ASCII 刻度。测试可用使得测试和调试这些错误变得容易。这是测试驱动开发的另一个好处。

def combine_character(plain, keyword):
    plain = plain.upper()
    keyword = keyword.upper()
    plain_num = ord(plain) - ord('A')
    keyword_num = ord(keyword) - ord('A')
    return chr(ord('A') + (plain_num + keyword_num) % 26)

既然combine_characters已经过测试,我想我们已经准备好实现encode功能了。但是,我们希望在该函数中的第一件事是重复使用与纯文本一样长的关键字字符串。让我们首先实现一个函数。哎呀,我的意思是让我们先实现测试!

def test_extend_keyword():
    cipher = VigenereCipher("TRAIN")
    extended = cipher.extend_keyword(16)
    assert extended == "TRAINTRAINTRAINT"

在编写这个测试之前,我希望将extend_keyword编写为一个独立的函数,接受一个关键字和一个整数。但是当我开始起草测试时,我意识到在VigenereCipher类上使用它作为辅助方法更有意义。这说明了测试驱动开发如何帮助设计更合理的 API。以下是方法实现:

    def extend_keyword(self, number):
        repeats = number // len(self.keyword) + 1
        return (self.keyword * repeats)[:number]

再一次,这进行了几次测试才正确。最后,我添加了第二个版本的测试,一个有 15 个字母,另一个有 16 个字母,以确保如果整数除法有偶数,它可以工作。

现在我们终于可以编写我们的encode方法了:

    def encode(self, plaintext):
        cipher = []
        keyword = self.extend_keyword(len(plaintext))
        for p,k in zip(plaintext, keyword):
            cipher.append(combine_character(p,k))
        return "".join(cipher)

这看起来是正确的。我们的测试套件现在应该通过了,对吗?

实际上,如果我们运行它,我们会发现两个测试仍然失败。我们完全忘记了空格和小写字符!我们编写这些测试来提醒我们是件好事。我们必须在方法的开头添加这一行:

        plaintext = plaintext.replace(" ", "").upper()

提示

如果我们在执行某个过程中有一个角落案例的想法,我们可以创建一个描述这个想法的测试。我们甚至不必实施测试;我们可以运行assert False来提醒我们以后执行它。失败的测试将永远不会让我们忘记这件事,它不能像提交任务一样被忽视。如果需要一段时间来修复实现,我们可以将测试标记为预期失败。

现在所有的测试都成功通过了。这一章相当长,因此我们将压缩解码示例。以下是一些测试:

def test_separate_character():
    assert separate_character("X", "T") == "E"
    assert separate_character("E", "R") == "N"

def test_decode():
    cipher = VigenereCipher("TRAIN")
    decoded = cipher.decode("XECWQXUIVCRKHWA")
    assert decoded == "ENCODEDINPYTHON"

以下是separate_character函数:

def separate_character(cypher, keyword):
    cypher = cypher.upper()
    keyword = keyword.upper()
    cypher_num = ord(cypher) - ord('A')
    keyword_num = ord(keyword) - ord('A')
    return chr(ord('A') + (cypher_num - keyword_num) % 26)

以及decode方法:

    def decode(self, ciphertext):
        plain = []
        keyword = self.extend_keyword(len(ciphertext))
        for p,k in zip(ciphertext, keyword):
            plain.append(separate_character(p,k))
        return "".join(plain)

这些方法与用于编码的方法有很多相似之处。编写并通过所有这些测试的好处是,我们现在可以返回并修改代码,知道它仍然安全地通过测试。例如,如果我们用这些重构方法替换现有的encodedecode方法,我们的测试仍然通过:

    def _code(self, text, combine_func):
        text = text.replace(" ", "").upper()
        combined = []
        keyword = self.extend_keyword(len(text))
        for p,k in zip(text, keyword):
            combined.append(combine_func(p,k))
        return "".join(combined)

    def encode(self, plaintext):
        return self._code(plaintext, combine_character)

    def decode(self, ciphertext):
        return self._code(ciphertext, separate_character)

这是测试驱动开发的最终好处,也是最重要的。一旦编写了测试,我们就可以随心所欲地改进代码,并且确信我们的更改没有破坏我们一直在测试的任何东西。此外,我们确切地知道重构何时完成:测试何时全部通过。

当然,我们的测试不可能全面测试我们需要的一切,;维护或代码重构仍然可能导致测试中未发现的未诊断错误。自动化测试不是万无一失的。但是,如果 bug 确实发生,仍然可以遵循测试驱动的计划;第一步是编写一个测试(或多个测试),复制或“证明”正在发生的 bug。这当然会失败。然后编写代码使测试停止失败。如果测试是全面的,那么 bug 将被修复,并且一旦我们运行测试套件,我们就会知道它是否再次发生。

最后,我们可以尝试确定我们的测试在这段代码上运行得有多好。安装了py.test覆盖率插件后,py.test –coverage-report=report告诉我们,我们的测试套件具有 100%的代码覆盖率。这是一个很好的统计数据,但我们不应该对此过于骄傲。当编码有数字的消息时,我们的代码还没有经过测试,因此它在此类输入中的行为是未定义的。

练习

实践测试驱动开发。这是你的第一个练习。如果您正在启动一个新项目,那么这样做会更容易,但是如果您有需要处理的现有代码,您可以从为您实现的每个新特性编写测试开始。随着您对自动化测试越来越着迷,这可能会变得令人沮丧。旧的、未经测试的代码将开始感觉僵硬和紧密耦合,并且将变得不适合维护;你会开始感觉到你所做的更改破坏了代码,而且由于缺乏测试,你无法知道。但是如果你从小处着手,添加测试将会改进,代码库会随着时间的推移而改进。

因此,为了让您对测试驱动的开发有所了解,请开始一个新的项目。一旦您开始意识到这些好处(您将意识到),并且意识到编写测试所花费的时间很快就可以通过更易于维护的代码得到恢复,您就需要开始为现有代码编写测试了。这是你应该开始做的时候,而不是之前。为我们“知道”的代码编写测试很无聊。要想对这个项目感兴趣是很困难的,直到你意识到我们认为正在工作的代码是多么的糟糕。

尝试使用内置的unittest模块和py.test编写相同的测试集。你喜欢哪一种?unittest与其他语言中的测试框架更为相似,而py.test可以说更像 Python。两者都允许我们轻松地编写面向对象的测试和测试面向对象的程序。

在我们的案例研究中,我们使用了py.test,但我们没有涉及任何使用unittest不容易测试的特性。尝试调整测试以使用测试跳过或 funcargs。尝试各种设置和拆卸方法,并将它们的用法与 funcargs 进行比较。你觉得哪个更自然?

在我们的案例研究中,我们有很多使用类似VigenereCipher对象的测试;请尝试重新编写此代码以使用 funcarg。它能保存多少行代码?

尝试对您编写的测试运行覆盖率报告。您是否错过了测试任何代码行?即使你有 100%的覆盖率,你是否测试了所有可能的输入?如果您正在进行测试驱动的开发,100%的覆盖率应该很自然,因为您将在满足该测试的代码之前编写测试。然而,如果为现有代码编写测试,则更有可能存在未经测试的边缘条件。

仔细考虑一些不同的值:当你期望全 1 时,空列表,与中间整数相比为 0 或 1 或无穷大,不舍入到精确小数位数的浮点,当你期望数字时的字符串,或者当你期望有意义的东西时无处不在的None值。如果您的测试覆盖了这些边缘情况,那么您的代码将处于良好状态。

总结

我们终于讨论了 Python 编程中最重要的主题:自动化测试。测试驱动开发被认为是一种最佳实践。标准库unittest模块为测试提供了一个很好的开箱即用的解决方案,而py.test框架有更多的 Python 语法。在我们的测试中,可以使用 mock 来模拟复杂类。代码覆盖率为我们提供了测试正在运行的代码数量的估计,但它并没有告诉我们测试了正确的东西。

在下一章中,我们将跳转到一个完全不同的主题:并发。