Skip to content

Latest commit

 

History

History
1002 lines (701 loc) · 65.1 KB

File metadata and controls

1002 lines (701 loc) · 65.1 KB

二十四、面向对象程序的测试

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

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

为什么要考试?

许多程序员已经知道测试代码的重要性。如果你是其中之一,请随意浏览本节。您将发现下一节——在这里我们实际看到了如何用 Python 创建测试——更加精彩。如果你不相信测试的重要性,我保证你的代码是错误的,你只是不知道而已。读下去!

有些人认为测试在 Python 代码中更重要,因为它是动态的;例如 java 和 C++的编译语言偶尔被认为是某种安全的,因为它们在编译时强制执行类型检查。然而,Python 测试很少检查类型。它们检查值。它们确保在正确的时间设置了正确的属性,或者序列具有正确的长度、顺序和值。这些高级概念需要用任何语言进行测试。

Python 程序员比其他语言的程序员测试更多的真正原因是,用 Python 进行测试非常容易!

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

我们需要测试我们的代码,以确保它的工作。正如我们刚才所做的那样,运行程序并修复错误是一种粗糙的测试形式。Python 的交互式解释器和接近零的编译时间使编写几行代码和运行程序变得很容易,以确保这些代码行执行预期的操作。但是更改几行代码可能会影响程序中我们没有意识到的部分,这些部分会受到更改的影响,因此忽略了对这些部分的测试。此外,随着程序的增长,解释器可以通过代码的路径数量也会增加,很快就不可能手动测试所有路径。

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

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

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

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

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

测试驱动开发

先写测试是测试驱动开发的口头禅。测试驱动开发将“未测试的代码就是坏代码”概念向前推进了一步,并建议只对未编写的代码进行未测试。我们不会编写任何代码,直到我们编写了能够证明其工作的测试。我们第一次运行测试时应该失败,因为代码还没有编写。然后,我们编写确保测试通过的代码,然后为下一段代码编写另一个测试。

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

测试驱动方法有两个目标。第一个是确保真正编写测试。在我们编写代码之后,很容易说:

"Hmm, it seems to work. I don't have to write any tests for this. It was just a small change; nothing could have broken."

如果在我们编写代码之前已经编写了测试,那么我们将确切地知道它何时工作(因为测试将通过),并且我们将在将来知道它是否被我们或其他人所做的更改破坏。

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

作为一个具体的例子,想象一下编写使用对象关系映射器在数据库中存储对象属性的代码。在这样的对象中使用自动分配的数据库 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 "first_unittest.py", line 9, 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中指定的值。如果没有,列表中将有两个 4,并且mode方法将返回三个值。这表明在每次测试之前都会单独调用setUp,从而确保测试类从一张干净的白纸开始。测试可以以任何顺序执行,并且一个测试的结果决不能依赖于任何其他测试。

除了setUp方法之外,TestCase还提供了一个无参数tearDown方法,该方法可用于在类上的每个测试运行后进行清理。如果清理需要垃圾收集对象以外的任何东西,则此方法非常有用。

例如,如果我们正在测试执行文件 I/O 的代码,我们的测试可能会创建新文件作为测试的副作用。tearDown方法可以删除这些文件,并确保系统处于与测试运行前相同的状态。测试用例不应该有副作用。通常,我们根据测试方法的共同设置代码,将它们分为不同的TestCase子类。需要相同或类似设置的多个测试将放在一个类中,而需要不相关设置的测试将放在另一个类中。

组织和运行测试

单元测试集合很快就会变得非常庞大和笨拙。一次加载并运行所有测试可能很快变得复杂。这是单元测试的一个主要目标:简单地运行我们程序上的所有测试,并快速得到是或否的问题答案,我最近的更改是否破坏了任何东西?

与普通程序代码一样,我们应该将测试类划分为模块和包,以保持它们的有序性。如果您以四个字符test开始命名每个测试模块,那么很容易找到并运行它们。Python 的discover模块在当前文件夹或子文件夹中查找名称以test开头的任何模块。如果在这些模块中发现任何TestCase对象,则执行测试。这是一种无痛的方式,可以确保我们不会错过运行任何测试。要使用它,请确保您的测试模块名为test_<something>.py,然后运行python3``-m``unittest``discover 命令。

