ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
[TOC] 熟练的Python程序员同意测试是软件开发最重要的方面之一。尽管本章差不多位于书的最后,但这不是事后的想法;到目前为止,我们所研究的一切都将有助于我们写测试。我们将研究: * 单元测试和测试驱动开发的重要性 * 标准单元测试模块 * `py.test`自动化测试套件 * `mock`模块 * 代码覆盖率 * 使用`tox`跨平台测试 ## 为什么测试 大量程序员已经知道测试他们的代码是件非常重要的事情。如果你是其中之一,请随意浏览这一节。在下一节你会发现——如何在Python中进行测试——包括更多内容。如果你不相信测试的重要性,我保证你的代码肯定有问题,你只是不知道而已。请继续读下去! </b> 有些人认为测试在Python代码中更重要,因为它动态特性;像Java和C++这样的编译语言偶尔会被认为会更“安全”一点儿,因为它们在编译时强制执行类型检查。然而,Python测试很少检查类型。它们进行值检查。它们确保在正确的时间设置了正确的属性,或者序列具有正确的长度、顺序和值。这些高层次的东西在任何语言中都需要被测试。Python程序员比其他语言的程序员做更多测试的真正原因是,用Python测试是如此容易! </b> 但是为什么要测试呢?我们真的需要测试吗?如果我们不测试会发生什么呢?为了回答这些问题,试想从头开始写一个井字游戏,不需要任何测试。先不要运行它,直到完全写完程序再开始测试。井字游戏非常容易实现,如果两个玩家都是人类玩家(不是人工智能)的话。你甚至不用去计算谁是赢家。现在运行你的程序。修复所有的错误。有多少个错误?我在井字游戏中发现了八个错误,并且我不确定我发现了所有的错误。你自己的程序中发现了多少错误? </b> 我们需要测试我们的代码,以确保它能够工作。运行程序,就像我们刚刚做得那样,然后修复错误,这是一种粗糙的测试形式。Python程序员可以编写几行代码并运行程序,以确保这些行正在运行他们所期望的。但是更改几行代码会影响程序的某些部分,开发者可能会没有意识到变化所带来的影响,因此不会测试它。此外,随着程序的增长,解释器获取代码的路线也在增长,很快手动完成所有的测试是不可能的。 </b> 为了解决这个问题,我们编写自动化测试。这些程序会自动通过其他程序或部分程序运行某些输入。我们可以很快测试程序,并涵盖更多的测试条件,这比程序员每次做一些测试改变范围更大。写测试有四个主要原因: * 确保代码以开发人员认为的方式工作 * 确保代码在我们进行更改时继续工作 * 确保开发人员理解这些要求 * 确保我们编写的代码有一个可维护的接口 第一点并不能证明写测试所花费的时间是合理的;我们可以简单地直接在交互式解释器中测试代码。但是我们在多次执行测试操作序列时,不得不做同样的事情,一次自动化这些步骤花费的时间更少,然后在必要时运行它们。每当我们更改代码,无论是在初始开发还是维护版本,运行测试是一个好主意。当我们有一套全面的自动化测试,我们可以在代码更改后运行它们,确保我们没有无意中破坏任何测试过的代码。 </b> 最后两点更有趣。当我们编写代码测试时,它会帮助我们设计代码采用的API、接口或模式。因此,如果我们误解了需求,写测试有助于发现这种误解。另一方面,如果我们不确定如何设计一个类,我们可以写一个测试,与那个类交互,这样我们可以知道什么是最自然的测试方法。事实上,在我们编写代码之前编写测试通常是有益的。 ### 测试驱动的开发模式 “先写测试”是测试驱动开发的口头禅。测试驱动开发将“未经测试的代码就是坏代码”的概念向前推进了一步,建议只有未写的代码才可以不经过测试。写完某段代码的测试之前不要写任何代码。所以第一步是编写一个测试来证明代码可以工作。显然,测试将会失败,因为代码还没有编写。然后编写代码,确保测试通过。然后为下一个代码段编写另一个测试。 </b> 测试驱动开发很有趣。它允许我们建立一些小难题来解决。然后我们编写代码来解决这些难题。然后我们设计一个更复杂的难题,编写代码来解决新的难题,而不是解决前一个难题。 </b> 测试驱动方法有两个目标。首先是确保测试真的写下来了。在我们写完代码后,很容易说:“嗯,它似乎奏效了。我不需要为此写任何测试。这只是一个小小的变化,没有什么可能会出问题的。”如果在我们编写代码之前已经写完测试代码,我们将确切知道它什么时候起作用(因为测试将会通过),并且我们将会知道将来它是否会被我们或其他人所做的改变破坏。 </b> 其次,编写测试首先迫使我们考虑代码是如何进行交互的。它告诉我们对象需要什么方法以及属性将如何被访问。它帮助我们将最初的问题分解成更小的、可测试的问题,然后将测试过的解决方案重新组合成更大的也测试过的解决方案。因此,测试可以成为设计过程的一部分。通常,如果我们为一个新对象写测试,我们会发现设计中的异常,迫使我们考虑软件中新的特征。 </b> 作为一个具体的例子,想象编写使用对象关系映射器将对象属性存储在数据库中的代码。通常为这些对象使用自动分配的数据库ID。我们的代码可能出于不同目的使用该ID。如果我们正在为这样的代码编写测试,在我们编写之前,我们可能会意识到我们的设计是错误的,因为对象在保存到数据库之前,不会有这样的ID。如果我们想在测试中,在不保存对象的前提下操纵一个对象,它会在我们根据错误的前提编写代码之前,发现这个问题。 </b> 测试让软件变得更好。在我们发布软件之前写测试,最好在最终用户看到或购买错误版本之前完成(在我已经工作过的一些公司,存在“用户可以测试它”的理念,虽然它们依旧繁荣,但这不是健康的商业模式!)。在我们写软件之前写测试会让它变得更好。 ## 单元测试 让我们从Python的内置测试工具开始探索。这个工具提供了单元测试的一个公共接口。单元测试侧重于在任何一个测试中尽可能测试最少量的代码。每一个测试只对可用代码总量的一个单位进行测试。 </b> 不出所料,这个Python库被称为`unittest`。它提供了几个创建和运行单元测试的工具,最重要的是测试用例`TestCase`类。这个类提供了一组方法,允许我们比较值、设置测试、完成后清理。 </b> 当我们想要为特定任务编写一组单元测试时,我们创建一个`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`主函数将给出以下输出: ``` . -------------------------------------------------------------- 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表示第二个测试失败。然后,在最后,它给我们一些信息输出,告诉我们测试失败的方式和地点,以及失败次数的摘要。 </b> 我们可以在一个`TestCase`类上添加任意多的测试方法;只要方法名以`test`开始,测试运行程序将对每个方法做单独测试。每个测试完全独立于其他测试。结果或先前测试中的计算对当前测试没有影响。编写好的单元测试的关键是尽可能缩短每个测试方法,每个测试用例仅测试一小段代码。如果你的代码似乎没有自然中断成这样的可测试片段,可能是你需要重新思考设计的一个信号。 ### 断言方法 测试用例的总体布局是给某些变量设置一些已知的值,运行一个或多个函数、方法或过程,然后使用`TestCase`断言方法“证明”正确的预期返回或计算结果。 </b> 有几种不同的断言方法可以用来确认特定的结果已经实现了。我们刚刚看到了`assertEqual`,如果两个参数没有通过等式检查,将会引发一个测试失败。反过来,`assertNotEqual`,如果两个参数确实相等,则测试失败。`assertTrue`和`assertFalse`,每个方法都接受一个表达式,如果表达式没能通过`if`测试,则测试失败。这些测试不检查布尔值是真还是假。相反,它们测试相同的条件,就像使用`if`语句一样:False,None,0,或者空列表、字典、字符串、集合或元组会将传递给`assertFalse`方法,而非零数字、包含值的容器或真值则调用`assertTrue`方法时会成功。 </b> 有一个`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() ``` 上下文管理器允许我们以通常的方式编写代码(通过调用函数或直接执行代码),而不是必须在另一个函数调用中包装函数调用。 </b> 下表还总结了其他几种断言方法: |方法 |描述 | | --- | --- | | assertGreater assertGreaterEqual <p> assertLess <p> assertLessEqual | 接受两个可比较的对象,并确保命名不等式成立。 | | assertIn <p> assertNotIn | 确保元素是(或不是)一个容器对象中的元素。 | | assertIsNone <p> assertIsNotNone | 确保一个元素是(或不是)精确None(但不是另一个虚假值)。 | | assertSameElements | 确保两个容器对象具有相同的元素,忽略顺序。 | | assertSequenceEqualassertDictEqual <p>assertSetEqual <p>assertListEqual <p>assertTupleEqual | 确保两个容器具有相同顺序的相同元素。如果测试失败,显示代码差异,比较两个列表,看看它们有什么不同。最后四种方法也用于测试列表的类型。 | 每个断言方法都接受一个名为`msg`的可选参数。如果有供应这个参数,如果断言失败,它会包含在错误消息中。这有助于澄清预期会发生什么,或者解释哪里可能发生了错误,从而导致断言失败。 ### 减少样本代码和清理 写了几个小测试后,我们经常发现对一些相关的测试,我们必须做同样的代码设置。例如,下面的列表子类有三种统计计算方法: ``` 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`中指定的值(如果没有,列表中将有两个4,`mode`方法将返回三个值)。这表明`setUp`在每次测试前被单独调用,以确保测试类从头开始。测试可以以任何顺序执行,一个测试的结果不应该依赖于其他测试中的结果。 </b> 除了`setUp`方法,`TestCase`还提供了一个无参数`tearDown`方法,它可用于在类上的每个测试运行后进行清理。如果除了让对象被垃圾回收之外清理还需要任何东西,这会很有用。例如,如果我们正在测试执行文件输入/输出的代码,我们的测试可能会创建含有测试副作用的新文件:`tearDown`方法可以移除这些文件,确保系统处于测试运行前的状态。测试用例应该永远不要有副作用。通常,我们将被测试方法分组到单独的`TestCase`子类中,这取决于这些方法有什么共同的设置代码。具有相同或相似设置的测试将被放在一个类中,而具有不相关设置的方法则分到另一个类中。 无关设置进入另一个类。 ### 组织和运行测试 单元测试的集合很快就会变得非常庞大和笨拙。一次加载和运行所有测试很快变得复杂。这是一个单元测试的主要目标;在我们的程序上运行所有的测试应该是轻松愉快的,并快速回答“我最近的改变破坏已有的测试了吗?” </b> Python的`discover`模块基本上寻找当前文件夹或子文件夹中任何名称以`test`开头的模块。如果它从中发现任何`TestCase`对象,测试被执行。这是一种无痛的方式来确保我们不会错过任何测试。要使用它,请确保你的测试模块已命名为`test_*.py`,然后运行命令`python3 -m unittest discover`。 ### 忽略中断的测试 有时,测试会失败,但是我们不希望测试套件报告失败。这可能是因为,我们已经为一个坏的或未完成的特性编写了测试,但是我们目前并不专注于改进它。更常见的情况是,这个功能仅在特定平台、特定Python版本或特定库的高级版上可用。Python为我们提供了一些装饰器来标记在已知条件下,预期会失败或跳过的测试。 </b> 这些装饰器是: * expectedFailure() * skip(reason) * skipIf(condition, reason) * skipUnless(condition, reason) 这些都是使用Python装饰器语法来应用的。第一个不接受参数,并简单地告诉测试运行者当测试失败时,不要将测试记录为失败。`skip`方法走得更远,甚至懒得运行测试。它需要一个字符串参数来描述测试被跳过的原因。另外两个装饰器接受两个参数,一个是布尔表达式,表示测试是否应该运行,另一个是与`skip`方法类似的描述。在使用中,这三个装饰器可以这样应用: ``` 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`的条件在我的系统上是真的。 ## 使用py.test测试 Python`unittest`模块需要大量样板代码来设置初始化测试。它基于非常流行的Java JUNit测试框架。它甚至使用相同的方法名(你可能已经注意到它们不符合PEP-8命名标准,PEP-8建议使用下划线而不是驼峰方法来分割名称中的单独单词)和测试布局。虽然这对于在Java中测试是有效的,但它不一定是Python测试的最佳设计。 </b> 因为Python程序员喜欢他们的代码优雅简单,所以他们已经在标准库之外开发了其他测试框架。两个最流行的框架是`py.test`和`nose`。前者更健壮,Python3将长期支持,所以我们将讨论它。 </b> 由于`py.test`不是标准库的一部分,你需要自己下载并安装;你可以从`http://pytest.org/`的`py.test`主页上获得各种解释器全面的安装说明,但是你通常可以把这些事情甩给更常见的python包安装程序,`pip`。只需在命令行上键入`pip install pytest`,你就可以喝茶去了(译注:如果使用`pycharm`,`pycharm`提供了4种可选的测试工具,包括 `unitTest`和`pytest`)。 </b> `py.test`的布局与`unittest`模块有很大不同。它不要求测试用例是类。相反,它利用了Python函数是对象的优势,允许任何正确命名的函数像测试一样运行。它不需要提供一组等值断言的客户方法,它使用了断言语句来验证结果。这使得测试更加可读和可维护。当我们运行`py.test`时,它将从当前文件夹开始,在当前文件夹或子包中,搜索任何名称以字符`test_`开头的模块。若模块中含有`test`开头的函数,它们将作为单独的测试。此外,如果模块中有任何类的名称以`Test`开头,该类中以`test_`开头的任何方法也将被测试。 </b> 让我们将之前写给`unittest`的最简单的示例移植到`py.test`: ``` def test_int_float(): assert 1 == 1.0 ``` 对于完全相同的测试,我们只需要写两行更加可读的代码,而在 `unittest`示例中,我们需要写六行。 </b> 然而,我们没有被禁止编写基于类的测试。类可用于将相关测试分组在一起,或者用于需要访问类中相关属性或方法的测试。下面的示例显示了一个带有测试通过和失败的扩展类;我们将看到错误输出比`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`表示失败。 </b> 所有测试运行后,将显示每个测试的错误输出。它呈现了一个局部变量摘要(在这个例子中只有一个局部变量:`self`参数被传递到函数中),错误发生的源代码,以及错误消息摘要。此外,如果抛出的是异常,而不是`AssertionError`,`py.test`将为我们提供完整的回溯,包括源代码引用。 </b> 默认情况下,如果测试成功,`py.test`会抑制打印语句的输出。这对测试调试很有用;当测试失败时,我们可以添加`print`测试语句,检查测试进行时特定变量和属性的值。如果测试失败,输出这些值有助于诊断。但是,一旦测试成功,`print`语句输出将不会显示,它们很容易被忽略。我们不必通过移除`print`语句来“清理”输出。如果测试由于未来的变化而再次失败,调试输出将立即可用。 ### 一种设置和清理的方法 `py.test`支持类似于`unittest`中使用的`setup`和`teardown`方法,但是它提供了更多的灵活性。我们将简短地讨论这些,因为它们是很常见的,但是它们没有在`unittest`模块中使用得像`py.test`那样广泛。`py.test`为我们提供了强大的funcargs功能,我们将在下一节讨论。 </b> 如果我们正在编写基于类的测试,我们可以使用两种方法,称为`setup_method`和`teardown_method`,基本上与在unittest中调用`setUp`和`tearDown`相同。它们在类中的每个测试方法之前和之后被调用,执行设置和清理任务。与`unittest`有一个不同之处。两种方法都接受一个参数:表示正在调用方法的函数对象。 </b> 此外,`py.test`还提供了其他`setUp`和`tearDown`函数,为我们提供了更多控制何时执行设置和清除代码的功能。`setup_class`和`teardown_class`方法应该是类方法;他们只接受一个参数(没有`self`参数),表示所讨论的类。 </b> 最后,我们有`setup_module`和`teardown_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`的角度来看,这两个子类不但有两种测试方法,而且有两个`setup`和两个`teardown`方法(分别对应类级别和方法级别)。 </b> 如果我们使用py.test运行这些测试,并禁用`print`的输出抑制(通过传递`-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`和`teardown`方法分别在测试开始和结束时分别执行。然后运行唯一的模块级测试函数。接下来,执行第一个类的`setup`方法,然后执行该类的两个测试。这些测试都分别包装在单独的`setup_method`和`teardown _ method`调用中。测试执行后,调用类`teardown`方法。同样过程也发生在第二个类中,在`teardownn_module`方法最终调用之前,执行同样的测试。 ### 一种完全不同的设置变量的方法 各种`setup`和`teardown`函数最常见的用途之一是确保某些类或模块变量在运行每个测试方法以前具有已知值。 </b> `py.test`提供了一种完全不同的方法,使用**funcargs**来实现这一点 ,funcargs是函数参数的缩写。funcargs基本上是命名变量,它在测试配置文件中预先定义。这允许我们将配置从执行测试分离出来,允许funcargs跨多个类和模块中使用。 </b> 为了使用它们,我们将参数添加到测试函数中。参数的名称用于在特殊命名函数中查找特定的参数。例如,如果当我们演示`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`函数创建的。如果这个函数参数是多个模块所需要的,它也可以在一个名为`conftest.py`的文件中定义。通过`py.test`将最新的py文件解析为加载到任何“全局”测试配置;这是一种一次搞定的定制`py.test`体验。 </b> 和其他`py.test`特性一样,返回`funcarg`的工厂名称很重要;funcarg是名为`pytest_funcarg__<identifier>`的函数,其中`<identifier>`是一个有效的变量名,可以用作测试函数的参数。该函数接受一个神秘的请求参数,并返回一个作为参数传递给各个测试函数的对象。这个funcarg在单个测试函数的每次调用中重新创建;例如,这允许我们,在一次测试中更改列表并知道它将在下一次测试中重置为原始值。(译注:pytest_2.3已经不支持这种定义方法,需要加入`@pytest.fixture(scope="session")`,具体可参考[pytest-2.3: reasoning for fixture/funcarg evolution](https://docs.pytest.org/en/latest/funcarg_compare.html),上段代码应该改为如下所示,后面示例也需要更改)。 ``` from stats import StatsList @pytest.fixture(scope="session") def 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] ``` Funcargs不仅仅可以返回基本变量。传递到funcarg工厂的该请求对象提供了一些非常有用的、修改funcarg行为的方法和属性。模块`module`、`cls`和函数`function`属性允许我们查看哪个测试正在请求funcarg。配置属性允许我们检查命令行参数和其他配置数据。 </b> 更有趣的是,request对象提供了允许我们额外清理funcarg的方法,或者在测试中重用它,否则将忽略被归入特定范围的`setup`和`teardown`方法。 </b> `request.addfinalizer`方法接受执行以下操作的回调函数(译注:回调的意思是,执行完测试后,再执行一些函数。[通俗理解“回调函数”](https://blog.csdn.net/angciyu/article/details/80794273),这个帖解释的很通俗!),调用使用funcarg的每个测试函数后进行清理。这提供了`teardown`方法的等价方法,允许我们清理文件,关闭连接,清空列表或重置队列。例如,下面的代码通过创建临时目录funcarg测试`os.mkdir`: ``` import tempfile import shutil import os.path @pytest.fixture(scope="session") def 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 ``` funcarg为要为创建的文件创建一个新的空临时目录。然后它添加一个`finalizer`调用,当测试完成时删除该目录(使用`shutil.rmtree`递归删除目录和其中的任何内容)。 </b> 然后文件系统回到其开始时的状态。 </b> 我们可以使用`request.cached _ setup`方法创建持续时间超过一次测试的函数参数变量。在设置昂贵的、可以被多个测试重用的操作时,这是非常有用的,只要资源重用没有打破测试的原子或单元性质(这样一个测试不依赖并不受先前测试的影响)。例如,如果我们要测试以下响应服务器,我们可能希望在单独的进程中只运行服务器的一个实例,然后让多个测试连接到该实例: ``` 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 @pytest.fixture(scope="session") def 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") @pytest.fixture(scope="session") def 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' ``` 我们在这里创建了两个funcargs。第一个在单独的进程中运行响应服务器,并返回进程对象。第二个为每个测试实例化一个新的套接字对象测试,并在测试完成后关闭它。第一个funcarg是我们目前感兴趣的。它看起来很像传统的单元测试`setup` 和`teardown`。我们创建了一个不接受参数并返回正确参数的`setup`函数;在这种情况下,测试实际上忽略了一个过程对象,因为他们只关心服务器是否在运行。然后,我们创建一个清理函数(函数的名称是任意的,因为它只是我们传递给另一个函数的对象),它接受一个参数:`setup`函数返回的参数。此清理代码终止进程。 </b> 父函数返回调用`request.cached_setup`的结果,而不是直接返回funcarg。它接受`setup` 和`teardown`函数(我们刚刚创建的)的两个参数和一个范围`scope`参数。最后一个参数应该是三个字符串“function”、“module”或“session”之一;它决定了参数将被缓存多久。在本例中,我们将其设置为“session”,因此它被缓存用于整个`py.test`运行的持续时间。该进程不会被终止或重新启动,直到所有测试都运行完毕。当然,“模块”范围只为模块测试缓存它,而“函数”作用域更像一个普通的funcarg,因为它在每个测试函数运行后复位。 (译注:这段代码还是有点小问题,就是`request.cached_setup`也被`pytest_2.3`废弃了,用`addfinalizer`代替。python这点儿挺令人痛苦的,就是版本更新太频繁了。改过的代码如下。) ``` import subprocess import socket import time import pytest @pytest.fixture(scope="session") def echoserver(request): p = subprocess.Popen( ['python', 'echo_server.py']) time.sleep(1) request.addfinalizer(lambda: p.terminate()) return p @pytest.fixture(scope="session") def 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' ``` (译注:测试结果如下,`test_echo2`为何测试失败,我还没搞懂……-……) ``` Testing started at 2:39 PM ... /home/a/PycharmProjects/opp/venv/bin/python /snap/pycharm-community/132/helpers/pycharm/_jb_pytest_runner.py --path /home/a/PycharmProjects/opp/test_class.py Launching pytest with arguments /home/a/PycharmProjects/opp/test_class.py in /home/a/PycharmProjects/opp ============================= test session starts ============================== platform linux -- Python 3.5.2, pytest-5.0.1, py-1.8.0, pluggy-0.12.0 rootdir: /home/a/PycharmProjects/oppcollected 2 items test_class.py .F test_class.py:31 (test_echo2) b'' != b'def' Expected :b'def' Actual :b'' <Click to see difference> echoserver = <subprocess.Popen object at 0x7f4440e7ac88> clientsocket = <socket.socket fd=10, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 47048)> def test_echo2(echoserver,clientsocket): clientsocket.send(b"def") > assert clientsocket.recv(3) == b'def' E AssertionError: assert b'' == b'def' E Use -v to get the full diff test_class.py:34: AssertionError [100%] =================================== FAILURES =================================== __________________________________ test_echo2 __________________________________ echoserver = <subprocess.Popen object at 0x7f4440e7ac88> clientsocket = <socket.socket fd=10, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 47048)> def test_echo2(echoserver,clientsocket): clientsocket.send(b"def") > assert clientsocket.recv(3) == b'def' E AssertionError: assert b'' == b'def' E Use -v to get the full diff test_class.py:34: AssertionError ====================== 1 failed, 1 passed in 1.03 seconds ====================== Process finished with exit code 0 Assertion failed Assertion failed ``` ### 使用py.test忽略测试 与`unittest`模块一样,我们经常需要跳过`py.test`(译注:有时候是`py.test`,有时候是`pytest`,两者都可以,这叫人困惑)中的测试,原因很多:被测试的代码还没有被编写,测试只是在某些解释器或操作系统上运行而已,或者测试很耗时,只在某些情况下运行。 </b> 我们可以使用`py.test.skip`函数在代码中的任何一点跳过测试。它接受一个单一的参数:一个描述它为什么被跳过的字符串。这个函数可以在任何地方被调用;如果我们在测试函数中调用它,测试将被跳过。如果我们在模块级别调用它,那么该模块中的所有测试都将被跳过。如果我们在`funcarg`函数中调用它,所有调用该`funcarg`的测试都将被跳过。 </b> 当然,很多时候,我们希望在所有这些地方,只有确定的条件成立或不成立的情况下,才跳过这些测试。因为我们可以在Python代码的任何地方执行跳过函数,我们可以在`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足够新的版本。 </b> 因为基于某个条件跳过单独的测试方法或函数,是跳过测试最常见的用法之一,py.test提供了一个方便的装饰器,允许我们在一行中完成这项工作。装饰器接受单个字符串,它可以包含任何计算结果为布尔值的可执行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提供一个更可测试的接口(这通常意味着一个更可用的接口),但有时我们发现自己编写的测试代码需要大量样板代码用于设置一些对象,这些对象仅仅是偶然与测试代码相关的。 </b> 例如,假设我们有一些代码用来跟踪航班状态,通过键值存储(如`redis`或`memcache`),我们可以存储时间戳和最近的状态。这段代码的基本版本可能如下所示: ``` 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) ``` 在`change_status`方法中,我们应该测试很多东西。我们应该检查如果传入了错误状态,是否会引发适当的错误。我们需要确保它将状态转换为大写。在`redis`对象上调用`set()`方法时,我们可以看到键和值有正确的格式。 </b> 然而,有一件事我们不必在单元测试中检查,那就是redis对象是否正确存储数据。这绝对应该在集成或应用程序测试中进行测试的项目,但是在单元测试级别,我们可以假设`py-redis`开发人员已经测试了他们的代码,这个方法做了我们想要的。作为惯例,单元测试应该是自包含的,而不是依赖于外部资源,例如正在运行的`Redis`实例。 </b> 相反,我们只需要测试`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`方法创建了一个`mock`对象,确保从不调用它。如果它被调用了,那就意味着我们的异常处理代码中有一个错误。 </b> 在这种情况下,简单地替换方法是可行的,因为被替换的对象最终被销毁了。然而,我们经常想仅在测试期间替换一个函数或方法。例如,如果我们想在mock方法中测试时间戳格式化,我们需要确切知道`datetime.datetime.now()`返回的结果。但是,该值随运行而变化。我们需要一些方法把它固定在一个特定的值上,这样我们就可以确定地测试它。 </b> 还记得猴子补丁吗?将库函数临时设置为特定的值是对它的极好利用。mock库提供了一个补丁上下文管理器,允许我们用模拟对象替换现有库中的属性。当上下文管理器退出时,原始属性会自动恢复,以免影响其他测试用例。这里有一个例子: ``` 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`函数将起作用(译注:这里有点儿小困惑,确实应该要在之前构建对象啊)! </b> `with`语句邀请补丁程序用模拟对象替换`datetime.datetime`模块,该对象作为值`dt`返回。模拟对象的好处是无论何时访问该对象的属性或方法时,它都会返回另一个模拟对象。因此,当我们现在访问`dt.now`时,它给了我们一个新的模拟对象。我们将该对象的`return_value`设置为我们的`fake_now`对象;这样,无论何时调用`datetime.datetime.now`函数,它将返回我们的对象,而不是新的模拟对象。 </b> 然后,在用已知值调用我们的`change_status`方法后,我们使用模拟类的`assert _called_once_with`函数,以确保`now`函数确实是没有参数情况下只被调用了一次。然后第二次调用`now`函数,证明`redis.set`方法按照我们预期的参数格式被调用。 </b> 前面的例子很好地说明了如何编写测试来指导我们API设计。`FlightStatusTracker`对象乍看起来很合理;我们在构建这个对象时建造了一个`redis`连接,我们需要时调用它。然而,当我们为这段代码编写测试时,我们发现即使我们模仿`FlightStatusTracker`上的`self.redis`变量,我们仍然不得不建造`redis`连接。如果没有运行`Redis`服务器,这个调用实际上会失败,测试也一同失败了。 </b> 我们可以在`setUp`方法中通过模拟`redis.StrictRedis `类,返回一个`mock`对象来解决这个问题。然而,一个更好的想法可能是重新思考我们的例子。也许我们应该允许用户传入一个`redis`实例,而不是在__init__内部构造redis实例,如下例所示: ``` def __init__(self, redis_instance=None): self.redis = redis_instance if redis_instance else redis.StrictRedis() ``` 这允许我们在测试时传递一个模拟对象,所以`StrictRedis`方法从未被构建。然而,这也允许任何与`FlightStatusTracker`交流的客户端代码传递自己的`redis`实例。客户端有各种各样的这么做的原因。他们可能已经为他们代码的一部分建造了一个`redis`实例。他们可能已经创建了一个`redis`API的优化实现。也许他们有一个用于记录内部监控系统的测量结果。通过编写单元测试,我们发现了一个用例,它使我们的应用API从一开始就更加灵活,而不用等待客户要求我们支持他们的特别需求。 </b> 这是对模拟代码奇迹的简要介绍。模拟对象是自Python 3.3以来的标准`unittest`库的一部分,但是从这些例子中可以看出,它们也可以与`py.test`和其他库一起使用。模拟对象还有其他的优点,随着代码越来越复杂,你可能会用到这些高级功能。例如,你可以使用`spec`参数邀请一个模仿对象来模仿一个现有的类,如果代码试图访问不存在于模仿类中的一个属性,它将抛出一个错误。你还可以构造模拟方法,每次调用时,都会传递一个列表作为`side_effect`参数,然后返回不同的参数。`side_effect`参数非常通用;当调用模拟或引发异常时,你也可以使用它来执行任意函数。 </b> 总的来说,我们应该对嘲笑相当吝啬。如果我们发现自己在给定的单元测试中模拟了多个元素,我们可能最终测试的是模拟框架而不是我们真正的代码。这毫无用处;毕竟,模拟对象已经经过了很好的测试!如果我们的代码做了这么多,这可能是另一个迹象,说明我们测试的API设计得很差。模仿应该存在于测试中的代码和与之接口的库之间的边界上。如果不是这样,我们可能需要更改API,以便在不同的地方重新绘制边界。 ## 多少测试才算是充足的? 我们已经声明,未经测试的代码是坏代码。但是我们如何知道我们的代码测试得有多好?我们如何知道我们的代码实际上有多少正在测试,有多少测试是失败的?第一个问题更重要,但是很难回答。即使我们知道已经测试了应用程序的每一行,我们仍然不知道我们是否已经正确地测试了它。例如,如果我们编写一个stats测试,只检查当我们提供一个整数列表时会发生什么,如果它被用在浮点型、字符串或者自制对象的列表上,它仍然会失败。设计完整测试套件的责任仍然在于程序员。 </b> 第二个问题——我们有多少代码实际上正在测试——这个很容易验证。代码覆盖率本质上是一个由程序执行的代码行数的估计。如果我们知道这个数字和程序中的行数,我们可以估计出代码被测试或被覆盖的真实百分比。如果我们额外有一个指标来说明哪些行没有经过测试,我们可以更容易地编写新的测试,以确保这些代码不那么脆弱。 </b> 最受欢迎的测试代码覆盖率的工具,非常值得记忆的是`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`选项传递给报告命令,它还会添加一个如下所示的列: ``` Missing ----------- 8-12, 15-23 ``` 此处列出的行范围标识了stats模块中没有在测试运行期间执行的代码行。 </b> 我们刚刚在上运行代码覆盖工具的例子使用了与我们本章前面创建的相同的统计模块。然而,它故意使用单一测试,文件中的许多代码并没有测试。测试如下: ``` 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() ``` This code doesn't test the median or mode functions, which correspond to the line numbers that the coverage output told us were missing. 该代码并没有测试中值函数或模函数,覆盖率输出告诉我们那些没有经过测试对应的行号。 The textual report is suficient, but if we use the command coverage html, we can get an even fancier interactive HTML report that we can view in a web browser. The web page even highlights which lines in the source code were and were not tested. Here's how it looks: 文本报告是足够的,但是如果我们使用命令覆盖`coverage html`,我们可以获得一个更好的交互式HTML报告,我们可以在网络浏览器中查看。这个网页甚至突出显示了源代码中哪些行被测试过,哪些行没有被测试过。看起来像这样: ![](https://box.kancloud.cn/dd18fb193c9dd96ad6ccb883c1e0a688_308x396.png) 我们也可以将`coverage.py`模块与`py.test`结合使用。我们需要使用`pip install pytest-coverage`安装用于代码覆盖率的`py.test`插件`pytest-coverage`。该插件为`py.test`添加了几个命令行选项,最有用的是`--cover-report`,可以设置为`html`、`report`或`annotation`(实际上后者修改源代码以突出显示任何未覆盖的行)。 </b> 不幸的是,如果我们能本节运行一次覆盖率报告,我们会发现我们还没有涵盖关于代码覆盖率的大部分知识!我们可以使用覆盖率API从我们的程序(或测试套件)内部管理代码覆盖率。`coverage.py`接受多种我们没有触及的配置选项。我们还没有讨论语句覆盖率和分支覆盖率(后者更有用,是`coverage.py`最新版本中的默认值)或其他类型的代码覆盖率。 </b> 记住,100%代码覆盖率是我们应该追求的崇高目标,但100%仍然不够!仅仅一个语句被测试,并不意味着它对所有可能输入都进行了适当的测试。 ## 个案研究 Let's walk through test-driven development by writing a small, tested, cryptography application. Don't worry, you won't need to understand the mathematics behind complicated modern encryption algorithms such as Threeish or RSA. Instead, we'll be implementing a sixteenth-century algorithm known as the Vigenère cipher. The application simply needs to be able to encode and decode a message, given an encoding keyword, using this cipher. First, we need to understand how the cipher works if we apply it manually (without a computer). We start with a table like this: 让我们通过编写一个小的、经过测试的密码术来完成测试驱动的开发 应用程序。别担心,你不需要理解后面的数学 复杂的现代加密算法,如三进制或RSA。相反, 我们将实施一种16世纪的算法,称为维格纳密码。 在给定的情况下,应用程序只需要能够对消息进行编码和解码 使用该密码的编码关键字。 首先,如果我们手动应用密码,我们需要了解它是如何工作的 (没有电脑)。我们从这样一张桌子开始: ![](https://box.kancloud.cn/618da418a80308d911205a85855c32cc_377x426.png) Given a keyword, TRAIN, we can encode the message ENCODED IN PYTHON as follows: 1. Repeat the keyword and message together such that it is easy to map letters from one to the other: 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. For each letter in the plain text, ind the row that begins with that letter in the table. 3. Find the column with the letter associated with the keyword letter for the chosen plaintext letter. 4. The encoded character is at the intersection of this row and column. For example, the row starting with E intersects the column starting with T at the character X. So, the irst letter in the ciphertext is X. The row starting with N intersects the column starting with R at the character E, leading to the ciphertext XE. C intersects A at C, and O intersects I at W. D and N map to Q while E and T map to X. The full encoded message is XECWQXUIVCRKHWA. Decoding basically follows the opposite procedure. First, ind the row with the character for the shared keyword (the T row), then ind the location in that row where the encoded character (the X) is located. The plaintext character is at the top of the column for that row (the E). 给定关键字TRAIN,我们可以用PYTHON编码消息 如下所示: 1.一起重复关键字和消息,以便于映射字母 从一个到另一个: 英、中、英、法、英、法、俄、法、俄、西 2.对于纯文本中的每个字母,找到以该字母开头的行 在桌子上。 3.查找包含与关键字字母相关联的字母的列 选择明文字母。 4.编码字符位于该行和列的交叉点。 例如,以E开头的行与以T开头的列相交于 字符x。所以,密文中的第一个字母是x。以N开头的行 在字符E处与以R开头的列相交,得到密文 XE。C与A相交于C,O与I相交于西经和北经,映射到Q,而E和T 映射到x。完整编码的消息是XECWQXUIVCRKHWA。 解码基本上遵循相反的过程。首先,找到带有 字符,然后找到该行中的位置 编码字符(X)所在的位置。明文字符位于 该行的列顶部(E)。 ### 实现它 Our program will need an encode method that takes a keyword and plaintext and returns the ciphertext, and a decode method that accepts a keyword and ciphertext and returns the original message. But rather than just writing those methods, let's follow a test-driven development strategy. We'll be using py.test for our unit testing. We need an encode method, and we know what it has to do; let's write a test for that method irst: 我们的程序将需要一种采用关键字和明文的编码方法 返回密文,以及接受关键字和密文的解码方法 并返回原始消息。 但是,让我们遵循测试驱动的开发,而不仅仅是编写这些方法 策略。我们将使用py.test进行单元测试。我们需要一种编码方法, 我们知道这有什么关系;让我们先为这个方法写一个测试: ``` def test_encode(): cipher = VigenereCipher("TRAIN") encoded = cipher.encode("ENCODEDINPYTHON") assert encoded == "XECWQXUIVCRKHWA" ``` This test fails, naturally, because we aren't importing a VigenereCipher class anywhere. Let's create a new module to hold that class. Let's start with the following VigenereCipher class: 这个测试自然会失败,因为我们没有导入VigenereCipher类 任何地方。让我们创建一个新模块来容纳该类。 让我们从下面的VigenereCipher类开始: ``` class VigenereCipher: def __init__(self, keyword): self.keyword = keyword def encode(self, plaintext): return "XECWQXUIVCRKHWA" ``` If we add a from vigenere_cipher import VigenereCipher line to the top of our test class and run py.test, the preceding test will pass! We've inished our irst test-driven development cycle. Obviously, returning a hardcoded string is not the most sensible implementation of a cipher class, so let's add a second test: 如果我们在顶部添加一个从vigenere_cipher导入VigenereCipher行 测试类并运行py.test,前面的测试将通过!我们已经完成了第一次 测试驱动的开发周期。 显然,返回硬编码字符串不是最明智的实现 密码类,所以让我们添加第二个测试: ``` def test_encode_character(): cipher = VigenereCipher("TRAIN") encoded = cipher.encode("E") assert encoded == "X" ``` Ah, now that test will fail. It looks like we're going to have to work harder. But I just thought of something: what if someone tries to encode a string with spaces or lowercase characters? Before we start implementing the encoding, let's add some tests for these cases, so we don't we forget them. The expected behavior will be to remove spaces, and to convert lowercase letters to capitals: 啊,现在考试要失败了。看来我们得更加努力工作了。但是我 想想看:如果有人试图用空格或 小写字符?在我们开始实现编码之前,让我们添加一些 测试这些病例,所以我们不会忘记。预期的行为是 删除空格,并将小写字母转换为大写: ``` 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" ``` If we run the new test suite, we ind that the new tests pass (they expect the same hardcoded string). But they ought to fail later if we forget to account for these cases. 如果我们运行新的测试套件,我们会发现新的测试通过了(他们期望相同 硬编码字符串)。但是,如果我们忘记考虑这些情况,它们以后应该会失败。 Now that we have some test cases, let's think about how to implement our encoding algorithm. Writing code to use a table like we used in the earlier manual algorithm is possible, but seems complicated, considering that each row is just an alphabet rotated by an offset number of characters. It turns out (I asked Wikipedia) that we can use modulo arithmetic to combine the characters instead of doing a table lookup. Given plaintext and keyword characters, if we convert the two letters to their numerical values (with A being 0 and Z being 25), add them together, and take the remainder mod 26, we get the ciphertext character! This is a straightforward calculation, but since it happens on a character-by-character basis, we should probably put it in its own function. And before we do that, we should write a test for the new function: 现在我们有了一些测试用例,让我们考虑如何实现我们的编码 算法。编写代码来使用像我们在早期手动算法中使用的表是 可能,但是看起来很复杂,考虑到每行只是一个旋转的字母表 偏移字符数。结果(我问维基百科)我们可以使用 模运算来组合字符,而不是查找表。考虑到 明文和关键字字符,如果我们把这两个字母转换成数字 值(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" ``` Now we can write the code to make this function work. In all honesty, I had to run the test several times before I got this function completely correct; irst I returned an integer, and then I forgot to shift the character back up to the normal ASCII scale from the zero-based scale. Having the test available made it easy to test and debug these errors. This is another bonus of test-driven development. ``` 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) ``` Now that combine_characters is tested, I thought we'd be ready to implement our encode function. However, the irst thing we want inside that function is a repeating version of the keyword string that is as long as the plaintext. Let's implement a function for that irst. Oops, I mean let's implement the test irst! 现在已经测试了组合字符,我想我们已经准备好实现我们 编码功能。然而,我们想在函数中做的第一件事是重复 与明文一样长的关键字字符串的版本。让我们实现一个 第一个功能。哎呀,我的意思是让我们先实现测试! ``` def test_extend_keyword(): cipher = VigenereCipher("TRAIN") extended = cipher.extend_keyword(16) assert extended == "TRAINTRAINTRAINT" ``` Before writing this test, I expected to write extend_keyword as a standalone function that accepted a keyword and an integer. But as I started drafting the test, I realized it made more sense to use it as a helper method on the VigenereCipher class. This shows how test-driven development can help design more sensible APIs. Here's the method implementation: 在编写这个测试之前,我希望将extend_keyword作为一个独立的函数来编写 接受关键字和整数的。但是当我开始起草测试时,我意识到 在VigenereCipher类中使用它作为帮助方法更有意义。这 展示了测试驱动开发如何帮助设计更合理的APIs。这是 方法实现: ``` def extend_keyword(self, number): repeats = number // len(self.keyword) + 1 return (self.keyword * repeats)[:number] ``` Once again, this took a few runs of the test to get right. I ended up adding a second versions of the test, one with ifteen and one with sixteen letters, to make sure it works if the integer division has an even number. Now we're inally ready to write our encode method: 这又一次需要几次测试才能得到正确答案。我最后增加了一秒钟 测试的版本,一个有ifteen,一个有16个字母,以确保它 如果整数除法有偶数,则有效。 现在我们终于准备好编写我们的编码方法了: ``` 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) ``` That looks correct. Our test suite should pass now, right? Actually, if we run it, we'll ind that two tests are still failing. We totally forgot about the spaces and lowercase characters! It is a good thing we wrote those tests to remind us. We'll have to add this line at the beginning of the method: 看起来没错。我们的测试套件现在应该通过了,对吧? 事实上,如果我们运行它,我们会发现两个测试仍然失败。我们完全忘记了 空格和小写字符!我们写这些测试来提醒是件好事 我们。我们必须在方法的开头添加这一行: ``` plaintext = plaintext.replace(" ", "").upper() ``` If we have an idea about a corner case in the middle of implementing something, we can create a test describing that idea. We don't even have to implement the test; we can just run assert False to remind us to implement it later. The failing test will never let us forget the corner case and it can't be ignored like iling a task can. If it takes a while to get around to ixing the implementation, we can mark the test as an expected failure. > 如果我们在实施过程中有一个关于角落案例的想法 我们可以创建一个测试来描述这个想法。我们甚至不知道 必须实施测试;我们可以运行断言False来提醒 我们以后再实施。失败的测试永远不会让我们忘记 转角案例,它不能像发送任务一样被忽略。如果需要一个 为了着手固定实现,我们可以标记测试 作为预期的失败。 Now all the tests pass successfully. This chapter is pretty long, so we'll condense the examples for decoding. Here are a couple tests: 现在所有的测试都成功通过了。这一章很长,所以我们要浓缩一下 解码的例子。这里有几个测试: ``` 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" ``` Here's the separate\_character function: ``` 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) ``` And the decode method: ``` 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) ``` These methods have a lot of similarity to those used for encoding. The great thing about having all these tests written and passing is that we can now go back and modify our code, knowing it is still safely passing the tests. For example, if we replace our existing encode and decode methods with these refactored methods, our tests still pass: 这些方法与用于编码的方法有很多相似之处。伟大的事情 关于所有这些测试的编写和通过,我们现在可以回去 修改我们的代码,知道它仍然安全地通过测试。例如,如果我们 用这些重构方法替换我们现有的编码和解码方法, 我们的测试仍然通过: ``` 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) ``` This is the inal beneit of test-driven development, and the most important. Once the tests are written, we can improve our code as much as we like and be conident that our changes didn't break anything we have been testing for. Furthermore, we know exactly when our refactor is inished: when the tests all pass. Of course, our tests may not comprehensively test everything we need them to; maintenance or code refactoring can still cause undiagnosed bugs that don't show up in testing. Automated tests are not foolproof. If bugs do occur, however, it is still possible to follow a test-driven plan; step one is to write a test (or multiple tests) that duplicates or "proves" that the bug in question is occurring. This will, of course, fail. Then write the code to make the tests stop failing. If the tests were comprehensive, the bug will be ixed, and we will know if it ever happens again, as soon as we run the test suite. Finally, we can try to determine how well our tests operate on this code. With the py.test coverage plugin installed, py.test –coverage-report=report tells us that our test suite has 100 percent code coverage. This is a great statistic, but we shouldn't get too cocky about it. Our code hasn't been tested when encoding messages that have numbers, and its behavior with such inputs is thus undeined. 这是测试驱动开发的最终好处,也是最重要的。一旦 测试已经完成,我们可以尽可能多地改进我们的代码,并且确信 我们的改变并没有破坏我们一直在测试的任何东西。此外,我们知道 当我们的重构完成时:当测试全部通过时。 当然,我们的测试可能无法全面测试我们需要的一切; 维护或代码重构仍然会导致无法显示的未诊断错误 在测试中。自动化测试不是万无一失的。然而,如果bug确实发生了,它仍然存在 可以遵循测试驱动的计划;第一步是编写一个测试(或多个测试),它 复制或“证明”有问题的bug正在发生。这当然会失败。 然后编写代码使测试停止失败。如果测试是全面的, 一旦我们运行,bug将被修复,我们将知道它是否会再次发生 测试套件。 最后,我们可以尝试确定我们的测试在这个代码上运行的有多好。用 py.test coverage plugin已安装,py . test–coverage-report = report告知 我们的测试套件有100%的代码覆盖率。这是一个很好的统计数据,但是 我们不应该对此过于自信。我们的代码在编码时没有经过测试 因此,具有数字的消息及其在这种输入下的行为是不确定的。 ## 总结 We have inally covered the most important topic in Python programming: automated testing. Test-driven development is considered a best practice. The standard library unittest module provides a great out-of-the-box solution for testing, while the py.test framework has some more Pythonic syntaxes. Mocks can be used to emulate complex classes in our tests. Code coverage gives us an estimate of how much of our code is being run by our tests, but it does not tell us that we have tested the right things. In the next chapter, we'll jump into a completely different topic: concurrency. 我们最终讨论了Python编程中最重要的主题: 自动化测试。测试驱动开发被认为是最佳实践。这 标准库unittest模块提供了一个很好的现成解决方案,用于 测试,而py.test框架有更多的Pythonic语法。嘲弄 可以在我们的测试中用来模拟复杂的类。代码覆盖率给了我们一个 估计我们的测试运行了多少代码,但这并不能说明问题 我们已经测试了正确的东西。 在下一章,我们将跳转到一个完全不同的主题:并发。