当我们构建复杂度很小的程序时,有无数种方法可以让缺陷潜入我们的代码中。这可能发生在我们最初编写代码时,但在对代码进行修改时同样可能会引入缺陷。为了帮助处理缺陷并保持代码的高质量,通常有一组测试非常有用,您可以运行这些测试来判断代码是否按预期运行。
为了帮助进行此类测试,Python 标准库包含了unittest 模块。尽管 顾名思义,这个模块的帮助不仅仅是单元测试。事实上,它是一个灵活的框架,用于自动化各种测试,从验收测试到集成测试再到单元测试。与许多语言中的许多测试框架一样,它的关键功能是帮助您进行自动化和可重复测试。有了这样的测试,您可以在任何时候廉价且轻松地验证代码的行为。
unittest模块围绕几个关键概念构建,其核心是测试用例的概念。unittest.TestCase 类中包含的测试用例将一组相关的测试方法组合在一起,是 unittest 框架中测试组织的基本单元。我们将在后面看到,单独的测试方法是作为unittest.TestCase子类上的方法实现的。
下一个重要概念是夹具。夹具是在每个测试方法之前和/或之后运行的代码片段。固定装置有两个主要用途:
- 设置夹具,确保测试环境在测试运行前处于预期状态。
- 拆卸装置在测试运行后清理环境,通常是释放资源。
例如,设置装置可能在运行测试之前在数据库中创建特定条目。类似地,拆卸装置可能会删除由测试创建的数据库条目。测试不需要固定装置,但它们非常常见,并且对于使测试可重复性非常关键。
最后一个关键概念是断言。断言是测试方法内部的特定检查,最终确定测试是通过还是失败。除其他外,断言可以:
- 进行简单的布尔检查
- 执行对象相等性测试
- 验证是否引发了正确的异常
如果断言失败,那么测试方法就会失败,因此断言表示您可以执行的最低测试级别。您可以在 unittest 文档中找到一个完整的断言列表。
考虑到这些概念,让我们看看如何在实践中实际使用 unittest 模块。对于这个例子,我们将使用测试驱动开发来编写一个简单的文本分析函数。此函数将文件名作为其唯一参数。然后它将读取 该文件并计算:
- 文件中的行数
- 文件中的字符数
TDD 是一个迭代开发过程,因此我们将把测试代码放在一个名为text_analyzer.py的文件中,而不是在 REPL 上工作。首先,我们将使用足够的支持代码创建第一个测试,以实际运行它:
# text_analyzer.py
import unittest
class TextAnalysisTests(unittest.TestCase):
"""Tests for the ``analyze_text()`` function."""
def test_function_runs(self):
"""Basic smoke test: does the function run."""
analyze_text()
if __name__ == '__main__':
unittest.main()我们要做的第一件事是导入unittest模块。然后,我们通过定义一个派生自unittest.TestCase的类TextAnalysisTests,来创建我们的测试用例。这就是使用unittest框架创建测试用例的方式。
要在测试用例中定义单个测试方法,只需在以test_开头的TestCase子类上创建方法。unittest 框架会在执行时自动发现这样的方法,因此您不需要显式注册测试方法。
在本例中,我们定义了最简单的测试:我们检查analyze_text()函数是否运行!我们的测试不进行任何显式检查,而是依赖于这样一个事实:如果测试方法抛出任何异常,它将失败。在这种情况下,如果没有定义analyze_text(),我们的测试将失败。
最后,我们定义了惯用的“main”块,该块在执行该模块时调用unittest.main()。unittest.main()函数将搜索模块中的所有TestCase子类,并执行它们的所有测试方法。
由于我们使用的是测试驱动的设计,我们希望我们的测试首先会失败。事实上,我们的测试非常失败,原因很简单,我们还没有定义analyze_text():
$ python text_analyzer.py
E
======================================================================
ERROR: test_function_runs (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "text_analyzer.py", line 5, in test_function_runs
analyze_text()
NameError: global name 'analyze_text' is not defined
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (errors=1)如您所见,unittest.main()生成一个简单的报告,告诉我们运行了多少测试,以及有多少测试失败。它还向我们展示了测试是如何失败的,在本例中,它向我们展示了当我们试图运行不存在的函数analyze_text()时,我们遇到了一个名称错误。
让我们通过定义analyze_text()来修复失败的测试。记住,在测试驱动的开发中,我们只编写足够的代码来满足我们的测试,所以我们现在要做的就是创建一个空函数。为了简单起见,我们将此函数放在text_analyzer.py中,但通常您的测试代码和实现代码将位于不同的模块中:
# text_analyzer.py
def analyze_text():
"""Calculate the number of lines and characters in a file.
"""
pass将此函数置于模块范围内。再次运行测试,我们发现他们现在 通过了:
% python text_analyzer.py
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK我们已经完成了一个 TDD 周期,但是我们的代码还没有真正完成任何工作。我们将不断改进测试和实现,以获得真正的解决方案。
接下来要做的事情是能够将文件名传递给analyze_text(),以便它知道要处理什么。当然,为了使analyze_text()起作用,这个文件名应该引用一个实际存在的文件!为了确保我们的测试存在一个文件,我们将定义一些装置。
我们可以定义的第一个夹具是方法TestCase.setUp()。如果已定义,则此方法在TestCase中的每个test方法之前运行。在本例中,我们将使用setUp()为我们创建一个文件,并记住作为TestCase成员的文件名:
# text_analyzer.py
class TextAnalysisTests(unittest.TestCase):
. . .
def setUp(self):
"Fixture that creates a file for the text methods to use."
self.filename = 'text_analysis_test_file.txt'
with open(self.filename, 'w') as f:
f.write('Now we are engaged in a great civil war,\n'
'testing whether that nation,\n'
'or any nation so conceived and so dedicated,\n'
'can long endure.')我们可以使用的第二个固定装置是TestCase.tearDown()。tearDown()方法在TestCase中的每个测试方法之后运行,在这种情况下,我们将使用它删除我们在setUp()中创建的文件:
# text_analyzer.py
import os
. . .
class TextAnalysisTests(unittest.TestCase):
. . .
def tearDown(self):
"Fixture that deletes the files used by the test methods."
try:
os.remove(self.filename)
except OSError:
pass注意,因为我们在tearDown()中使用 os 模块,所以需要在文件顶部导入它。
还要注意tearDown()是如何接受os.remove()抛出的任何异常的。我们这样做是因为tearDown()实际上无法确定该文件是否存在,所以它只是尝试删除该文件,并假设可以安全地忽略任何异常。
有了两个固定装置,我们现在有了一个文件,该文件在每个测试方法之前创建,在每个测试方法之后删除。这意味着每个测试方法都是在稳定的已知状态下开始的。这对于进行可重复性试验至关重要。让我们通过修改现有测试将此文件名传递给analyze_text():
# text_analyzer.py
class TextAnalysisTests(unittest.TestCase):
. . .
def test_function_runs(self):
"Basic smoke test: does the function run."
analyze_text(self.filename)记住我们的setUp()将文件名存储在self.filename上。由于传递给 fixture 的自参数与传递给测试方法的实例相同,因此我们的测试可以使用该属性访问文件名。
当然,当我们运行测试时,我们看到该测试失败,因为analyze_text()尚未接受任何参数:
% python text_analyzer.py
E
======================================================================
ERROR: test_function_runs (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "text_analyzer.py", line 25, in test_function_runs
analyze_text(self.filename)
TypeError: analyze_text() takes no arguments (1 given)
----------------------------------------------------------------------
Ran 1 test in 0.003s
FAILED (errors=1)我们只需在analyze_text()中添加一个参数即可解决此问题:
# text_analyzer.py
def analyze_text(filename):
pass如果我们再次运行测试,我们会发现我们再次通过了:
% python text_analyzer.py
.
----------------------------------------------------------------------
Ran 1 test in 0.003s
OK我们仍然没有一个实现可以做任何有用的事情,但是您可以开始了解测试是如何驱动实现的。
既然我们对analyze_text()的存在感到满意,并且接受了正确数量的参数,那么让我们看看是否能够让它真正起作用。我们首先希望函数返回文件中的行数,因此让我们定义该测试:
# text_analyzer.py
class TextAnalysisTests(unittest.TestCase):
. . .
def test_line_count(self):
"Check that the line count is correct."
self.assertEqual(analyze_text(self.filename), 4)这里我们看到了断言的第一个示例。TestCase类有许多断言方法,在本例中,我们使用assertEqual()检查我们的函数计数的行数是否等于 4。如果analyze_text()返回的值不等于 4,则此断言将导致测试方法失败。如果我们运行新的测试,我们会发现这正是发生的:
% python text_analyzer.py
.F
======================================================================
FAIL: test_line_count (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "text_analyzer.py", line 28, in test_line_count
self.assertEqual(analyze_text(self.filename), 4)
AssertionError: None != 4
----------------------------------------------------------------------
Ran 2 tests in 0.003s
FAILED (failures=1)在这里,我们看到我们现在正在运行两个测试,其中一个通过了,而新的测试失败了,出现了一个AssertionError。
让我们打破 TDD 规则,现在行动快一点。首先,我们将更新函数以返回文件中的行数:
# text_analyzer.py
def analyze_text(filename):
"""Calculate the number of lines and characters in a file.
Args:
filename: The name of the file to analyze.
Raises:
IOError: If ``filename`` does not exist or can't be read.
Returns: The number of lines in the file.
"""
with open(filename, 'r') as f:
return sum(1 for _ in f)这一变化确实给了我们想要的结果:
% python text_analyzer.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.003s
OK因此,让我们为我们想要的另一个特性添加一个测试,即计算文件中的字符数。由于analyze_text()现在应该返回两个值,我们将让它返回一个元组,第一个位置是行计数,第二个位置是字符计数。我们的新测试如下所示:
# text_analyzer.py
class TextAnalysisTests(unittest.TestCase):
. . .
def test_character_count(self):
"Check that the character count is correct."
self.assertEqual(analyze_text(self.filename)[1], 131)而它却如预期的那样失败了:
% python text_analyzer.py
E..
======================================================================
ERROR: test_character_count (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "text_analyzer.py", line 32, in test_character_count
self.assertEqual(analyze_text(self.filename)[1], 131)
TypeError: 'int' object has no attribute '__getitem__'
----------------------------------------------------------------------
Ran 3 tests in 0.004s
FAILED (errors=1)这个结果告诉我们它不能索引到analyze_text()返回的整数中。因此,让我们修正analyze_text()以返回正确的元组:
# text_analyzer.py
def analyze_text(filename):
"""Calculate the number of lines and characters in a file.
Args:
filename: The name of the file to analyze.
Raises:
IOError: If ``filename`` does not exist or can't be read.
Returns: A tuple where the first element is the number of lines in
the files and the second element is the number of characters.
"""
lines = 0
chars = 0
with open(filename, 'r') as f:
for line in f:
lines += 1
chars += len(line)
return (lines, chars)这修复了我们的新测试,但我们发现我们打破了一个旧测试:
% python text_analyzer.py
..F
======================================================================
FAIL: test_line_count (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "text_analyzer.py", line 34, in test_line_count
self.assertEqual(analyze_text(self.filename), 4)
AssertionError: (4, 131) != 4
----------------------------------------------------------------------
Ran 3 tests in 0.004s
FAILED (failures=1)幸运的是,这很容易修复,因为我们所需要做的就是在早期测试中考虑新的返回类型:
# text_analyzer.py
class TextAnalysisTests(unittest.TestCase):
. . .
def test_line_count(self):
"Check that the line count is correct."
self.assertEqual(analyze_text(self.filename)[0], 4)现在一切又过去了:
% python text_analyzer.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.004s
OK我们要测试的另一件事是,analyze_text()在传递不存在的文件名时引发正确的异常,我们可以这样测试:
# text_analyzer.py
class TextAnalysisTests(unittest.TestCase):
. . .
def test_no_such_file(self):
"Check the proper exception is thrown for a missing file."
with self.assertRaises(IOError):
analyze_text('foobar')这里我们使用TestCase.assertRaises()断言。此断言检查指定的异常类型(在本例中为IOError)是否从with块的主体中抛出。
由于open()对不存在的文件提出IOError,我们的测试已经通过,没有进一步的实现:
% python text_analyzer.py
....
----------------------------------------------------------------------
Ran 4 tests in 0.004s
OK最后,通过编写一个测试来验证analyze_text()没有删除文件,我们可以看到一种更有用的断言类型—这是函数的合理要求!:
# text_analyzer.py
class TextAnalysisTests(unittest.TestCase):
. . .
def test_no_deletion(self):
"Check that the function doesn't delete the input file."
analyze_text(self.filename)
self.assertTrue(os.path.exists(self.filename))TestCase.assertTrue()函数只是检查传递给它的值的计算结果是否为 True。有一个等效的assertFalse()对
假值进行相同的测试。
正如您可能预期的那样,此测试也已通过:
% python text_analyzer.py
.....
----------------------------------------------------------------------
Ran 5 tests in 0.002s
OK所以现在我们有了一套有用的、通过的测试!这个示例很小,但它演示了unittest模块的许多重要部分。unittest 模块还有更多的部分,但仅使用我们在这里看到的技术就可以走得很远。
禅宗时刻:面对模糊,拒绝猜测的诱惑:
Figure 10.1: Moment of Zen
猜测的诱惑,或一厢情愿地忽视模棱两可,都可能导致短期收益。但它往往会导致未来的混乱,并导致难以理解和修复的 bug。在进行下一个快速修复之前,问问自己需要哪些信息才能正确地进行修复。
unittest模块是开发可靠自动化测试的框架。- 您通过从
unittest.TestCase子类化来定义测试用例。 unittest.main()功能可用于运行模块中的所有 测试。setUp()和tearDown()夹具用于在每个测试方法之前和之后运行代码。- 通过在测试用例对象上创建以
test_开头的方法名称来定义测试方法。 - 当不满足正确的条件时,可以使用各种
TestCase.assert...方法使测试方法失败。 - 在
with语句中使用TestCase.assertRaises()检查测试中是否抛出了正确的异常。
-
测试驱动开发,或者简称 TDD,是一种软件开发形式,首先编写测试,也就是说,在编写要测试的实际功能之前。一开始,这似乎是一种倒退,但它可能是一种非常强大的技术。您可以在这里了解更多关于 TDD的信息。
-
请注意,我们实际上还没有尝试测试任何功能。这只是我们测试套件的初始框架,它允许我们验证测试方法是否执行。
-
TDD 的一个原则是,您的测试应该在通过之前失败,并且您应该只编写足够的实现代码使您的测试通过。这样,您的测试就可以作为代码行为的完整描述。
-
您可能已经注意到,
setUp()和tearDown()方法名称与 PEP8 规定的不一致。这是因为unittest模块早于 PEP 8 的那些部分,这些部分规定了函数名以小写加下划线的约定。Python 标准库中有几个这样的例子,但大多数新的 Python 代码都遵循 PEP8 风格。 -
如果我们在这里严格解释 TDD,那么这个实现量会太多。为了使我们现有的测试通过,我们不需要实际实现行计数;我们只需要返回值 4。随后的测试将迫使我们不断“更新”我们的实现,因为它们描述了分析算法的更完整版本。我们认为您会同意,这种教条式的方法在这里是不合适的,坦率地说,在实际开发中也是不合适的。