大多数 Python 程序员选择将测试放在一个单独的包中(通常与源目录一起命名为tests/。然而,这不是必需的。例如,有时将不同包的测试模块放在该包旁边的子包中是有意义的。

忽略中断的测试

有时,我们知道测试失败,但我们不希望测试套件报告失败。这可能是因为一个坏的或未完成的特性已经编写了测试,但我们目前并不专注于改进它。更常见的情况是,这是因为功能仅在特定平台、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.7 的 Linux 系统上,输出如下所示:

xssF
======================================================================
FAIL: test_skipunless (__main__.SkipTests)
----------------------------------------------------------------------
Traceback (most recent call last):
 File "test_skipping.py", line 22, 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

用 pytest 进行测试

Pythonunittest模块需要大量样板代码来设置和初始化测试。它基于非常流行的 JUnit Java 测试框架。它甚至使用相同的方法名(您可能已经注意到它们不符合 PEP-8 命名标准,该标准建议使用 snake_case 而不是 CamelCase 来表示方法名)和测试布局。虽然这对于 Java 测试是有效的,但它不一定是 Python 测试的最佳设计。实际上,我发现unittest框架是过度使用面向对象原则的一个很好的例子。

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

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

pytestunittest模块的布局有很大不同。它不要求测试用例是类。相反,它利用了 Python 函数是对象这一事实,并允许任何正确命名的函数像测试一样运行。它没有提供一系列用于断言相等性的自定义方法,而是使用assert语句来验证结果。这使得测试更具可读性和可维护性。

当我们运行pytest时,它在当前文件夹中启动,并搜索名称以字符test_开头的任何模块或子包。如果此模块中的任何功能也以test开头,则它们将作为单独的测试执行。此外,如果模块中存在名称以Test开头的任何类,则该类上以test_开头的任何方法也将在测试环境中执行。

使用以下代码,我们将前面编写的最简单的unittest示例移植到pytest

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" 

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

============================== test session starts ==============================
platform linux -- Python 3.7.0, pytest-3.8.0, py-1.6.0, pluggy-0.7.1
rootdir: /home/dusty/Py3OOP/Chapter 24: Testing Object-oriented Programs, inifile:
collected 3 items

test_with_pytest.py ..F [100%]

=================================== FAILURES ====================================
___________________________ TestNumbers.test_int_str ____________________________

self = <test_with_pytest.TestNumbers object at 0x7fdb95e31390>

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

test_with_pytest.py:10: AssertionError
====================== 1 failed, 2 passed in 0.03 seconds =======================

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

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

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

安装和清理的一种方法

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

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

此外,pytest还提供了其他设置和拆卸功能,使我们能够更好地控制何时执行设置和清理代码。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类的唯一目的是提取与测试类相同的四个方法,并使用继承来减少重复代码的数量。因此,从pytest的角度来看,这两个子类不仅各有两个测试方法,还有两个设置和两个拆卸方法(一个在类级别,一个在方法级别)。

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

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 方法,然后执行该类的两个测试。这些测试分别包装在单独的setup_methodteardown_method调用中。执行测试后,调用类上的 teardown 方法。在teardown_module方法最终被调用之前,第二个类也会发生同样的顺序,只发生一次。

设置变量的完全不同的方法

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

pytest提供了一种完全不同的方法,使用所谓的夹具。fixture 基本上是在测试配置文件中预定义的命名变量。这允许我们将配置与测试的执行分开,并允许在多个类和模块之间使用夹具。

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

import pytest
from stats import StatsList

@pytest.fixture
def valid_stats():
    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的参数;此参数是通过调用valid_stats函数创建的,该函数用@pytest.fixture修饰。

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

如果我们将夹具实现为生成器,那么可以在每次测试运行后运行清理代码。这提供了与拆卸方法等效的方法,但每个夹具除外。我们可以使用它来清理文件、关闭连接、清空列表或重置队列。例如,下面的代码通过创建临时目录 fixture 来测试os.mkdir功能:

import pytest
import tempfile
import shutil
import os.path

@pytest.fixture
def temp_dir(request):
    dir = tempfile.mkdtemp()
    print(dir)
    yield dir
    shutil.rmtree(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,递归删除目录及其内部的任何内容)。然后,文件系统将保持其启动时的状态。

我们可以通过一个scope参数来创建一个持续时间超过一次测试的夹具。这在设置可由多个测试重用的昂贵操作时非常有用,只要资源重用不会破坏测试的原子或单元性质(以便一个测试不依赖于前一个测试,也不受前一个测试的影响)。例如,如果要测试以下 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
import pytest

@pytest.fixture(scope="session")
def echoserver():
    print("loading server")
    p = subprocess.Popen(["python3", "echo_server.py"])
    time.sleep(1)
    yield p
    p.terminate()

@pytest.fixture
def clientsocket(request):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(("localhost", 1028))
    yield s
    s.close()

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 服务器,并生成进程对象,完成后将其清理干净。第二个为每个测试实例化一个新的套接字对象,并在测试完成后关闭套接字。

第一个装置是我们目前感兴趣的装置。从传递到装饰器构造函数中的scope="session"关键字参数中,pytest知道我们只希望在单元测试会话期间初始化和终止此装置一次。

范围可以是字符串classmodulepackagesession中的一个。它决定了参数的缓存时间。在本例中,我们将其设置为session,因此它在整个pytest运行期间被缓存。在所有测试运行之前,进程不会终止或重新启动。当然,module作用域仅为该模块中的测试缓存该对象,class作用域将该对象视为一个正常的类设置和拆卸。

At the time the third edition of this book went to print, the package scope was labeled experimental in pytest. Be careful with it, and they request that you supply bug reports.

使用 pytest 跳过测试

unittest模块一样,经常有必要跳过pytest中的测试,原因类似:被测试的代码尚未编写,测试仅在某些解释器或操作系统上运行,或者测试非常耗时,只能在某些情况下运行。

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

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

import sys 
import pytest 

def test_simple_skip(): 
    if sys.platform != "fakeos": 
        pytest.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 的最新版本是否足够。

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

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

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

pytest除了这里描述的功能外,还有很多其他功能,开发人员正在不断添加创新的新方法,使您的测试体验更加愉快。他们在其网站上有详尽的文件 https://docs.pytest.org/

The pytest can find and run tests defined using the standard unittest library in addition to its own testing infrastructure. This means that if you want to migrate from unittest to pytest, you don't have to rewrite all your old tests.

模仿贵重物品

有时,我们希望测试需要提供昂贵或难以构造的对象的代码。在某些情况下,这可能意味着您的 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 flight_status_redis import FlightStatusTracker
from unittest.mock import Mock
import pytest

@pytest.fixture
def tracker():
    return FlightStatusTracker()

def test_mock_method(tracker):
 tracker.redis.set = Mock()
    with pytest.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

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

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

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

import datetime
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函数,它都将返回我们的对象,而不是新的模拟对象。但当解释器退出上下文管理器时,原始的datetime.datetime.now()功能将恢复。

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

Mocking dates so you can have deterministic test results is a common patching scenario. If you are in a situation where you are doing a lot of this, you might appreciate the freezegun and pytest-freezegun projects available in the Python Package Index.

前面的示例很好地说明了编写测试如何指导我们的 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库的一部分,但正如您从这些示例中看到的,它们也可以与pytest和其他库一起使用。mock 还有其他更高级的特性,随着代码变得更加复杂,您可能需要利用这些特性。例如,您可以使用spec参数邀请模拟来模拟现有类,这样,如果代码试图访问模拟类上不存在的属性,它就会引发错误。您还可以通过将列表作为side_effect参数传递,构造每次调用时都返回不同参数的模拟方法。side_effect参数用途广泛;您还可以使用它在调用 mock 时执行任意函数或引发异常。

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

多少测试就足够了?

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

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

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

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

$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 浏览器中查看它。该网页甚至突出显示了源代码中哪些行经过测试,哪些行没有经过测试。下面是它的外观:

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

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

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

个案研究

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

If you want a deep dive into how the RSA algorithm works, I wrote one on my blog at https://dusty.phillips.codes/.

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

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 
  1. 对于纯文本中的每个字母,在表中查找以该字母开头的行。
  2. 查找包含与所选明文字母的关键字字母关联的字母的列。
  3. 编码字符位于此行和列的交点处。

例如,以 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方法,该方法接受关键字和密文并返回原始消息。

但是,让我们遵循测试驱动的开发策略,而不仅仅是编写这些方法。我们将使用pytest进行单元测试。我们需要一种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行并运行pytest,前面的测试将通过!我们已经完成了第一个测试驱动的开发周期。

这似乎是一个可笑的愚蠢的事情来测试,但它实际上验证了很多。我第一次实现它时,在类名中错误地将 cipher 拼写为cypher。甚至我的基本单元测试也有助于发现一个 bug。即便如此,返回硬编码字符串显然不是密码类最明智的实现,因此让我们添加第二个测试,如下所示:

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类的助手方法使用更有意义,这样它就可以访问self.keyword属性。这说明了测试驱动开发如何帮助设计更合理的 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() 

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 as easily as a ticket in an issue tracker. If it takes a while to get around to fixing the implementation, we can mark the test as an expected failure.

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

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) 

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

