第 11 章 测试代码
在编写函数或类时,还可为其编写测试。通过测试,可确定代码面对各种输入都能够按要求工作。测试让你坚信,无论有多少人使用你的程序,它都将正确地工作。在程序中添加新代码时,也可对其进行测试,确认它们不会破坏程序既有的行为。程序员都会犯错,因此每个程序员都必须经常测试自己的代码,先于用户发现问题。
在本章中,你将学习如何使用 pytest 来测试代码。pytest 库是一组工具,不仅能帮助你快速而轻松地编写测试,而且能持续支持随项目增大而变得复杂的测试。Python 默认不包含 pytest,因此你将学习如何安装外部库。知道如何安装外部库让你能够使用各种设计良好的代码。这些库还极大地增加了你可开发的项目类型。
你将学习编写测试用例,核实一系列输入都将得到预期的输出。你将看到测试通过了是什么样子的,测试未通过又是什么样子的,还将知道测试未通过如何有助于改进代码。你将学习如何测试函数和类,以及该为项目编写多少个测试。
11.1 使用 pip 安装 pytest
虽然 Python 通过标准库提供了大量的功能,但 Python 开发人员还是需要频繁用到第三方包。第三方包(third-party package)指的是独立于 Python 核心的库。有些深受欢迎的第三方包最终会被纳入标准库,并从此随 Python 一起被安装。通常,能被纳入标准库的包在消除最初的 bug 后不会发生太多变化,它们在被纳入后只能与 Python 语言同步演进。
然而,很多包并未被纳入标准库,因此得以独立于 Python 语言本身的更新计划。相较于纳入标准库,独立的第三方包的更新频率往往更高,pytest 和本书第二部分将使用的大部分库属于这种情况。虽然不应盲目信任所有的第三方包,但也不要因噎废食,因为很多重要的功能是使用第三方包实现的。
11.1.1 更新 pip
Python 提供了一款名为 pip 的工具,可用来安装第三方包。因为 pip 帮我们安装来自外部的包,所以更新频繁,以消除潜在的安全问题。有鉴于此,我们先来更新 pip。
打开一个终端窗口,执行如下命令:
$ python -m pip install —upgrade pip
❶ Requirement already satisfied: pip in …python3.11/site-packages (22.0.4)
—snip—
❷ Successfully installed pip-22.1.2
这个命令的第一部分(python -m pip)让 Python 运行 pip 模块;第二部分(install —upgrade)让 pip 更新一个已安装的包;而最后一部分(pip)指定要更新哪个第三方包。输出表明,当前的 pip 版本(22.0.4)(见❶)被替换成了最新的版本(本书编写期间为 22.1.2)(见❷)。
可使用下面的命令更新系统中安装的任何包:
$ python -m pip install —upgrade package_name
注意:如果你使用的是 Linux,在安装 Python 时可能不会自动安装 pip。如果在你试图更新 pip 时出现错误消息,请参阅附录 A 提供的说明。
11.1.2 安装 pytest
将 pip 升级到最新版本后,就可以安装 pytest 了:
$ python -m pip install —user pytest
Collecting pytest
—snip—
Successfully installed attrs-21.4.0 iniconfig-1.1.1 …pytest-7.x.x
这里使用的核心命令也是 pip install,但指定的标志不是 —upgrade,而是 —user。这个标志让 Python 只为当前用户安装指定的包。输出表明,成功地安装了最新版本的 pytest,以及 pytest 运行所需的多个其他包。
可使用下面的命令安装众多的第三方包:
$ python -m pip install —user package_name
注意:如果在执行这个命令时遇到麻烦,可尝试在不指定标志 —user 的情况下再次执行它。
11.2 测试函数
要学习测试,必须有要测试的代码。下面是一个简单的函数,它接受名和姓并返回格式规范的姓名:
name_function.py
def get_formatted_name(first, last):
"""生成格式规范的姓名"""
full_name = f"{first} {last}"
return full_name.title()
get_formatted_name() 函数将名和姓合并成姓名,在名和姓之间加上一个空格并将首字母大写,然后返回结果。为了核实 get_formatted_name() 会像期望的那样工作,我们编写一个使用这个函数的程序。程序 names.py 让用户输入名和姓,并显示格式规范的姓名:
names.py
from name_function import get_formatted_name
print("Enter 'q' at any time to quit.")
while True:
first = input("\nPlease give me a first name: ")
if first == 'q':
break
last = input("Please give me a last name: ")
if last == 'q':
break
formatted_name = get_formatted_name(first, last)
print(f"\tNeatly formatted name: {formatted_name}.")
这个程序从 name_function.py 中导入 get_formatted_name()。用户可输入一系列名和姓,并看到格式规范的姓名:
Enter 'q' at any time to quit.
Please give me a first name: janis
Please give me a last name: joplin
Neatly formatted name: Janis Joplin.
Please give me a first name: bob
Please give me a last name: dylan
Neatly formatted name: Bob Dylan.
Please give me a first name: q
从上述输出可知,合并得到的姓名正确无误。现在假设要修改 get_formatted_name(),使其还能够处理中间名。在添加这项功能时,要确保不破坏这个函数处理只有名和姓的姓名的方式。为此,可在每次修改 get_formatted_name() 后都进行测试:运行程序 names.py,并输入像 Janis Joplin 这样的姓名。不过这太烦琐了。所幸 pytest 提供了一种自动测试函数输出的高效方式。倘若对 get_formatted_name() 进行自动测试,我们就能始终确信,当给这个函数提供测试过的姓名时,它都能正确地工作。
11.2.1 单元测试和测试用例
软件的测试方法多种多样。一种最简单的测试是单元测试(unit test),用于核实函数的某个方面没有问题。测试用例(test case)是一组单元测试,这些单元测试一道核实函数在各种情况下的行为都符合要求。良好的测试用例考虑到了函数可能收到的各种输入,包含针对所有这些情况的测试。全覆盖(full coverage)测试用例包含一整套单元测试,涵盖了各种可能的函数使用方式。对于大型项目,要进行全覆盖测试可能很难。通常,最初只要针对代码的重要行为编写测试即可,等项目被广泛使用时再考虑全覆盖。
11.2.2 可通过的测试
使用 pytest 进行测试,会让单元测试编写起来非常简单。我们将编写一个测试函数,它会调用要测试的函数,并做出有关返回值的断言。如果断言正确,表示测试通过;如果断言不正确,表示测试未通过。
这个针对 get_formatted_name() 函数的测试如下:
test_name_function.py
from name_function import get_formatted_name
❶ def test_first_last_name():
"""能够正确地处理像 Janis Joplin 这样的姓名吗?"""
❷ formatted_name = get_formatted_name('janis', 'joplin')
❸ assert formatted_name == 'Janis Joplin'
在运行这个测试前,先来仔细观察一下。测试文件的名称很重要,必须以 test打头。当你让 pytest 运行测试时,它将查找以 test打头的文件,并运行其中的所有测试。
在这个测试文件中,首先导入要测试的 getformatted_name() 函数。然后,定义一个测试函数 test_first_last_name()(见❶)。这个函数名比以前使用的都长,原因有二。第一,测试函数必须以 test 打头。在测试过程中,pytest 将找出并运行所有以 test_ 打头的函数。第二,测试函数的名称应该比典型的函数名更长,更具描述性。你自己不会调用测试函数,而是由 pytest 替你查找并运行它们。因此,测试函数的名称应足够长,让你在测试报告中看到它们时,能清楚地知道它们测试的是哪些行为。
接下来,调用要测试的函数(见❷)。像运行 names.py 时一样,这里在调用 get_formatted_name() 函数时向它传递了实参 'janis' 和 'joplin'。将这个函数的返回值赋给变量 formatted_name。
最后,做出一个断言(见❸)。断言(assertion)就是声称满足特定的条件:这里声称 formatted_name 的值为 'Janis Joplin'。
11.2.3 运行测试
如果直接运行文件 test_name_function.py,将不会有任何输出,因为我们没有调用这个测试函数。相反,应该让 pytest 替我们运行这个测试文件。
为此,打开一个终端窗口,并切换到这个测试文件所在的文件夹。如果你使用的是 VS Code,可打开测试文件所在的文件夹,并使用该编辑器内嵌的终端。在终端窗口中执行命令 pytest,你将看到如下输出:
$ pytest
========================= test session starts =========================
❶ platform darwin — Python 3.x.x, pytest-7.x.x, pluggy-1.x.x
❷ rootdir: …python_work/chapter_11
❸ collected 1 item
❹ test_name_function.py . [100%]
========================== 1 passed in 0.00s ==========================
下面来尝试解读这些输出。首先,我们看到了一些有关运行测试的系统的信息(见❶)。我是在 macOS 系统中运行该测试的,因此你看到的输出可能与这里显示的不同。最重要的是要注意,输出指出了用来运行该测试的 Python、pytest 和其他包的版本。
接下来,可以看到该测试是从哪个目录运行的(见❷),这里是 python_work/chapter_11。如你所见,pytest 找到了一个测试(见❸),并指出了运行的是哪个测试文件(见❹)。文件名后面的句点表明有一个测试通过了,而 100% 指出运行了所有的测试。在可能有数百乃至数千个测试的大型项目中,句点和完成百分比有助于监控测试的运行进度。
最后一行指出有一个测试通过了,运行该测试花费的时间不到 0.01 秒。
上述输出表明,在给定包含名和姓的姓名时,get_formatted_name() 函数总是能正确地处理。修改 get_formatted_name() 后,可再次运行这个测试。如果它通过了,就表明在给定 Janis Joplin 这样的姓名时,这个函数依然能够正确地处理。
注意:如果不知道如何在终端窗口中切换到正确的文件夹,请参阅 1.5 节。另外,如果出现一条消息,提示没有找到命令 pytest,请执行命令 python -m pytest。
11.2.4 未通过的测试
测试未通过时的结果是什么样的呢?我们来修改 get_formatted_name(),使其能够处理中间名,但同时故意让这个函数无法正确地处理像 Janis Joplin 这样只有名和姓的姓名。
下面是 get_formatted_name() 函数的新版本,它要求通过一个实参指定中间名:
name_function.py
def get_formatted_name(first, middle, last):
"""生成格式规范的姓名"""
full_name = f"{first} {middle} {last}"
return full_name.title()
这个版本应该能够正确地处理包含中间名的姓名,但对其进行测试时,我们发现它不再能正确地处理只有名和姓的姓名了。
这次运行 pytest 时,输出如下:
$ pytest
========================= test session starts =========================
—snip—
❶ testnamefunction.py F [100%]
❷ ============================== FAILURES ===============================
❸ __ testfirstlastname __
def test_first_last_name():
"""能够正确地处理像 Janis Joplin 这样的姓名吗?"""
❹ > formatted_name = get_formatted_name('janis', 'joplin')
❺ E TypeError: get_formatted_name() missing 1 required positional
argument: 'last'
test_name_function.py:5: TypeError
======================= short test summary info =======================
FAILED test_name_function.py::test_first_last_name - TypeError:
get_formatted_name() missing 1 required positional argument: 'last'
========================== 1 failed in 0.04s ==========================
这里的信息很多,因为在测试未通过时,需要你知道的事情可能有很多。首先,输出中有一个字母 F(见❶),表明有一个测试未通过。然后是 FAILURES 部分(见❷),这是关注的焦点,因为在运行测试时,通常应该关注未通过的测试。接下来,指出未通过的测试函数是 test_first_last_name()(见❸)。右尖括号(见❹)指出了导致测试未能通过的代码行。下一行中的 E(见❺)指出了导致测试未通过的具体错误:缺少必不可少的位置实参 'last',导致 TypeError。在末尾的简短小结中,再次列出了最重要的信息。这样,即使你运行了很多测试,也可快速获悉哪些测试未通过以及测试未通过的原因。
11.2.5 在测试未通过时怎么办
在测试未通过时,该怎么办呢?如果检查的条件没错,那么测试通过意味着函数的行为是对的,而测试未通过意味着你编写的新代码有错。因此,在测试未通过时,不要修改测试。因为如果你这样做,即便能让测试通过,像测试那样调用函数的代码也将突然崩溃。相反,应修复导致测试不能通过的代码:检查刚刚对函数所做的修改,找出这些修改是如何导致函数行为不符合预期的。
在这个示例中,get_formatted_name() 以前只需要名和姓这两个实参,但现在要求提供名、中间名和姓,而且新增的中间名参数是必不可少的。这导致 get_formatted_name() 的行为与原来不同。就这里而言,最佳的选择是让中间名变为可选的。这样,不仅在使用类似于 Janis Joplin 的姓名进行测试时可以通过,而且这个函数还能接受中间名。下面来修改 get_formatted_name(),将中间名设置为可选的,然后再次运行这个测试用例。如果通过,就接着确认这个函数是否能够妥善地处理中间名。
要将中间名设置为可选的,可在函数定义中将形参 middle 移到形参列表末尾,并将其默认值指定为一个空字符串。还需要添加一个 if 测试,以便根据是否提供了中间名相应地创建姓名:
name_function.py
def get_formatted_name(first, last, middle=''):
"""生成格式规范的姓名"""
if middle:
full_name = f"{first} {middle} {last}"
else:
full_name = f"{first} {last}"
return full_name.title()
在 get_formatted_name() 的这个新版本中,中间名是可选的。如果向这个函数传递了中间名,姓名将包含名、中间名和姓,否则将只包含名和姓。现在,对于这两种不同的姓名,这个函数应该都能够正确地处理了。为了确定这个函数依然能够正确地处理像 Janis Joplin 这样的姓名,再次运行测试:
$ pytest
========================= test session starts =========================
—snip—
test_name_function.py . [100%]
========================== 1 passed in 0.00s ==========================
测试通过了。太好了,这意味着这个函数又能正确地处理像 Janis Joplin 这样的姓名了,无须手动测试这个函数。因为未通过的测试帮我们识别出了新代码是如何破坏函数原有行为的,所以函数的修复工作变得更容易了。
11.2.6 添加新测试
确定 get_formatted_name() 又能正确地处理简单的姓名后,我们再编写一个测试,用于测试包含中间名的姓名。为此,在文件 test_name_function.py 中添加一个测试函数:
test_name_function.py
from name_function import get_formatted_name
def test_first_last_name():
—snip—
def test_first_last_middle_name():
"""能够正确地处理像 Wolfgang Amadeus Mozart 这样的姓名吗?"""
❶ formatted_name = get_formatted_name(
'wolfgang', 'mozart', 'amadeus')
❷ assert formatted_name == 'Wolfgang Amadeus Mozart'
我们将这个新函数命名为 testfirst_last_middle_name()。记住,函数名必须以 test 打头,这样该函数才会在我们运行 pytest 时自动运行。这个函数名清楚地指出了它测试的是 get_formatted_name() 的哪个行为,如果该测试未通过,我们就能马上知道受影响的是哪种类型的姓名。
为测试 get_formatted_name() 函数,我们先使用名、姓和中间名调用它(见❶),再断言返回的姓名与预期的姓名(名、中间名和姓)一致(见❷)。再次运行 pytest,两个测试都通过了:
$ pytest
========================= test session starts =========================
—snip—
collected 2 items
❶ test_name_function.py .. [100%]
========================== 2 passed in 0.01s ==========================
❶处的两个点号表明有两个测试通过了,最后一行输出也清楚地指出了这一点。太好了!现在我们知道,这个函数又能正确地处理像 Janis Joplin 这样的姓名了,而且确定它也能够正确地处理像 Wolfgang Amadeus Mozart 这样的姓名。
动手试一试
练习 11.1:城市和国家 编写一个函数,它接受两个形参:一个城市名和一个国家名。这个函数返回一个格式为 City, Country 的字符串,如 Santiago, Chile。将这个函数存储在一个名为 city_functions.py 的模块中,并将这个文件存储在一个新的文件夹中,以免 pytest 在运行时,尝试运行之前编写的测试。
创建一个名为 test_cities.py 的程序,对刚编写的函数进行测试。编写一个名为 test_city_country() 的函数,核实在使用类似于 'santiago' 和 'chile' 这样的值来调用该函数时,得到的字符串是正确的。运行测试,确认 test_city_country() 通过了。
练习 11.2:人口数量 修改前面的函数,使其包含第三个必不可少的形参 population,并返回一个格式为 City, Country - population xxx 的字符串,如 Santiago, Chile - population 5000000。运行测试,确认 test_city_country() 未通过。
修改上述函数,将形参 population 设置为可选的。再次运行测试,确认 test_city_country() 又通过了。
再编写一个名为 test_city_country_population() 的测试,核实可以使用类似于 'santiago'、'chile' 和 'population=5000000' 这样的值来调用这个函数。再次运行测试,确认 test_city_country_population() 通过了。
11.3 测试类
在本章的前半部分,你编写了针对单个函数的测试,下面来编写针对类的测试。很多程序会用到类,因此证明类能够正确地工作十分必要。如果针对类的测试通过了,你就能确信对类所做的改进没有意外地破坏其原有的行为。
11.3.1 各种断言
到目前为止,我们只介绍了一种断言:声称一个字符串变量取预期的值。在编写测试时,可做出任何可表示为条件语句的断言。如果该条件确实成立,你对程序行为的假设就得到了确认,可以确信其中没有错误。如果你认为应该满足的条件实际上并不满足,测试就不能通过,让你知道代码存在需要解决的问题。表 11-1 列出了可在测试中包含的一些有用的断言。
表 11-1 测试中常用的断言语句
断言 | 用途 |
---|---|
assert a == b | 断言两个值相等 |
assert a != b | 断言两个值不等 |
assert a | 断言 a 的布尔求值为 True |
assert not a | 断言 a 的布尔求值为 False |
assert element in list | 断言元素在列表中 |
assert element not in list | 断言元素不在列表中 |
这里列出的只是九牛一毛,测试能包含任意可用条件语句表示的断言。
11.3.2 一个要测试的类
类的测试与函数的测试相似,所做的大部分工作是测试类中方法的行为。然而,二者还是存在一些不同之处。下面来编写一个要测试的类,这是一个帮助管理匿名调查的类:
survey.py
class AnonymousSurvey:
"""收集匿名调查问卷的答案"""
❶ def init(self, question):
"""存储一个问题,并为存储答案做准备"""
self.question = question
self.responses = []
❷ def show_question(self):
"""显示调查问卷"""
print(self.question)
❸ def store_response(self, new_response):
"""存储单份调查答卷"""
self.responses.append(new_response)
❹ def show_results(self):
"""显示收集到的所有答卷"""
print("Survey results:")
for response in self.responses:
print(f"- {response}")
这个类首先存储一个调查问题(见❶),并创建了一个空列表,用于存储答案。这个类包含打印调查问题的方法(见❷),在答案列表中添加新答案的方法(见❸),以及将存储在列表中的答案打印出来的方法(见❹)。要创建这个类的实例,只需提供一个问题即可。有了表示调查的实例,就可以使用 show_question() 来显示其中的问题,使用 store_response() 来存储答案,并使用 show_results() 来显示调查结果了。
为了证明 AnonymousSurvey 类能够正确地工作,编写一个使用它的程序:
language_survey.py
from survey import AnonymousSurvey
# 定义一个问题,并创建一个表示调查的 AnonymousSurvey 对象
question = "What language did you first learn to speak?"
language_survey = AnonymousSurvey(question)
# 显示问题并存储答案
language_survey.show_question()
print("Enter 'q' at any time to quit.\n")
while True:
response = input("Language: ")
if response == 'q':
break
language_survey.store_response(response)
# 显示调查结果
print("\nThank you to everyone who participated in the survey!")
language_survey.show_results()
这个程序定义了一个问题("What language did you first learn to speak?"),并使用这个问题创建了一个 AnonymousSurvey 对象。接下来,这个程序调用 show_question() 来显示问题,并提示用户输入答案。它会在收到每个答案的同时将其存储起来。用户输入所有答案(输入 q 要求退出)后,调用 show_results() 来打印调查结果:
What language did you first learn to speak?
Enter 'q' at any time to quit.
Language: English
Language: Spanish
Language: English
Language: Mandarin
Language: q
Thank you to everyone who participated in the survey!
Survey results:
- English
- Spanish
- English
- Mandarin
AnonymousSurvey 类可用于进行简单的匿名调查。假设我们将它放在了 survey 模块中,并想进行改进:让每个用户都可输入多个答案;编写一个方法,只列出不同的答案并指出每个答案出现了多少次;或者再编写一个类,用于管理非匿名调查。
进行上述修改存在风险,可能会影响 AnonymousSurvey 类的当前行为。例如,在允许每个用户输入多个答案时,可能不小心修改了处理单个答案的方式。要确认在开发这个模块时没有破坏既有的行为,可编写针对这个类的测试。
11.3.3 测试 AnonymousSurvey 类
下面来编写一个测试,对 AnonymousSurvey 类的行为的一个方面进行验证。我们要验证的是,如果用户在面对调查问题时只提供一个答案,这个答案也能被妥善地存储:
test_survey.py
from survey import AnonymousSurvey
❶ def test_store_single_response():
"""测试单个答案会被妥善地存储"""
question = "What language did you first learn to speak?"
❷ language_survey = AnonymousSurvey(question)
language_survey.store_response('English')
❸ assert 'English' in language_survey.responses
首先,导入要测试的 AnonymousSurvey 类。第一个测试函数验证:调查问题的单个答案被存储后,它会包含在调查结果列表中。对于这个测试函数,一个不错的描述性名称是 test_store_single_response()(见❶)。如果这个测试未通过,我们就能通过测试小结中的函数名得知,在存储单个调查答案方面存在问题。
要测试类的行为,需要创建其实例。在❷处,使用问题 "What language did you first learn to speak?" 创建一个名为 language_survey 的实例,然后使用 store_response() 方法存储单个答案 English。接下来,通过断言 English 在列表 language_survey.responses 中,核实这个答案被妥善地存储了(见❸)。
如果在执行命令 pytest 时没有指定任何参数,pytest 将运行它在当前目录中找到的所有测试。为了专注于一个测试文件,可将该测试文件的名称作为参数传递给 pytest。下面运行为 AnonymousSurvey 编写的测试:
$ pytest test_survey.py
========================= test session starts =========================
—snip—
test_survey.py . [100%]
========================== 1 passed in 0.01s ==========================
这开了一个好头,但只能收集一个答案的调查用途不大。下面来核实,当用户提供三个答案时,它们都将被妥善地存储。为此,再添加一个测试函数:
from survey import AnonymousSurvey
def test_store_single_response():
—snip—
def test_store_three_responses():
"""测试三个答案会被妥善地存储"""
question = "What language did you first learn to speak?"
language_survey = AnonymousSurvey(question)
❶ responses = ['English', 'Spanish', 'Mandarin']
for response in responses:
language_survey.store_response(response)
❷ for response in responses:
assert response in language_survey.responses
我们将这个新函数命名为 test_store_three_responses(),并像 test_store_single_response() 一样,在其中创建一个调查对象。先定义一个包含三个不同答案的列表(见❶),再对其中的每个答案都调用 store_response()。存储这些答案后,使用一个循环来断言每个答案都包含在 language_survey.responses 中(见❷)。
再次运行这个测试文件,两个测试(针对单个答案的测试和针对三个答案的测试)都通过了:
$ pytest test_survey.py
========================= test session starts =========================
—snip—
test_survey.py .. [100%]
========================== 2 passed in 0.01s ==========================
前述做法的效果很好,但这些测试有重复的地方。下面使用 pytest 的另一项功能来提高效率。
11.3.4 使用夹具
在前面的 test_survey.py 中,我们在每个测试函数中都创建了一个 AnonymousSurvey 实例。虽然这对于这个简单的示例来说不是问题,但在包含数十乃至数百个测试的项目中是个大问题。
在测试中,夹具(fixture)可帮助我们搭建测试环境。这通常意味着创建供多个测试使用的资源。在 pytest 中,要创建夹具,可编写一个使用装饰器 @pytest.fixture 装饰的函数。装饰器(decorator)是放在函数定义前面的指令。在运行函数前,Python 将该指令应用于函数,以修改函数代码的行为。这听起来很复杂,但是不用担心:即便没有学习如何编写装饰器,也可使用第三方包中的装饰器。
下面使用夹具创建一个 AnonymousSurvey 实例,让 test_survey.py 中的两个测试函数都可使用它:
import pytest
from survey import AnonymousSurvey
❶ @pytest.fixture
❷ def language_survey():
"""一个可供所有测试函数使用的 AnonymousSurvey 实例"""
question = "What language did you first learn to speak?"
language_survey = AnonymousSurvey(question)
return language_survey
❸ def test_store_single_response(language_survey):
"""测试单个答案会被妥善地存储"""
❹ language_survey.store_response('English')
assert 'English' in language_survey.responses
❺ def test_store_three_responses(language_survey):
"""测试三个答案会被妥善地存储"""
responses = ['English', 'Spanish', 'Mandarin']
for response in responses:
❻ language_survey.store_response(response)
for response in responses:
assert response in language_survey.responses
现在需要导入 pytest,因为我们使用了其中定义的一个装饰器。我们将装饰器 @pytest.fixture(见❶)应用于新函数 language_survey()(见❷)。这个函数创建并返回一个 AnonymousSurvey 对象。
请注意,两个测试函数的定义都变了(见❸和❺):都有一个名为 language_survey 的形参。当测试函数的一个形参与应用了装饰器 @pytest.fixture 的函数(夹具)同名时,将自动运行夹具,并将夹具返回的值传递给测试函数。在这个示例中,language_survey() 函数向 test_store_single_response() 和 test_store_three_responses() 提供了一个 language_survey 实例。
两个测试函数都没有新增代码,而且都删除了两行代码(见❹和❻):定义问题的代码行,以及创建 AnonymousSurvey 对象的代码行。
再次运行这个测试文件,这两个测试也都通过了。如果要扩展 AnonymousSurvey,使其允许每个用户输入多个答案,这些测试将很有用:修改代码以接受多个答案后,你可运行这些测试,确认存储单个答案或一系列答案的行为未受影响。
上述代码的结构看起来很复杂,包含一些非常抽象的代码。你并非一定要马上使用夹具,即使编写包含大量重复代码的测试也胜过根本不编写测试。你只需知道下面一点就好:如果编写的测试包含大量重复的代码,有一种已得到验证的方式可用来消除重复的代码。另外,对于简单的测试,使用夹具并不一定能让代码更简洁、更容易理解;但在项目包含大量测试或需要使用很多行代码来创建供多个测试使用的资源的情况下,使用夹具可极大地改善测试代码的质量。
在想要使用夹具时,可编写一个函数来生成供多个测试函数使用的资源,再对这个函数应用装饰器 @pytest.fixture,并让使用该资源的每个测试函数都接受一个与该函数同名的形参。这样,测试将更简洁,编写和维护起来也将更容易。
动手试一试
练习 11.3:雇员 编写一个名为 Employee 的类,其 init() 方法接受名、姓和年薪,并将它们都存储在属性中。编写一个名为 give_raise() 的方法,它默认将年薪增加 5000 美元,同时能够接受其他的年薪增加量。
为 Employee 类编写一个测试文件,其中包含两个测试函数:test_give_default_raise() 和 test_give_custom_raise()。在不使用夹具的情况下编写这两个测试,并确保它们都通过了。然后,编写一个夹具,以免在每个测试函数中都创建一个 Employee 对象。重新运行测试,确认两个测试都通过了。
11.4 小结
在本章中,你学习了如何使用 pytest 模块中的工具来为函数和类编写测试。不仅学习了如何编写测试函数,以核实函数和类的行为符合预期,而且学习了如何使用夹具来高效地创建可在测试文件中的多个测试函数中使用的资源。
测试是很多初学者并不熟悉的主题。作为初学者,你并非必须为自己尝试的所有项目编写测试。但是,在参与工作量较大的项目时,应该对自己编写的函数和类的重要行为进行测试。这样就能够确信,自己所做的工作不会破坏项目的其他部分,让你能够随心所欲地改进既有的代码。如果不小心破坏了原来的功能,你马上就会知道,从而能够轻松地修复问题。比起等到不满意的用户报告 bug 后再采取措施,在测试未通过时采取措施要容易得多。
如果你在项目中纳入了测试,其他程序员将更敬佩你。他们不仅能够更得心应手地使用你编写的代码,也更愿意与你合作开发项目。要给其他程序员开发的项目贡献代码,就必须证明你编写的代码通过了既有的测试,而且通常需要为你添加的新行为编写测试。
请通过多多开展测试来熟悉代码测试过程。对于自己编写的函数和类,请编写针对其重要行为的测试。但在早期的项目中,不必以编写全覆盖测试用例为目标,除非有充分的理由。