第三章:夹具¶
到目前为止,我们已经学习了pytest 的基本知识,是时候把注意力转向夹具(fixture),它对于是编写测试代码绕不开的话题。
📌 温馨提示1:在本章中,我们依旧会编写和运行基于tasks_proj
项目的测试函数,请确保在studypyest/ch3
目录中成功安装了Tasks项目。tasks_proj
项目最初是在第2章中引入的,具体安装步骤详见对应的安装指引。
📌 温馨提示2:在本章中执行终端命令的基准目录一律为测试目录,即ch3/tasks_proj/tests
。如果有指定cd
命令的,则需要先切换到该目录后再执行对应命令。
3.1 夹具概念¶
夹具(Jiājù)是指机械制造过程中用来固定加工对象,使之占有正确的位置,以接受施工或检测的夹具。 从广义上说,在工艺过程中的任何工序,用来迅速、方便、安全地安装工件的夹具,都可称为夹具。————百度百科
pytest fixtures are functions attached to the tests which run before the test function is executed.
在我们开始研究夹具之前,需要说明一个事实,即夹具这个术语在编程和测试社区中有很多含义,甚至在 Python 社区中也是如此。我们在本章节中使用的夹具、夹具函数和夹具方法,指的都是使用@pytest.fixture()
装饰的函数。夹具也可以用来指代由夹具函数提供的资源。夹具函数通常会设置或获取一些测试可以使用的测试数据。有时这些数据被认为是一个夹具。例如,Django 社区经常使用夹具来表示在应用程序开始时加载到数据库中的一些初始数据。
不管其他含义如何,在本书中,测试夹具指的是pytest提供的机制,它允许从测试函数中分离事前准备和事后清理的代码。
夹具是一类函数,通常在具体的测试函数之前或是之后运行。夹具中的代码可以做任何事情,也可以专门用来给多个测试函数提供所需的数据集,也可以在运行测试之前使系统进入既定的状态,即初始化工作。
大体上可以分为pytest内置夹具函数和用户自定义的夹具函数。
pytest 的夹具是 pytest 从其他测试框架中脱颖而出的独特且核心的特性之一,也是许多人转向并坚持使用pytest 的原因。然而,pytest 中的夹具不同于 Django 中的夹具,也不同于 unittest 和 nose 中的前置setup 和后置teardown过程。它有很多特性和细微差别。一旦你对夹具的工作原理有了一个良好的概念模型,它们对你来说就会显得很容易。
3.2 夹具定义¶
语法:@pytest.fixture(scope="function", autouser=False, name="xxx")
装饰器参数:
scope
参数:可选,表示夹具的作用域,默认值为函数范围。autouse
参数:可选,表示是否开启自动使用,默认值为False
。name
参数:可选,表示夹具的名称,默认值为夹具函数的名称。params
参数:可选,表示夹具用于参数化的数据集。ids
参数:可选,表示夹具参数化的标识符,值支持可迭代对象和可调用对象。
下面是一个简单的夹具示例,用来返回一个数字:
# ch3/test_fixtures.py
import pytest
# 夹具
@pytest.fixture()
def some_data():
"""Return answer to ultimate question."""
return 42
# 测试函数
def test_some_data(some_data):
"""Use fixture return value in a test."""
assert some_data == 42
@pytest.fixture()
装饰器是用来告诉pytest,这个函数是一个夹具函数。如果在测试函数的参数列表中传入夹具函数名称时,pytest就会在运行测试函数之前先运行这个夹具函数;第14行开始是测试函数test_some_data()
的代码,它接收 some_data
夹具的名称作为参数,那么pytest会寻找一个带有这个名字的夹具。pytest 将在测试函数所在模块中查找该名字的夹具,如果没有找到,则会去conftest.py
插件文件中查找。
3.3 共享夹具¶
Sharing fixtures Through conftest.py
你可以将夹具放到单个测试模块中,但是如果要在多个测试模块之间共享夹具,则需要在所有测试模块的某个中心位置使用conftest.py
文件。
对于task_proj 项目,所有的夹具都统一在 tasks_proj/tests/conftest.py
这个本地插件文件中。这样一来,夹具可以共享给当前项目中的所有测试模块。
如果你希望夹具只用于单个测试文件中,你可以把 夹具放在对应的测试文件中。同样,你也可以将 conftest.py 文件放在 tasks_proj/tests
的子目录中。如果你这样做了,定义在这些较低级别的 conftest.py 文件中的夹具将可用于该目录和子目录中的测试。
注意:虽然 conftest.py
是一个普通 python 模块,但是不要试图在测试文件中import
导入它!这是因为 conftest.py 文件是一个特殊文件,会被pytest当成一个本地插件来读取。我们只需要简单地把conftest.py
文件看作是一个存放共享夹具的地方。
更多关于插件的知识详见第5章(插件)。
接下来,让我们针对 tasks_proj 编写一些测试用例来演示如何正确地使用夹具。
3.3 显式使用¶
使用夹具的第一种方式:将夹具函数名称传入测试函数的参数列表。
语法:def test_xxx(FIXTURE_FUNCTION_NAME)
用作前置后置¶
Using fixtures for Setup and Teardown
前面第二章中演示的关于Tasks 项目中的大多数测试函数,都假设tasks数据库已经成功地创建、运行并准备就绪。如果需要清理的话,我们应该在最后清理,也许还可以断开与数据库的连接。
幸运的是,tasks项目的源码中的现成代码已经可以解决大部分问题,参见tasks_proj/src/tasks/api.py
模块中的start_tasks_db(db_path, db_type)
和 stop_tasks_db()
函数。我们只需要在正确的时间调用它们即可。
# ch2/tasks_proj/src/tasks/api.py
#代码片段
# ...
def start_tasks_db(db_path, db_type): # type: (str, str) -> None
"""Connect API functions to a db."""
if not isinstance(db_path, string_types):
raise TypeError('db_path must be a string')
global _tasksdb
if db_type == 'tiny':
import tasks.tasksdb_tinydb
_tasksdb = tasks.tasksdb_tinydb.start_tasks_db(db_path)
elif db_type == 'mongo':
import tasks.tasksdb_pymongo
_tasksdb = tasks.tasksdb_pymongo.start_tasks_db(db_path)
else:
raise ValueError("db_type must be a 'tiny' or 'mongo'")
def stop_tasks_db(): # type: () -> None
"""Disconnect API functions from db."""
global _tasksdb
_tasksdb.stop_tasks_db()
_tasksdb = None
tmpdir
的内置夹具,可以直接拿来就用,根本不必担心事后的清理工作。真是妙蛙种子吃着妙脆角妙进了米奇妙妙屋妙到家了呀。
根据上面分析的已知条件,一个新的夹具就应运而生了。
💡 在ch3/tasks_proj/tests
目录的插件文件conftest.py
中,添加新的夹具:
# ch3/tasks_proj/tests/conftest.py
import pytest
import tasks
@pytest.fixture()
def tasks_db(tmpdir):
"""Connect to db before tests, disconnect after."""
# Setup : start db
tasks.start_tasks_db(str(tmpdir), 'tiny')
yield # this is where the testing happens
# Teardown : stop db
tasks.stop_tasks_db()
tasks.start_tasks_db(str(tmpdir), 'tiny')
,其中 tmpdir 的值不是一个字符串,而是一个表示目录的对象。但是,由于它内部实现了__str__()
特殊方法,因此我们可以使用str()
函数来获得一个字符串,并作为第一个参数传递给start_tasks_db()
函数;这里的第二个参数'tiny'
表示TinyDB
数据库。
更多关于内置夹具tempdir的知识详见【第四章:内置夹具】
夹具在使用它的测试函数之前运行。如果在夹具中有yield语句,它就会停在那里,将控制权传递给测试函数,并在测试完成后再回来继续执行。因此,可以把yield之前的代码看作是前置(setup
),yield之后的代码看作是后置(teardown
)。yield之后的代码无论在测试过程中发生了什么,都一定会运行。目前我们不会在这个夹具中使用yield返回任何数据。
让我们使用这个新鲜出炉的夹具,在tasks.add()
中尝试编写一个测试函数。
💡 在ch3/tasks_proj/tests/func
目录的测试模块test_add.py
中,添加新的测试函数:
# ch3/tasks_proj/tests/func/test_add.py
import tasks
from tasks import Task
def test_add_returns_valid_id(tasks_db):
"""tasks.add(<valid task>) should return an integer."""
# GIVEN an initialized tasks db
# WHEN a new task is added
# THEN returned task_id is of type int
new_task = Task('do something')
task_id = tasks.add(new_task)
assert isinstance(task_id, int)
tasks_db
夹具添加到了测试函数的参数列表中。第10行开始,使用注释来将测试用例的结构设置成 GIVEN/WHEN/THEN
的格式是个不错的主意,特别是在代码结构不明显的情况下。GIVEN an initialized tasks db
这句描述有助于解释为什么tasks_db被用作测试的夹具的原因。
执行命令:
$ pytest func/test_add.py -v
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch3\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item
func/test_add.py::test_add_returns_valid_id PASSED [100%]
========================== 1 passed in 0.02s ===========================
用来提供数据¶
Using fixtures for Test Data
夹具是存储测试数据的好地方。比如,这里有一个返回混合类型元组的夹具。
💡 在ch3/
目录的测试模块test_fixtures.py
中,添加新的夹具和测试函数:
# ch3/test_fixtures.py
# 代码片段
# ...
# 返回元组数据的夹具
@pytest.fixture()
def a_tuple():
"""Return something more interesting."""
return (1, 'foo', None, {'bar': 23})
def test_a_tuple(a_tuple):
"""Demo the a_tuple fixture."""
assert a_tuple[3]['bar'] == 32
test_a_tuple()
注定会失败(因为23! = 32不成立),我们可以看到,当一个带有夹具的测试用例执行失败时会是什么样子。
执行命令:
$ cd ch3
$ pytest test_fixtures.py::test_a_tuple -v
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch3
collected 1 item
test_fixtures.py::test_a_tuple FAILED [100%]
=============================== FAILURES ===============================
_____________________________ test_a_tuple _____________________________
a_tuple = (1, 'foo', None, {'bar': 23})
def test_a_tuple(a_tuple):
"""Demo the a_tuple fixture."""
> assert a_tuple[3]['bar'] == 32
E assert 23 == 32
test_fixtures.py:24: AssertionError
======================= short test summary info ========================
FAILED test_fixtures.py::test_a_tuple - assert 23 == 32
========================== 1 failed in 0.08s ===========================
a_tuple = (1, 'foo', None, {'bar': 23})
。在测试函数这里,夹具是测试函数的参数,因此与报错一同展示。
如果在夹具中含有assert 断言(或异常),将会发生什么?来看一个示例。
💡 在ch3/
目录的测试模块test_fixtures.py
中,添加新的夹具和测试函数:
# ch3/test_fixtures.py
# 代码片段
# ...
@pytest.fixture()
def some_other_data():
"""Raise an exception from fixture."""
assert 1 == 2
def test_other_data(some_other_data):
pass
$ cd ch3
$ pytest test_fixtures.py::test_other_data -v
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch3
collected 1 item
test_fixtures.py::test_other_data ERROR [100%]
================================ ERRORS ================================
__________________ ERROR at setup of test_other_data ___________________
@pytest.fixture()
def some_other_data():
"""Raise an exception from fixture."""
> assert 1 == 2
E assert 1 == 2
test_fixtures.py:30: AssertionError
======================= short test summary info ========================
ERROR test_fixtures.py::test_other_data - assert 1 == 2
=========================== 1 error in 0.09s ===========================
test_other_data()
测试函数的执行结果是判定为 ERROR(错误),而不是判定成 FAILED(失败),因为失败发生在测试函数之外,是夹具中的错误导致的。这个区别很大,需要注意。FAILED失败则是表示失败发生在测试函数本身,而不是在它所依赖的任何夹具。
对于 Tasks 项目,我们或许可以使用一些数据夹具,也许是不同的列表,其中包含不同属性的task对象。
💡 在ch3/tasks_proj/tests
目录的本地插件conftest.py
中,添加新的夹具:
# ch3/tasks_proj/tests/conftest.py
# 代码片段
# ...
from tasks import Task
# Reminder of Task constructor interface
# Task(summary=None, owner=None, done=False, id=None)
# summary is required
# owner and done are optional
# id is set by database
@pytest.fixture()
def tasks_just_a_few():
"""All summaries and owners are unique."""
return (
Task('Write some code', 'Brian', True),
Task("Code review Brian's code", 'Katie', False),
Task('Fix what Brian did', 'Michelle', False))
@pytest.fixture()
def tasks_mult_per_owner():
"""Several owners with several tasks each."""
return (
Task('Make a cookie', 'Raphael'),
Task('Use an emoji', 'Raphael'),
Task('Move to Berlin', 'Raphael'),
Task('Create', 'Michelle'),
Task('Inspire', 'Michelle'),
Task('Encourage', 'Michelle'),
Task('Do a handstand', 'Daniel'),
Task('Write some books', 'Daniel'),
Task('Eat ice cream', 'Daniel'))
追踪夹具信息¶
Tracing Fixutre Execution with –setup-show option
刚才的输出结果美中不足的是,我们完全看不到任何有关夹具的信息,也不确定我们的tasks_db
夹具是不是真的运行了。当我们在开发和调试夹具时,要是能够看到哪个夹具在运行,什么时候运行了该多好。幸运的是,pytest 提供了一个命令行选项--setup-show
,它可以满足我们的需求。
执行命令:
$ pytest func/test_add.py -v --setup-show
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch3\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item
func\test_add.py
SETUP S tmp_path_factory
SETUP F tmp_path (fixtures used: tmp_path_factory)
SETUP F tmpdir (fixtures used: tmp_path)
SETUP F tasks_db (fixtures used: tmpdir)
func/test_add.py::test_add_returns_valid_id (fixtures used: reque
st, tasks_db, tmp_path, tmp_path_factory, tmpdir).
TEARDOWN F tasks_db
TEARDOWN F tmpdir
TEARDOWN F tmp_path
TEARDOWN S tmp_path_factory
========================== 1 passed in 0.02s ===========================
test_add_returns_valid_id
的执行结果,位于中间位置;pytest 为每个夹具指定了一个 SETUP
前置和 TEARDOWN
后置区域。在前置区域可以看到,tmpdir
夹具是在测试函数之前运行的(第11行),并且更前面的是两个内置的tmp_path
夹具和tmpdir_factory
夹具。因此可以推断出,tmpdir
的内部其实使用了这两个夹具。另外,夹具名称前面的大写字母 F
和S
表示夹具的作用域范围。F
对应的是函数(function
)级别,S
对应的是会话(session
)级别。
夹具嵌套¶
Using Multiple fixtures
我们之前已经见过了在tmpdir
夹具中使用了tmpdir_factory
夹具的场景,后来,我们又在本地插件文件conftest.py
中的tasks_db
夹具中使用了tmpdir
夹具。嗯,看起来像个套娃游戏。
让我们继续接力套起来,来为非空的tasks数据库添加一些专门的夹具。
💡 在ch3/tasks_proj/tests
目录的本地插件conftest.py
中,添加新的使用夹具的夹具:
# ch3/tasks_proj/tests/conftest.py
# 代码片段
# ...
@pytest.fixture()
def db_with_3_tasks(tasks_db, tasks_just_a_few):
"""Connected db with 3 tasks, all unique."""
for t in tasks_just_a_few:
tasks.add(t)
@pytest.fixture()
def db_with_multi_per_owner(tasks_db, tasks_mult_per_owner):
"""Connected db with 9 tasks, 3 owners, all with 3 tasks."""
for t in tasks_mult_per_owner:
tasks.add(t)
现在,如果需要从一个非空数据库开始测试时,那么测试函数可以这样编写。
💡 在ch3/tasks_proj/tests/func
目录的测试模块test_add.py
中,添加新的测试函数test_add_increases_count()
:
# ch3/tasks_proj/tests/func/test_add.py
# 代码片段
# ...
def test_add_increases_count(db_with_3_tasks):
"""Test tasks.add() affect on tasks.count()."""
# GIVEN a db with 3 tasks
# WHEN another task is added
tasks.add(Task('throw a party'))
# THEN the count increases by 1
assert tasks.count() == 4
为 GIVEN/WHEN/THEN 编写注释,并尽可能把 GIVEN 改造成夹具是个不错的主意 ,原因有两点。首先,这样做使得测试函数更容易阅读,因此更加容易维护;其次,如果数据库初始化失败,在夹具中的assert 断言或异常将导致ERROR结果,而在测试函数中的 assert 断言或异常则导致FAILED结果。避免出现测试函数test_add_increases_count()
中,因为数据库初始化失败(本身就不是测试函数的锅)而导致被判为失败,测试函数表示这个锅它不背,毕竟只有在测试函数的逻辑,即add()
功能确实无法改变计数的情况下,才有可能把测试函数 test_add_increases_count() 判定为 FAILED,这才合情合理。
让我们运行一下,看看所有的夹具表现如何。
执行命令:
$ pytest func/test_add.py::test_add_increases_count -v --setup-show
--setup-show
选项表示执行测试时展示fixtures配置情况。
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch3\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item
func/test_add.py::test_add_increases_count
SETUP S tmp_path_factory
SETUP F tmp_path (fixtures used: tmp_path_factory)
SETUP F tmpdir (fixtures used: tmp_path)
SETUP F tasks_db (fixtures used: tmpdir)
SETUP F tasks_just_a_few
SETUP F db_with_3_tasks (fixtures used: tasks_db, tasks_just_a
_few)
func/test_add.py::test_add_increases_count (fixtures used: db_wit
h_3_tasks, request, tasks_db, tasks_just_a_few, tmp_path, tmp_path_factor
y, tmpdir)PASSED
TEARDOWN F db_with_3_tasks
TEARDOWN F tasks_just_a_few
TEARDOWN F tasks_db
TEARDOWN F tmpdir
TEARDOWN F tmp_path
TEARDOWN S tmp_path_factory
========================== 1 passed in 0.04s ===========================
db_with_3_tasks
,而在db_with_3_tasks
夹具中又引用了tasks_db
和tasks_just_a_few
两个夹具,而tasks_db
夹具中又引用了tempdir
夹具,以此类推,所有引用的夹具都依次列举出来了。
3.4 作用域¶
Specifying Fixture Scope
根据夹具的定义语法,它支持一个名为 scope
的可选参数,用来控制夹具启动和解除的频率。scope
参数的合法值可以是"fuction"
、"class"
、"module"
、和"session"
。默认的作用域是function
函数级别。
到目前为止,在本地插件文件conftest.py
中编写的所有夹具都没有指定作用域。因此,它们都是函数范围的夹具。
下面是所有作用域范围:
作用域参数值 | 标志 | 范围 | 说明 |
---|---|---|---|
scope="function" | F | 函数 | 默认的作用域。每个测试函数运行一次。setup前置部分在每次测试之前使用夹具运行,teardown后置部分在每次使用夹具的测试之后运行 |
scope="class" | C | 类 | 每个测试类运行一次,不管类中有多少个测试方法 |
scope="module" | M | 模块 | 每个模块运行一次,不管模块中有多少测试函数或方法或其他夹具夹具使用它 |
scope="session" | S | 会话 | 每个会话运行一次。所有使用会话范围的夹具 的测试方法和函数都共享一个 setup 和 teardown 调用 |
💡 在ch3
目录添加新的测试模块test_scope.py
,添加新的夹具和测试函数:
# ch3/test_scope.py
"""Demo fixture scope."""
import pytest
@pytest.fixture(scope='function')
def func_scope():
"""A function scope fixture."""
pass
@pytest.fixture(scope='module')
def mod_scope():
"""A module scope fixture."""
pass
@pytest.fixture(scope='session')
def sess_scope():
"""A session scope fixture."""
pass
@pytest.fixture(scope='class')
def class_scope():
"""A class scope fixture."""
pass
# 测试函数
def test_1(sess_scope, mod_scope, func_scope):
"""Test using session, module, and function scope fixtures."""
assert True
# 测试函数
def test_2(sess_scope, mod_scope, func_scope):
"""Demo is more fun with multiple tests."""
assert True
# 测试类
@pytest.mark.usefixtures('class_scope')
class TestSomething:
"""Demo class scope fixtures."""
def test_3(self):
"""Test using a class scope fixture."""
assert True
def test_4(self):
"""Again, multiple tests are more fun."""
assert True
让我们使用 --setup-show
来演示夹具的调用次数,以及根据不同作用域何时运行setup和teardown。
$ cd ch3
$ pytest test_scope.py --setup-show -v
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch3
collected 4 items
test_scope.py::test_1
SETUP S sess_scope
SETUP M mod_scope
SETUP F func_scope
test_scope.py::test_1 (fixtures used: func_scope, mod_scope, sess
_scope)PASSED
TEARDOWN F func_scope
test_scope.py::test_2
SETUP F func_scope
test_scope.py::test_2 (fixtures used: func_scope, mod_scope, sess
_scope)PASSED
TEARDOWN F func_scope
test_scope.py::TestSomething::test_3
SETUP C class_scope
test_scope.py::TestSomething::test_3 (fixtures used: class_scope)
PASSED
test_scope.py::TestSomething::test_4
test_scope.py::TestSomething::test_4 (fixtures used: class_scope)
PASSED
TEARDOWN C class_scope
TEARDOWN M mod_scope
TEARDOWN S sess_scope
========================== 4 passed in 0.02s ===========================
F
和S
,还有分别表示类范围(class)和模块范围(module)的作用域标志C
和M
。
作用域是在夹具的定义时设置的,而不是在它被调用的地方。真正使用夹具的测试函数并不能控制夹具启动和销毁的频率。
夹具只能依赖于其范围相同或范围更广的其他夹具夹具。所以函数级别的作用域夹具可以依赖于其他函数作用域夹具。默认的作用域范围是函数范围,目前在Tasks 项目中使用的默认范围也都是函数级别。函数作用域范围夹具可以依赖于类、模块和会话范围的夹具,但是反过来则不行。
有了作用域的知识,现在让我们更改一些 Task 项目的一些夹具的作用域范围。
到目前为止,我们还没有遇到测试时间的问题。 因为 tasks_db
夹具的作用域范围是函数级别的,为每个测试函数都会建立一个临时目录和新的数据库连接,但是似乎是一种浪费。只要我们能在需要的时候保证一个空的数据库,这就足够了。
要使tasks_db
之类的夹具的作用域变成会话范围,则需要使用 tmpdir_factory
夹具,因为 tmpdir 是函数范围,tmpdir _ factory 才是会话范围。
💡 在ch3/tasks_proj/tests
目录的本地插件文件conftest.py
中,添加新的夹具tasks_db_session
,修改tasks_db
夹具的代码:
# ch3/b/tasks_proj/tests/conftest.py
# 代码片段
@pytest.fixture(scope='session')
def tasks_db_session(tmpdir_factory):
"""Connect to db before tests, disconnect after."""
temp_dir = tmpdir_factory.mktemp('temp')
tasks.start_tasks_db(str(temp_dir), 'tiny')
yield
tasks.stop_tasks_db()
@pytest.fixture()
def tasks_db(tasks_db_session):
"""An empty tasks db."""
tasks.delete_all()
# ...
tasks_db
夹具曾经接收函数范围的 tempdir
,现在更改为接收会话范围的 tasks_db_session
夹具,并删除了所有数据记录以确保数据库是空的。因为我们没有改变夹具的名字,所以之前已经包含tasks_db
夹具的任何其他夹具或测试函数都不受影响。
数据夹具只是返回一组数据,例如tasks_just_a_few()
和tasks_mult_per_owner()
夹具。所以每个测试会话运行一次也就完全足够了,真的没有必要让它们一直运行。
💡 在ch3/tasks_proj/tests
目录的本地插件文件conftest.py
中,修改tasks_just_a_few()
和tasks_mult_per_owner()
夹具的作用域范围,加上参数scope="session"
:
# ch3/b/tasks_proj/tests/conftest.py
# 代码片段
# ...
@pytest.fixture(scope="session")
def tasks_just_a_few():
"""All summaries and owners are unique."""
return (
Task('Write some code', 'Brian', True),
Task("Code review Brian's code", 'Katie', False),
Task('Fix what Brian did', 'Michelle', False))
@pytest.fixture(scope="session")
def tasks_mult_per_owner():
"""Several owners with several tasks each."""
return (
Task('Make a cookie', 'Raphael'),
Task('Use an emoji', 'Raphael'),
Task('Move to Berlin', 'Raphael'),
Task('Create', 'Michelle'),
Task('Inspire', 'Michelle'),
Task('Encourage', 'Michelle'),
Task('Do a handstand', 'Daniel'),
Task('Write some books', 'Daniel'),
Task('Eat ice cream', 'Daniel'))
现在,让我们看看这些改变是否对我们的测试依旧有效:
$ pytest -v
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch3\tasks_proj\tests, configfile: p
ytest.ini
collected 2 items
func\test_add.py .. [100%]
========================== 2 passed in 0.04s ===========================
让我们追踪其中一个测试文件的夹具,看看不同的作用域是否正常工作。
$ pytest func/test_add.py --setup-show
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch3\tasks_proj\tests, configfile: p
ytest.ini
collected 2 items
func\test_add.py
SETUP S tmpdir_factory
SETUP S tasks_db_session (fixtures used: tmpdir_factory)
SETUP F tasks_db (fixtures used: tasks_db_session)
func/test_add.py::test_add_returns_valid_id (fixtures used: reque
st, tasks_db, tasks_db_session, tmpdir_factory).
TEARDOWN F tasks_db
SETUP S tasks_just_a_few
SETUP F tasks_db (fixtures used: tasks_db_session)
SETUP F db_with_3_tasks (fixtures used: tasks_db, tasks_just_a
_few)
func/test_add.py::test_add_increases_count (fixtures used: db_wit
h_3_tasks, request, tasks_db, tasks_db_session, tasks_just_a_few, tmpdir_
factory).
TEARDOWN F db_with_3_tasks
TEARDOWN F tasks_db
TEARDOWN S tasks_db_session
TEARDOWN S tmpdir_factory
TEARDOWN S tasks_just_a_few
========================== 2 passed in 0.04s ===========================
tasks_db_session()
夹具现在每个会话只调用一次,更快的 tasks_db
现在只是在每次测试函数执行之前清空数据库。
3.5 自动使用¶
语法:@pytest.fixture(autouse=True)
到目前为止,测试函数或者类中使用的夹具都是通过传递夹具默认名称来显示指定的。
其实还有第二种使用夹具的方式,可以在夹具定义时设置 autouse=True
来让一个夹具在它的作用域范围内总是自动使用,此时不需要在测试函数参数中传递夹具名称。如果希望在特定时机运行某个夹具,并且测试函数又不依赖于任何系统状态或来自夹具的数据时,设置成自动使用就很有用。
💡 在ch3/tasks_proj/tests/demo
目录新增测试模块test_autouse.py
,添加夹具和测试函数:
# ch3/tasks_proj/tests/demo/test_autouse.py
"""演示自动启用autouse的夹具"""
import pytest
import time
# 夹具
@pytest.fixture(autouse=True, scope='session')
def footer_session_scope():
"""在测试会话结束的时候展示系统时间的夹具"""
yield
now = time.time()
print('--')
print('finished : {}'.format(time.strftime('%d %b %X', time.localtime(now))))
print('-----------------')
# 夹具
@pytest.fixture(autouse=True)
def footer_function_scope():
"""在每个测试函数执行结束后打印测试过程时长"""
start = time.time()
yield
stop = time.time()
delta = stop - start
print('\ntest duration : {:0.3} seconds'.format(delta))
# 测试函数
def test_1():
"""模拟耗时很久的测试函数"""
time.sleep(2)
def test_2():
"""模拟耗时特别久的测试函数"""
time.sleep(3)
footer_session_scope
夹具和footer_function_scope
夹具都设定成了自动使用,并且在每次测试之后打印测试耗时,以及在会话结束时打印当前系统时间。
执行命令:
$ pytest demo/test_autouse.py -v -s
=========================== test session starts ============================
platform darwin -- Python 3.9.7, pytest-6.2.0, py-1.10.0, pluggy-0.13.1 -- /Users/xiaofo/Envs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /Users/xiaofo/coding/Personal/studypytest/ch3/tasks_proj/tests, configfile: pytest.ini
plugins: allure-pytest-2.9.45, Faker-9.3.1, ordering-0.6
collected 2 items
demo/test_autouse.py::test_1 PASSED
test duration : 2.01 seconds
demo/test_autouse.py::test_2 PASSED
test duration : 3.01 seconds
--
finished : 19 Apr 11:04:07
-----------------
============================ 2 passed in 5.05s =============================
autouse
的特性了,简直一劳永逸,不需要再每一个测试函数中手工指定了。
那么有没有必要把任何夹具也设成自动使用,比如tasks_db
夹具?其实,没这个必要,要看具体业务。因为在Tasks 项目中,在 db 初始化之前测试 API 函数的功能是很重要的,万一有bug时会引发一个适当的异常。但是如果我们强制每个测试都进行了db的初始化,我们有可能就不能识别它。
3.6 命名¶
语法:@pytest.fixture(name="xxx")
在前面的示例中,我们在定义夹具的时候,都没有指定name参数。因此,当需要指定夹具名称的时候,无论是在测试函数的参数列表中,还是在其他夹具的参数列表中,使用的都是夹具的默认函数名。如果夹具函数的名称非常长时,就会显得很不方便,这时就可以考虑使用name
参数给夹具重新命名。
💡 在ch3/tasks_proj/tests/demo
目录中,新增测试模块test_rename_fixture.py
,添加夹具和测试函数:
# ch3/tasks_proj/tests/demo/test_rename_fixture.py
"""演示重命名夹具"""
import pytest
@pytest.fixture(name="clifford")
def a_fixture_with_a_name_much_longer():
"""演示夹具有一个非常长的默认名称"""
return 666
def test_everything(clifford):
"""使用夹具自定义名称"""
assert clifford == 666
clifford
现在是夹具的真正名称了,而不再是a_fixture_with_a_name_much_longer
。
执行命令:
$ pytest demo/test_rename_fixture.py --setup-show
=========================== test session starts ============================
platform darwin -- Python 3.9.7, pytest-6.2.0, py-1.10.0, pluggy-0.13.1
rootdir: /Users/xiaofo/coding/Personal/studypytest/ch3/tasks_proj/tests, configfile: pytest.ini
plugins: allure-pytest-2.9.45
collected 1 item
demo/test_rename_fixture.py
SETUP F clifford
demo/test_rename_fixture.py::test_everything (fixtures used: clifford).
TEARDOWN F clifford
============================ 1 passed in 0.01s =============================
如果需要查看 clifford
夹具是在什么地方定义的,可以在提供测试文件名的同时,添加--fixtures
选项,它会列出该文件中所有可用的夹具,包括那些已经被重命名的。
$ pytest demo/test_rename_fixture.py --fixtures
=========================== test session starts ============================
platform darwin -- Python 3.9.7, pytest-6.2.0, py-1.10.0, pluggy-0.13.1
rootdir: /Users/xiaofo/coding/Personal/studypytest/ch3/tasks_proj/tests, configfile: pytest.ini
plugins: allure-pytest-2.9.45
collected 1 item
......此处省略内置夹具清单
----------- fixtures defined from ch3.tasks_proj.tests.conftest ------------
tasks_db
An empty tasks db.
tasks_just_a_few [session scope]
All summaries and owners are unique.
tasks_mult_per_owner [session scope]
Several owners with several tasks each.
db_with_3_tasks
Connected db with 3 tasks, all unique.
db_with_multi_per_owner
Connected db with 9 tasks, 3 owners, all with 3 tasks.
tasks_db_session [session scope]
Connect to db before tests, disconnect after.
--- fixtures defined from ch3.tasks_proj.tests.demo.test_rename_fixture ----
clifford
演示夹具有一个非常长的默认名称
========================== no tests ran in 0.01s ===========================
clifford
夹具展示在底部,同时也显示了它被定义的模块位置。我们可以用这个方法来查找一个夹具的定义的位置。
我们用同样的方式来看看tasks项目的test_add.py
模块,看看是什么效果。
$ pytest func/test_add.py --fixtures
=========================== test session starts ============================
platform darwin -- Python 3.9.7, pytest-6.2.0, py-1.10.0, pluggy-0.13.1
rootdir: /Users/xiaofo/coding/Personal/studypytest/ch3/tasks_proj/tests, configfile: pytest.ini
plugins: allure-pytest-2.9.45
collected 2 items
......此处省略内置夹具清单
----------- fixtures defined from ch3.tasks_proj.tests.conftest ------------
tasks_db
An empty tasks db.
tasks_just_a_few [session scope]
All summaries and owners are unique.
tasks_mult_per_owner [session scope]
Several owners with several tasks each.
db_with_3_tasks
Connected db with 3 tasks, all unique.
db_with_multi_per_owner
Connected db with 9 tasks, 3 owners, all with 3 tasks.
tasks_db_session [session scope]
Connect to db before tests, disconnect after.
========================== no tests ran in 0.01s ===========================
conftest.py
中定义的所有夹具都在这里显示了。
3.7 参数化¶
语法:@pytest.fixture(params=dataset)
在第二章中的标记参数化测试的时候,我们给测试函数进行了参数化。其实,pytest也同样支持对夹具进行参数化。
定制数据集¶
我们继续像以前一样,使用现有的task列表tasks_to_try
、task标识符task_ids
和等价函数equivalent
。
💡 在ch3/tasks_proj/tests/func
目录中,新增测试模块test_add_variety2.py
,添加夹具a_task
和测试函数test_add_a_task
:
# ch3/tasks_proj/tests/func/test_add_variety2.py
import pytest
import tasks
from tasks import Task
import pytest
import tasks
from tasks import Task
# 数据集
tasks_to_try = (Task('sleep', done=True),
Task('wake', 'brian'),
Task('breathe', 'BRIAN', True),
Task('exercise', 'BrIaN', False))
# 辅助函数,判断是否相等
def equivalent(t1, t2):
"""Check two tasks for equivalence."""
return ((t1.summary == t2.summary) and
(t1.owner == t2.owner) and
(t1.done == t2.done))
# 参数化夹具
@pytest.fixture(params=tasks_to_try)
def a_task(request):
"""Using no ids."""
return request.param
# 测试函数
def test_add_a(tasks_db, a_task):
"""Using a_task fixture (no ids)."""
task_id = tasks.add(a_task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, a_task)
a_task
的夹具进行参数化。夹具函数的参数列表中的request
是一个pytest内置夹具,它表示夹具的调用状态。request
夹具有一个param
属性,它的值每次对应于params
数据集中的一个元素。
这个a_task
夹具非常简单,它只是将request.param
,也就是数据集的每一项作为它的值返回给使用它的测试函数。由于我们的tasks_to_try
数据集中有四个task对象,因此夹具会被调用四次,所以测试函数会被调用四次。
执行代码:
$ pytest func/test_add_variety2.py::test_add_a -v
=========================== test session starts ============================
platform darwin -- Python 3.9.7, pytest-6.2.0, py-1.10.0, pluggy-0.13.1 -- /Users/xiaofo/Envs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /Users/xiaofo/coding/Personal/studypytest/ch3/tasks_proj/tests, configfile: pytest.ini
plugins: allure-pytest-2.9.45
collected 4 items
func/test_add_variety2.py::test_add_a[a_task0] PASSED [ 25%]
func/test_add_variety2.py::test_add_a[a_task1] PASSED [ 50%]
func/test_add_variety2.py::test_add_a[a_task2] PASSED [ 75%]
func/test_add_variety2.py::test_add_a[a_task3] PASSED [100%]
============================ 4 passed in 0.02s =============================
这里的数据集是对象集合,但是我们目前没有指定参数化的标识符列表ids
,所以pytest默认使用夹具名称加数字序号临时编造了一些名称作为用例标识符。
定制标识符¶
可迭代的标识符
在fixture的定义中,还支持使用可选参数ids
,代表标识符,支持的值类型可以是可迭代对象,或者可调用对象。
💡 在ch3/tasks_proj/tests/func
目录的测试模块test_add_variety2.py
中,添加新的夹具b_task
和测试函数test_add_b_task
:
# ch3/tasks_proj/tests/func/test_add_variety2.py
# 代码片段
# ...
# 标识符列表
task_ids = ['Task({},{},{})'.format(t.summary, t.owner, t.done) for t in tasks_to_try]
# 参数化夹具:指定标识符
@pytest.fixture(params=tasks_to_try, ids=task_ids)
def b_task(request):
"""Using a list of ids."""
return request.param
# 测试函数
def test_add_b(tasks_db, b_task):
"""Using b_task fixture, with ids."""
task_id = tasks.add(b_task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, b_task)
$ pytest func/test_add_variety2.py::test_add_b -v
=========================== test session starts ============================
platform darwin -- Python 3.9.7, pytest-6.2.0, py-1.10.0, pluggy-0.13.1 -- /Users/xiaofo/Envs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /Users/xiaofo/coding/Personal/studypytest/ch3/tasks_proj/tests, configfile: pytest.ini
plugins: allure-pytest-2.9.45
collected 4 items
func/test_add_variety2.py::test_add_b[Task(sleep,None,True)] PASSED [ 25%]
func/test_add_variety2.py::test_add_b[Task(wake,brian,False)] PASSED [ 50%]
func/test_add_variety2.py::test_add_b[Task(breathe,BRIAN,True)] PASSED [ 75%]
func/test_add_variety2.py::test_add_b[Task(exercise,BrIaN,False)] PASSED [100%]
============================ 4 passed in 0.02s =============================
可调用的标识符
我们还可以将ids
参数的值,设置为专门的生成标识符列表的函数。
下面是我们使用函数生成标识符时的情况:
# ch3/tasks_proj/tests/func/test_add_variety2.py
# 代码片段
# ...
# 辅助函数:生成标识符列表
def gen_ids(fixture_value):
"""A function for generating ids."""
t = fixture_value
return 'Task({},{},{})'.format(t.summary, t.owner, t.done)
# 参数化夹具:指定标识符函数
@pytest.fixture(params=tasks_to_try, ids=gen_ids)
def c_task(request):
"""Using a function (id_func) to generate ids."""
return request.param
# 测试函数
def test_add_c(tasks_db, c_task):
"""Use fixture with generated ids."""
task_id = tasks.add(c_task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, c_task)
gen_ids(fixture_value)
的入参fixture_value
将从参数化数据集的每个数据项的值中获取。由于参数化的数据集是一个Task 对象列表,因此gen_ids()
将通过调用一个Task对象,该对象允许我们使用命名元组访问器方法访问单个 Task 对象,每次为一个 Task 对象生成标识符。这比提前生成一个完整的列表要明了一些。
执行命令,查看效果:
$ pytest func/test_add_variety2.py::test_add_c -v
=========================== test session starts ============================
platform darwin -- Python 3.9.7, pytest-6.2.0, py-1.10.0, pluggy-0.13.1 -- /Users/xiaofo/Envs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /Users/xiaofo/coding/Personal/studypytest/ch3/tasks_proj/tests, configfile: pytest.ini
plugins: allure-pytest-2.9.45
collected 4 items
func/test_add_variety2.py::test_add_c[Task(sleep,None,True)] PASSED [ 25%]
func/test_add_variety2.py::test_add_c[Task(wake,brian,False)] PASSED [ 50%]
func/test_add_variety2.py::test_add_c[Task(breathe,BRIAN,True)] PASSED [ 75%]
func/test_add_variety2.py::test_add_c[Task(exercise,BrIaN,False)] PASSED [100%]
============================ 4 passed in 0.02s =============================
使用参数化函数,你可以多次运行该函数;对于参数化夹具,每个使用该夹具的测试函数都会被多次调用,非常强大。
项目优化¶
现在,让我们看看如何在tasks_proj
项目中使用参数化夹具。
到目前为止,我们在所有的测试中一直在使用TinyDB
,但是最好开放这个选项,不要硬编码。因此,编写的任何功能代码以及任何测试代码,都应该同时适用于 TinyDB
和 MongoDB
。
conftest.py
中的tasks_db_session
夹具的代码:
# ch3/tasks_proj/tests/conftest.py
# 代码片段
# ...
@pytest.fixture(scope='session')
def tasks_db_session(tmpdir_factory):
"""Connect to db before tests, disconnect after."""
temp_dir = tmpdir_factory.mktemp('temp')
tasks.start_tasks_db(str(temp_dir), 'tiny')
yield
tasks.stop_tasks_db()
tasks_db_session
夹具中的所调用的start_tasks_db(db_path, db_type)
函数相互隔离,其中的db_type
参数只是负责切换数据库。因此考虑把db_type
字段进行参数化。
start_tasks_db(db_path, db_type)
的代码:
# ch3/tasks_proj/src/tasks/api.py
# 代码片段
# ...
def start_tasks_db(db_path, db_type): # type: (str, str) -> None
"""Connect API functions to a db."""
if not isinstance(db_path, string_types):
raise TypeError('db_path must be a string')
global _tasksdb
if db_type == 'tiny':
import tasks.tasksdb_tinydb
_tasksdb = tasks.tasksdb_tinydb.start_tasks_db(db_path)
elif db_type == 'mongo':
import tasks.tasksdb_pymongo
_tasksdb = tasks.tasksdb_pymongo.start_tasks_db(db_path)
else:
raise ValueError("db_type must be a 'tiny' or 'mongo'")
为了测试MongoDB
数据库,我们需要把 db_type
的值设置为MongoDB
,然后运行所有的测试。只需一个小小的改动就可以实现这个需求。
💡 在ch3/tasks_proj/tests/conftest.py
,修改tasks_db_session
夹具的代码,把db_type
字段进行参数化。
# ch3/tasks_proj/src/tasks/api.py
# 代码片段
# ...
@pytest.fixture(scope="session", params=["tiny", "mongo"])
def tasks_db_session(tmpdir_factory, request):
"""Connect to db before tests, disconnect after."""
temp_dir = tmpdir_factory.mktemp('temp')
tasks.start_tasks_db(str(temp_dir), request.param)
yield
tasks.stop_tasks_db()
在这里,我们将params=['tiny','mongo']
添加到了夹具装饰器参数列表中,然后把 request夹具添加到夹具函数tasks_db_session
的参数列表中,最后将原来的db_type
的值替换成了request.param
,而不再是硬编码值tiny
或mongo
了。
为了运行优化后的夹具有关的测试函数,首先要确保安装数据库 MongoDB
软件和python第三方库 pymongo
。
- MongoDB:并非本书必要内容,仅在本例和第七章示例中出现,仅供了解。
MongoDB 社区版:https://www.MongoDB. com/download-center
- pymongo:可以通过pip工具安装
pymongo
库。$ pip install pymongo
运行命令,看看效果:
$ pytest func/test_add.py -v --tb=no
输出结果:
=========================== test session starts ============================
platform darwin -- Python 3.9.7, pytest-6.2.0, py-1.10.0, pluggy-0.13.1 -- /Users/xiaofo/Envs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /Users/xiaofo/coding/Personal/studypytest/ch3/tasks_proj/tests, configfile: pytest.ini
plugins: allure-pytest-2.9.45
collected 4 items
func/test_add.py::test_add_returns_valid_id[tiny] PASSED [ 25%]
func/test_add.py::test_add_increases_count[tiny] PASSED [ 50%]
func/test_add.py::test_add_returns_valid_id[mongo] ERROR [ 75%]
func/test_add.py::test_add_increases_count[mongo] ERROR [100%]
========================= short test summary info ==========================
ERROR func/test_add.py::test_add_returns_valid_id[mongo] - NotADirectoryE...
ERROR func/test_add.py::test_add_increases_count[mongo] - NotADirectoryEr...
======================= 2 passed, 2 errors in 0.05s ========================
TinyDB
作为测试数据库。
关于如何调试这个错误的更多内容详见【第七章:配套工具】。
3.8 标记夹具¶
通常,我们会显示地将夹具名称放在测试函数的参数列表中,或者使用autouse
让它自动使用。
其实还有第三种使用夹具的方式,就是通过给测试函数或者测试类打上@pytest.mark.usefixtures
的标记来指定夹具,在标记中支持传入多个夹具名称。
语法:@pytest.mark.usefixtures('夹具1', '夹具2')
对于测试类来说,标记夹具确实很好用。比如之前在demo/test_scope.py
中已经写过的测试类TestSomething
就是一个很好的例子,我们给它指定了名为class_scope
的夹具。
# ch3/test_scope.py
# 代码片段
# ...
# 测试类
@pytest.mark.usefixtures('class_scope')
class TestSomething:
"""Demo class scope fixtures."""
def test_3(self):
"""Test using a class scope fixture."""
assert True
def test_4(self):
"""Again, multiple tests are more fun."""
assert True
pytest demo/test_scope.py --setup-show -v
=========================== test session starts ============================
platform darwin -- Python 3.9.7, pytest-6.2.0, py-1.10.0, pluggy-0.13.1 -- /Users/xiaofo/Envs/pytest/bin/python3
cachedir: .pytest_cache
rootdir: /Users/xiaofo/coding/Personal/studypytest/ch3/tasks_proj/tests, configfile: pytest.ini
plugins: allure-pytest-2.9.45
collected 2 items
demo/test_scope.py::TestSomething::test_3
SETUP C class_scope
demo/test_scope.py::TestSomething::test_3 (fixtures used: class_scope)PASSED
demo/test_scope.py::TestSomething::test_4
demo/test_scope.py::TestSomething::test_4 (fixtures used: class_scope)PASSED
TEARDOWN C class_scope
============================ 2 passed in 0.01s =============================
温馨提示:对于测试函数来说,在测试函数的参数列表中指定夹具名称,和打上usefixtures
标记之间,虽然同样都能使用夹具,注意存在很大差别:如果是在函数参数列表中指定夹具名称,那么在测试函数中能获取到夹具的返回值;但是通过usefixtures
标记来使用夹具,则只能调用夹具而获取不到夹具的返回值。
pytest 夹具非常灵活,可以使用夹具来构建测试前置setup和后置teardown处理,以及切换系统的不同配置(比如用 Mongo 替换 TinyDB)。
在本章中,你看到了自己编写的pytest 夹具,以及部分内置夹具,例如tmpdir
和 tmpdir_factory
。在下一章中,我们将进一步了解pytest内置的夹具。