当然,我们的测试不可能全面测试我们需要的一切,;维护或代码重构仍然可能导致测试中未发现的未诊断错误。自动化测试不是万无一失的。但是,如果出现错误,仍然可以遵循测试驱动的计划,如下所示:

  1. 编写一个测试(或多个测试),重复或证明存在问题的 bug。这当然会失败。

  2. 然后编写代码使测试停止失败。如果测试是全面的,那么 bug 将被修复,并且一旦我们运行测试套件,我们就会知道它是否再次发生。

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

练习

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

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

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

在我们的案例研究中,我们使用了pytest,但我们没有涉及任何使用unittest不容易测试的特性。尝试调整测试以使用测试跳过或固定装置(一个VignereCipher的实例会很有帮助)。尝试各种设置和拆卸方法,并将它们的用法与 funcargs 进行比较。你觉得哪个更自然?

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

仔细考虑不同的值,例如以下值:

  • 当您期望完整的列表时,请清空列表
  • 负数、零、一或与正整数相比的无穷大
  • 不四舍五入到精确小数点的浮点
  • 需要数字时使用字符串
  • 预期使用 ASCII 时使用 Unicode 字符串
  • 当你期望一些有意义的东西时,无处不在的None

如果您的测试覆盖了这些边缘情况,那么您的代码将处于良好状态。

总结

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

感谢您阅读《Python 入门》。我希望您已经享受了这段旅程,并渴望在未来的所有项目中开始实现面向对象的软件!