《pytest测试实战》-- Brian Okken
一、pytest 入门
这是一个测试用例
ch1/test_one.py def test_passing(): assert (1, 2, 3) == (1, 2, 3)
执行
cd /ch1
pytest test_one.py
结果
(venv) C:\Users\admin\Desktop\ch1>pytest test_one.py ======================================= test session starts ==================== platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch1 collected 1 item test_one.py . [100%] ========================================= 1 passed in 0.01s ====================
这是第二个测试用例
ch1/test_two.py def test_passing(): assert (1, 2, 3) == (3, 2, 1)
运行后结果
(venv) C:\Users\admin\Desktop\ch1>pytest test_two.py ================================================ test session starts ================================ platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch1 collected 1 item test_two.py F [100%] =========================== FAILURES ================================= ________________________________________ test_passing ____________________ def test_passing(): > assert (1, 2, 3) == (3, 2, 1) E assert (1, 2, 3) == (3, 2, 1) E At index 0 diff: 1 != 3 E Use -v to get the full diff test_two.py:2: AssertionError =================================== short test summary info =================================== FAILED test_two.py::test_passing - assert (1, 2, 3) == (3, 2, 1) ==================================== 1 failed in 0.03s ========================================
1.1 资源获取
pytest的官方文档地址
https://docs.pytest.org
pytest通过PyPI(Python官方包管理索引)分发托管:
https://pypi.python.org/pypi/pytest
建议使用vritualenv来使用
1.2 运行pytest
pytest --help
usage: pytest [options] [file_or_dir] [file_or_dir] [...]
在没有其他参数的情况下,pytest会递归遍历每个目录及其子目录。
举一个例子,我们创建一个tasks子目录,并且创建以下测试文件:
ch1/tasks/test_three.py """Test the Task data type.""" from collections import namedtuple Task = namedtuple('Task', ['summary', 'owner', 'done', 'id']) Task.__new__.__defaults__ = (None, None, False, None) # 指定默认值 def test_defaults(): """Using no parameters should invoke defaults""" t1 = Task() t2 = Task(None, None, False, None) assert t1 == t2 def test_member_access(): """Check .field functionality of namedtuple.""" t = Task('buy milk', 'brian') assert t.summary == 'buy milk' assert t.owner == 'brian' assert (t.done, t.id) == (False, None)
下面演示下_asdict() 函数和 _replace() 函数的功能:
# ch1/tasks/test_four.py """Type the Task data type""" from collections import namedtuple Task = namedtuple('Task', ['summary', 'owner', 'done', 'id']) Task.__new__.__defaults__ = (None, None, False, None) def test_asdict(): """_asdict() should return a dictionary""" t_task = Task('do something', 'okken', True, 21) t_dict = t_task._asdict() expected = {'summary': 'do something', 'owner': 'okken', 'done': True, 'id': 21} assert t_dict == expected def test_replace(): """replace() should change passed in fields""" t_before = Task('finish book', 'brian', False) t_after = t_before._replace(id=10, done=True) t_expected = Task('finish book', 'brian', True, 10) assert t_after == t_expected
运行时
cd ch1
pytest
如果不指定,pytest会搜索当前目录及子目录中以test_开头或者以_test结尾的测试函数
我们把 pytest 搜索测试文件和测试用例的过程称为测试搜索(test discovery)。只要遵守命名规则,就能自动搜索。以下是几条主要的命名规则
- 测试文件应当命名为 test_<something>.py 或者 <something_test.py>
- 测试函数、测试类方法应当命名为teet_<something>
- 测试类应当命名为 Test<Something>
运行单个文件时的控制台信息
================================================= test session starts ======================================================== platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch1,inifile: collected 1 item test_one.py . [100%] ================================================ 1 passed in 0.01s ============================================================
====== test session starts ======
pytest为每段测试会话(session)做了明确的分隔,一段会话就是pytest的一次调用
platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
运行平台和版本
rootdir: C:\Users\admin\Desktop\ch1,inifile:
rootdir(当前起始目录)是pytest搜索测试代码时最常使用的目录,inifile用于列举配置文件(这里没有定义),文件名可能是pytest.ini、tox.ini或者setup.cfg
collected 1 item
搜索范围内找到两个测试条目
test_one.py . [100%]
表示测试文件及结果。点号表示通过。Failure(失败)、error(异常)、skip(跳过)、xfail(预期失败)、xpass(预期失败但通过)会被分别标记为F、E、s、x、X,使用 -v 或者 --verbose 可以看到更多细节
=== 1 passed in 0.01s ====
表示测试通过或者失败等条目的数量以及这段会话耗费的时间,如果存在未通过的测试用例,则会根据未通过的类型列举数量。
以下是可能出现的类型:
PASSED(.):测试通过
FAILED(F):测试失败(也有可能是XPASS状态与strict选项冲突造成的失败,见后文)
SKIPPED(s):测试未被执行。指定测试跳过执行,可以将测试标记为@pytest.mark.skip(),或者使用@pytest.mark.skipif()指定跳过测试的条件
xfail(x):预期测试失败,并且确实失败。使用@pytest.mark.xfail()指定你认为会失败的测试用例。
XPASS(X):预期测试失败,但实际上运行通过,不符合预期。
ERROR(E):测试用例之外的代码触发了异常,可能由 fixture 引起,也可能由 hook 函数引起
1.3 运行单个测试用例
可以直接在指定文件后添加 ::test_name
pytest -v tasks/test_four.py::test_asdict
1.4 使用命令行选项
--collect-only选项
使用 --collect-only 选项可以展示在给定的配置下哪些测试用例会被运行。
(venv) C:\Users\admin\Desktop\ch1>pytest --collect-only ================= test session starts ================= platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch1 collected 6 items <Module test_one.py> <Function test_passing> <Module test_two.py> <Function test_passing> <Module tasks/test_four.py> <Function test_asdict> <Function test_replace> <Module tasks/test_three.py> <Function test_defaults> <Function test_member_access> ================= no tests ran in 0.02s =================
--collect-only选项可以让你非常方便地在测试运行之前,检查选中的测试用例是否符合预期。
-k 选项
-k 选项允许你使用表达式指定希望运行的测试用例。
假设希望选中 test_asdict() 和 test_defaults(),name可以使用 --collect-only 验证:
(venv) C:\Users\admin\Desktop\ch1>pytest -k "asdict or defaults" --collect-only ============================ test session starts ============================ platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch1 collected 6 items / 4 deselected / 2 selected <Module tasks/test_four.py> <Function test_asdict> <Module tasks/test_three.py> <Function test_defaults> ============================ 4 deselected in 0.02s ============================
-m选项
标记(marker)用于标记测试并分组。
使用什么标记名由你自己决定,比如 @pytest.mark.mark1 或者 @pytest.mark.mark2
@pytest.mark.mark1 def test_member_access(): """Check .field functionality of namedtuple.""" t = Task('buy milk', 'brian') assert t.summary == 'buy milk' assert t.owner == 'brian' assert (t.done, t.id) == (False, None)
此时运行
(venv) C:\Users\admin\Desktop\ch1>pytest -m mark1 =============================================================================================================================== test session starts ================================================================================================================================ platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch1 collected 6 items / 5 deselected / 1 selected tasks\test_three.py . [100%] ================== warnings summary ================== tasks\test_three.py:16 C:\Users\admin\Desktop\ch1\tasks\test_three.py:16: PytestUnknownMarkWarning: Unknown pytest.mark.mark1 - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/mark.html @pytest.mark.mark1 -- Docs: https://docs.pytest.org/en/stable/warnings.html ================== 1 passed, 5 deselected, 1 warning in 0.03s ==================
使用 -m 选项可以用表达式指定多个标记名。
使用 -m "mark1 and mark2" 可以同时选中带有这两个标记的所有测试用例。
使用 -m "mark1 and not mark2" 则会选中带有mark1的测试用例,同时过滤掉带有mark2 的测试用例。
使用 -m "mark1 or mark2" 同时选中带有 mark1 或者 mark2 的所有测试用例。
-x 选项(小写)
正常情况下,如果有运行失败的用例,pytest 会标记为失败,但是会继续运行下一个测试用例。
如果我们希望遇到失败时立即停止整个会话,这时 -x 选项就派上用场了。
(venv) C:\Users\admin\Desktop\ch1>pytest -x ========================== test session starts ========================== platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch1 collected 6 items test_one.py . [ 16%] test_two.py F ========================== FAILURES ========================== ____________________________test_passing ____________________________ def test_passing(): > assert (1, 2, 3) == (3, 2, 1) E assert (1, 2, 3) == (3, 2, 1) E At index 0 diff: 1 != 3 E Use -v to get the full diff test_two.py:2: AssertionError========================== short test summary info ========================== FAILED test_two.py::test_passing - assert (1, 2, 3) == (3, 2, 1) !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ========================== 1 failed, 1 passed, 1 warning in 0.05s ==========================
如果没有 -x 选项,那么6个测试都会被执行,去掉 -x 再运行一次,并且使用 --tb=no 选项关闭错误信息回溯。
(venv) C:\Users\admin\Desktop\ch1>pytest --tb=no ====================== test session starts ====================== platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch1 collected 6 items test_one.py . [ 16%] test_two.py F [ 33%] tasks\test_four.py .. [ 66%] tasks\test_three.py .. [100%] ====================== short test summary info ====================== FAILED test_two.py::test_passing - assert (1, 2, 3) == (3, 2, 1) ====================== 1 failed, 5 passed in 0.03s ======================
--maxfail=num
-x 选项的特点是,一旦遇到测试失败,就会全局停止。
使用 --maxfail 选项,明确指定可以失败几次。
pytest --maxfail=2 --tb=no
-s 与 --capture=method
-s选项允许终端在测试运行时输出某些结果(比如print),包括任何符合标准的的输出流信息。
-s 等价于 --capture=no
--lf(--last-failed)选项
当一个或多个测试失败时,我们常常希望能够定位到最后一个失败的测试用例重新运行,这时候可以使用 --lf 选项
至于上一个失败的测试用例,pytest框架会自动记录
--ff(--failed-first)选项
--ff(--failed-first)选项与 --last-failed选项的作用基本相同,不同之处在于 --ff 会运行完剩余的测试用例。
-v(--verbose)选项
最明显的区别就是每个文件中的每个测试用例都占一行(先前是每个文件占一行)
(venv) C:\Users\admin\Desktop\ch1>pytest -v ============================ test session starts ============================ platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 -- c:\users\admin\desktop\ch1\venv\scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\admin\Desktop\ch1 collected 6 items test_one.py::test_passing PASSED [ 16%] test_two.py::test_passing FAILED [ 33%] tasks/test_four.py::test_asdict PASSED [ 50%] tasks/test_four.py::test_replace PASSED [ 66%] tasks/test_three.py::test_defaults PASSED [ 83%] tasks/test_three.py::test_member_access PASSED [100%]
-q(--quiet)选项
该选项的作用与 -v/--verbose的相反,它会简化输出信息,只保留最核心的内容。
-l(--showlocals)选项
使用 -l 选项,失败测试用例由于被堆栈追踪,所以局部变量及其值都会显示出来。
(venv) C:\Users\admin\Desktop\ch1>pytest -l tasks/test_four.py ========================= test session starts ========================= platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch1 collected 2 items tasks\test_four.py .F [100%] ============================ FAILURES ============================ ___________________________________ test_replace ___________________________________ def test_replace(): """replace() should change passed in fields""" t_before = Task('finish book', 'brian', False) t_after = t_before._replace(id=10, done=True) t_expected = Task('finish book', 'brian', True, 11) > assert t_after == t_expected E assert Task(summary=...e=True, id=10) == Task(summary=...e=True, id=11) E At index 3 diff: 10 != 11 E Use -v to get the full diff t_after = Task(summary='finish book', owner='brian', done=True, id=10) t_before = Task(summary='finish book', owner='brian', done=False, id=None) t_expected = Task(summary='finish book', owner='brian', done=True, id=11) tasks\test_four.py:25: AssertionError ========================== short test summary info ========================== FAILED tasks/test_four.py::test_replace - assert Task(summary=...e=True, id=10) == Task(summary=...e=True, id=11) ========================== 1 failed, 1 passed in 0.05s ==========================
assert 触发测试失败之后,代码片段下方显示的是本地变量 t_after、t_before、t_expected详细的值。标红处显示。
--tb=style选项
--tb=style选项决定捕捉到失败时输出信息的显示方式。某个测试用例失败后,pytest会列举出失败信息,包括失败出现在哪一行、是什么失败、怎么失败的,此过程我们称之为“信息回溯”。
常用的三种模式:
short 模式仅输出 assert的一行以及系统判定内容(不显示上下文);
line 模式只使用一行输出显示所有的错误信息
no 模式则直接屏蔽全部回溯信息
还有三种可选模式:
--tb=long 输出最为详尽的回溯信息
--tb=auto 是默认值,如果有多个测试用例失败,仅打印第一个和最后一个用例的回溯信息(格式与long模式的一致)
--tb=native 只输出Python标准库的回溯信息,不显示额外信息
--durations=N选项
--duration=N 选项可以加快测试节奏。它不关心测试时如何运行运行的,只统计测试过程中哪几个阶段是最慢的(包括每个测试用例的call、setup、teardown过程)。
使用--duration=0,则会将所有阶段按耗时长短排序后显示。
(venv) C:\Users\admin\Desktop\ch1>pytest --durations=0 tasks -vv ========================= test session starts ========================= platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 -- c:\users\admin\desktop\ch1\venv\scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\admin\Desktop\ch1 collected 4 items tasks/test_four.py::test_asdict PASSED [ 25%] tasks/test_four.py::test_replace PASSED [ 50%] tasks/test_three.py::test_defaults PASSED [ 75%] tasks/test_three.py::test_member_access PASSED [100%] ========================= slowest durations ========================= 0.00s setup tasks/test_four.py::test_asdict 0.00s teardown tasks/test_three.py::test_member_access 0.00s setup tasks/test_four.py::test_replace 0.00s call tasks/test_four.py::test_asdict 0.00s setup tasks/test_three.py::test_member_access 0.00s setup tasks/test_three.py::test_defaults 0.00s teardown tasks/test_four.py::test_asdict 0.00s teardown tasks/test_four.py::test_replace 0.00s call tasks/test_four.py::test_replace 0.00s teardown tasks/test_three.py::test_defaults 0.00s call tasks/test_three.py::test_member_access 0.00s call tasks/test_three.py::test_defaults ========================= 4 passed in 0.02s =========================
--version 选项
使用 --version 可以显示当前的 pytest 版本及安装目录
-h(--help)选项
使用 -h 选项可以获得:
基本用法:pytest [options] [file_or_dir] [file_or_dir] [...]
命令行选项及其用法,包括新添加的插件的选项及其用法
可用于ini配置文件中的选项
影响pytest行为的环境变量
使用 pytest --markers 时的可用 marker 列表
使用 pytest --fixtures 时的可用 fixture 列表
二、编写测试函数
2.1 目录结构
Tasks项目的文件结构:
tasks_proj/ |——CHANGELOG.rst |——LICENSE |——MANIFEST.in |——README.rst |——setup.py |——src (放源码) | |——tasks | |——__init__.py | |——api.py | |——cli.py | |——config.py | |——tasksdb_pymongo.py | |——taskdb_tinydb.py |——tests (放测试) |——conftest.py |——pytest.ini |——func |——__init__.py |——test_add.py |——。。。 |——unit |——_init__.py |——test_task.py |。。。
2.2 使用 assert 声明
pytest有一个重要功能是可以重写 assert 关键字。pytest 会截断对原生 assert 的调用,替换为 pytest 定义的assert,从而提供更多的失败信息和细节。
每个失败的测试用例在行首都用一个 > 号来标识。以 E 开头的行时 pytest 提供的额外判断信息,用于帮组我们了解异常的具体情况。
2.3 预期异常
测试异常的格式 with pytest.raises(<expected exception>)
import pytest import tasks def test_add_raises(): """add() should raise an exception with wrong type param""" with pytest.raises(TypeError): tasks.add(task="not a Task object")
测试用例 test_add_raises() 中有 with pytest.raises(TypeError)声明,意味着无论with中的内容是什么,都至少会发生TypeError异常。如果测试通过,说明确实发生了我们预期 TypeError 异常:如果抛出的是其他类型的异常,则与我们所预期的不一致,说明测试失败。
上面的测试中只检验了传参数据的 “类型异常”,换可以检验 “值异常”。为校验异常信息是否符合预期,可以通过增加 as excinfo 语句得到异常消息的值,再进行比对。
import pytest import tasks def test_add_raises(): """add() should raise an exception with wrong type param""" with pytest.raises(AttributeError) as excinfo: tasks.add(task="not a Task object") exception_msg = excinfo.value.args[0] # 获得报错信息 assert exception_msg == "module 'tasks' has no attribute 'add'"
2.4 测试函数的标记
pytest 允许使用 marker 对测试函数做标记。
一个测试函数可以有多个 marker,一个 marker 也可以用来标记多个测试函数。
带有相同 marker 的测试即使存放在不同的文件下,也会被一起执行。
import pytest import tasks @pytest.mark.smoke def test_add_raises_true(): """add() should raise an exception with wrong type param""" with pytest.raises(AttributeError) as excinfo: tasks.add(task="not a Task object") exception_msg = excinfo.value.args[0] assert exception_msg == "module 'tasks' has no attribute 'add'" @pytest.mark.smoke @pytest.mark.get def test_add_raises_false(): """add() should raise an exception with wrong type param""" with pytest.raises(AttributeError) as excinfo: tasks.add(task="not a Task object") exception_msg = excinfo.value.args[0] assert exception_msg == "module 'tasks' has no attribute 'addtwo'"
可以通过以下命令运行
pytest -m smoke
pytest -m get
-m 后面也可以加表达式,可以在标记之间添加 add、or、not 关键字
pytest -m "smoke and get" pytest -m "smoke or get" pytest -m "smoke and not get"
警告信息消除,mark标记时会warn,可以在conftest里面添加
def pytest_configure(config): marker_list = ["search","login"] for markers in marker_list: config.addinivalue_line("markers",markers)
2.5 跳过测试
skip 和 skipif 允许你跳过不希望运行的测试。
@pytest.mark.skip(reason="跳过的原因") @pytest.mark.skipif(表达式,reason="跳过的原因")
skipif() 的判断条件可以使任何Python 表达式,这里比对的是包版本。
如果运行的时候要看到跳过的原因,可以使用 -rs
-r 选项
-r
选项可以在执行结束后,打印一个简短的总结报告。在执行的测试用例很多时,可以让你对结果有个清晰的了解-r
选项后面要紧接以下的一个参数,用于过滤显示测试用例的结果。
以下是所有有效的字符参数:
- f:失败的
- E:出错的
- s:跳过执行的
- x:跳过执行,并标记为xfailed的
- X:跳过执行,并标记为xpassed的
- p:测试通过的
- P:测试通过,并且有输出信息的;即用例中有
print
等 - a:除了测试通过的,其他所有的;即除了
p
和P
的 - A:所有的
上述字符参数可以叠加使用,例如:我们期望过滤出失败的和未执行的:
pytest -rfs
2.6 标记预期会失败的测试
使用 skip 和 skipif 标记,测试会直接跳过,而不会被执行。使用 xfail 标记,则告诉pytest运行此测试,但我们预期它会失败。
@pytest.mark.xfail(表达式,reason="跳过的原因")
2.7 运行测试子集
单个目录
运行单个目录下的所有测试,以目录作为 pytest 的参数即可。
pytest tests/func --tb=no
单个测试文件/模块
运行单个文件里的全部测试,以路径名加文件名作为 pytest 参数即可。
pytest tests/func/test_add.py
单个测试函数
运行单个测试函数,只需要在文件名后面添加 :: 符号和函数号
pytest tests/func/test_add.py::test_add_returns_valid_id
单个测试类
测试类用于将某些相似的测试函数组合在一起。
class TestUpdate(): """Test expected exceptions with tasks.update().""" def test_bad_id(self): """A non-int id should raise an exception""" with pytest.raises(TypeError): tasks.upadte(task_id={"dict instead": 1}, task=tasks.Task()) def test_bad_task(self): """A non-Task task should raise an excption""" with pytest.raises(TypeError): tasks.update(task_id=1, task="not a task")
要运行该类,可以在文件名后面加上 :: 符号和类名(与运行单个测试函数类似)
pytest tests/func/test_api_exceptions.py::TestUpdate
单个测试类中的测试方法
如果不希望运行测试类中的所有测试,只想指定运行其中一个,一样可以在文件名后面添加 :: 符号和方法名。
pytest tests/func/test_api.py:TestUpdate:test_bad_id
用测试名划分测试集合
-k 选项允许用一个表达式指定需要运行的测试,该表达式可以匹配测试名(或其子串)。
表达式中可以包含and 、or 、not
运行所有名字中包含 _raises 的测试
pytest -k _raises
如果要跳过 test_delete_raises() 的执行,则可以使用 and 和 not
pytest -k "_raises and not delete"
2.8 参数化测试
有时候仅仅使用一组数据是无法充分测试函数功能的,参数化测试允许传递多组数据。
import pytest @pytest.mark.parametrize("task", [Task("sleep", done=True), Task("wake", "brian"), Task("breathe", "BRIAN", True), Task("exercise", "BrIaN", "False")]) def test_add_2(task): """Demonstrate paramertrize with one parameter""" task_id = task.add(task) t_from_db = tasks.get(task_id) assert equivalent(t_from_db, task)
@pytest.mark.parametrize() 的第一个参数是用逗号分隔的字符串列表;第二个参数是一个值列表。
pytest会轮流对每个task做测试,并分别报告每一个测试用例的结果。
以下是 多组键值对情况
import pytest @pytest.mark.parametrize("str", ["abc","def","twq","tre"]) def test_add_2(str): """Demonstrate paramertrize with one parameter""" str2 = "abc" assert str == str2
执行如下:
(venv) C:\Users\admin\Desktop\ch2>pytest test_add_variety.py -v =============================== test session starts =============================== platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 -- c:\users\admin\desktop\ch2\venv\scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\admin\Desktop\ch2 collected 4 items test_add_variety.py::test_add_2[abc] PASSED [ 25%] test_add_variety.py::test_add_2[def] FAILED [ 50%] test_add_variety.py::test_add_2[twq] FAILED [ 75%] test_add_variety.py::test_add_2[tre] FAILED [100%]
如有以下的参数化测试用例
import pytest from collections import namedtuple Task = namedtuple('Task', ['summary', 'owner', 'done', 'id']) Task.__new__.__defaults__ = (1, 1, 1, 1) str_to_try = [Task(2, 2, 2, 2), Task(3, 3, 3, 3), Task(4, 4, 4, 4), Task(5, 5, 5, 5)] @pytest.mark.parametrize("task", str_to_try) def test_add_2(task): """Demonstrate paramertrize with one parameter""" t1 = Task() t2 = Task(None, None, False, None) assert t1 == t2
可见可读性非常差
(venv) C:\Users\admin\Desktop\ch2>pytest test_add_variety.py -v ======================================= test session starts ======================================= platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 -- c:\users\admin\desktop\ch2\venv\scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\admin\Desktop\ch2 collected 4 items test_add_variety.py::test_add_2[task0] FAILED [ 25%] test_add_variety.py::test_add_2[task1] FAILED [ 50%] test_add_variety.py::test_add_2[task2] FAILED [ 75%] test_add_variety.py::test_add_2[task3] FAILED [100%] ==================== FAILURES ====================
为了改善可读性,我们为parametrize()引入一个额外参数ids,使列表中的每一个元素都被表示。ids 是一个字符串列表,它和数据对象列表的长度保持一致。由于给数据集分配了一个变量 tasks_to_try,所以可以通过他生成ids。
import pytest from collections import namedtuple Task = namedtuple('Task', ['summary', 'owner', 'done', 'id']) Task.__new__.__defaults__ = (1, 1, 1, 1) str_to_try = [Task(2, 2, 2, 2), Task(3, 3, 3, 3), Task(4, 4, 4, 4), Task(5, 5, 5, 5)] str_ids = ["Task({},{},{})".format(i.summary, i.owner, i.done, i.id) for i in str_to_try] @pytest.mark.parametrize("task",str_to_try,ids=str_ids) def test_add_2(task): """Demonstrate paramertrize with one parameter""" t1 = Task() t2 = Task(None, None, False, None) assert t1 == t2
自定义测试标识能够被 pytest 识别
(venv) C:\Users\admin\Desktop\ch2>pytest test_add_variety.py -v ============================ test session starts ============================ platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 -- c:\users\admin\desktop\ch2\venv\scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\admin\Desktop\ch2 collected 4 items test_add_variety.py::test_add_2[Task(2,2,2)] FAILED [ 25%] test_add_variety.py::test_add_2[Task(3,3,3)] FAILED [ 50%] test_add_variety.py::test_add_2[Task(4,4,4)] FAILED [ 75%] test_add_variety.py::test_add_2[Task(5,5,5)] FAILED [100%] ============================ FAILURES ============================
@pytest.mark.parametrize() 装饰器也可以给测试类加上,在这种情况下,该数据集会被传递给该类的所有类方法。
import pytest from collections import namedtuple Task = namedtuple('Task', ['summary', 'owner', 'done', 'id']) Task.__new__.__defaults__ = (1, 1, 1, 1) str_to_try = [Task(2, 2, 2, 2), Task(3, 3, 3, 3), Task(4, 4, 4, 4), Task(5, 5, 5, 5)] str_ids = ["Task({},{},{})".format(i.summary, i.owner, i.done, i.id) for i in str_to_try] @pytest.mark.parametrize("task",str_to_try,ids=str_ids) class TestAdd(): def test_add_2(self,task): """Demonstrate paramertrize with one parameter""" t1 = Task() t2 = Task(None, None, False, None) assert t1 == t2 def test_add_3(self,task): """Demonstrate paramertrize with one parameter""" t3 = Task() t4 = Task(None, None, False, None) assert t3 == t4
在给@pytest.mark.parametrize() 装饰器传入列表参数时,还可以在参数值旁边定义一个 id 来做标识,语法是 pytest.param(<value>,id="something")
import pytest from collections import namedtuple @pytest.mark.parametrize("task", [ pytest.param(Task("create"), id="just summary"), pytest.param(Task("inspire", "Michelle"), id="summary/owner"), pytest.param(Task("encourage", "Michelle", Ture), id="summary/oener/done") ]) def test_add_6(task): task_id = tasks.add(task) t_from_db = tasks.get(task_id) assert equivalent(t_from_db, task)
标识也能够被识别
(venv) C:\Users\admin\Desktop\ch2>pytest test_add_variety.py -v ============================ test session starts ============================ platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 -- c:\users\admin\desktop\ch2\venv\scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\admin\Desktop\ch2 collected 4 items test_add_variety.py::test_add_6[just summary] PASSED [ 33%] test_add_variety.py::test_add_6[summary/owner] PASSED [ 66%] test_add_variety.py::test_add_6[summary/owner/done] PASSED [ 100%]============================ FAILURES ============================
参数组合
@pytest.mark.parametrize("x",[1,2]) @pytest.mark.parametrize("y",[8,10,11]) def test_foo(x,y): print(f"测试数据组合x:{x},y:{y}")
方法作为参数名
test_user_data = ['Tome','Jerry'] @pytest.fixture(scope="module") def login_r(request): # 这是接受并传入的参数、 user = request.parame print(f"\n 打开首页准备登录,登录用户:{user}") return user # indirect=True,可以把传过来的参数当函数来执行 @pytest.mark.parametrize("login_r",test_user_data,indirect=True) def test_login(login_r): a = login_r print(f"测试用例中login的返回值:{a}") assert a != ""
三、pytest fixture
fixture 是在测试函数运行前后,由pytest执行的外壳函数。
简单实例
import pytest @pytest.fixture() def some_data(): return 42 def test_some_data(some_data): assert some_data == 42
测试用例 test_some_data() 的参数列表中包含一个 fixture名 some_data,pytest 会以该名称搜索 fixture(可见命名在pytest 中是非常重要的。)
pytest 会优先搜索该测试所在的模块,然后搜索 conftest.py
3.1 通过 conftest.py 共享 fixture
fixture 可以放在单独的测试文件里。此时只有这个测试文件能够使用相关的fixture。
如果希望多个测试文件共享 fixture,可以在某个公共目录下新建一个 conftest.py 文件,将 fixture 放在其中。(作用域根据所放的文件夹决定,最上层文件夹的话整个项目共用,子文件夹的话,子文件夹里面的测试共用。)
尽管 conftest.py 是Python 模块,但它不能被测试文件导入。import conftest 的用法是不允许出现的。conftest.py 被 pytest 视作一个本地插件库。可以把 tests/conftest.py 看成一是一个供 tests 目录下所有测试使用的 fixture仓库。
3.2 使用 fixture 执行配置及销毁逻辑
fixture 函数会在测试函数之前运行,但如果 fixture 函数包含 yield,那么系统会在 yield 处停止,转而运行测试函数,等测试函数执行完毕后再回到 fixture,继续执行 yield 之后的代码。
可以将 yield 之前的代码视为 配置(setup)过程,将yield 之后的代码视为清理(teardown)过程。
无论测试过程中发生了说明,yield之后的代码都会被执行。
3.3 使用 --setup-show 回溯 fixture 的执行过程
直接运行测试,则不会看到fisture的执行过程。
如果希望看到测试过程中执行的是什么,以及执行的先后顺序。pytest 提供的 --setup-show 选项可以实现这个功能。
pytest --setup-show test_add.py
fixture 名称前面的F 和S代表的是fixture的作用范围,F代表函数级别的作用范围。S代表会话级别的作用范围。
3.4 使用 fixture 传递测试数据
fixture 非常适合存放测试数据,并且它可以返回任何数据。
import pytest @pytest.fixture() def a_tuple(): return (1, "foo", None, {"bar": 23}) def test_a_tuple(a_tuple): assert a_tuple[3]["bar"] == 32
yeild 返回数据
import pytest @pytest.fixture() def a_tuple(): print("1111") yield (1, "foo", None, {"bar": 23}) print("2222") def test_a_tuple(a_tuple): assert a_tuple[3]["bar"] == 32
明显23不等于32,所以会失败。
(venv) C:\Users\admin\Desktop\ch2>pytest test_fixture.py ============================ test session starts ============================ platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch2 collected 1 item test_fixture.py F [100%] ============================ FAILURES ============================ ____________________________ test_a_tuple ____________________________ a_tuple = (1, 'foo', None, {'bar': 23}) def test_a_tuple(a_tuple): > assert a_tuple[3]["bar"] == 32 E assert 23 == 32 test_fixture.py:10: AssertionError ============================ short test summary info ============================ FAILED test_fixture.py::test_a_tuple - assert 23 == 32 ============================ 1 failed in 0.03s ============================
pytest 给出了具体引起 assert 异常的函数参数值。fixture 作为测试函数的参数,也会被堆栈跟踪并纳入测试报告。
假设assert 异常(或任何类型的异常)就发生在fixture,会发生什么?
import pytest @pytest.fixture() def a_tuple(): x = 43 assert x == 43 def test_a_tuple(a_tuple): assert a_tuple[3]["bar"] == 32
在fixture 中,42 不等于 43,断言错误。pytest运行时,如下:
(venv) C:\Users\admin\Desktop\ch2>pytest test_fixture.py ======================== test session starts ======================== platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch2 collected 1 item test_fixture.py E [100%] ======================== ERRORS ======================== __________________________________ ERROR at setup of test_a_tuple __________________________________ @pytest.fixture() def a_tuple(): x = 43 > assert 42 == 43 E assert 42 == 43 test_fixture.py:7: AssertionError ======================== short test summary info ======================== ERROR test_fixture.py::test_a_tuple - assert 42 == 43 ======================== 1 error in 0.04s ========================
可以看到 执行结果是 ERROR 而不是 FAIL。
这个区分很清楚,如果测试结果为 fail,用户就知道失败是发生在核心测试函数内,而不是发生在测试依赖的 fixture。
3.5 使用多个 fixture
fixture互相调用
import pytest @pytest.fixture() def a_tuple(): return (1,2,3) @pytest.fixture() def two_tuple(a_tuple): if a_tuple[2] == 3: return (1, "foo", None, {"bar": 23}) return False def test_a_tuple(two_tuple): if two_tuple: assert two_tuple[3]["bar"] == 23
用例中传入多个fixture
import pytest @pytest.fixture() def a_tuple(): return 1 @pytest.fixture() def two_tuple(): return 2 def test_a_tuple(a_tuple, two_tuple): assert a_tuple == 1 assert two_tuple == 2
3.6 指定 fixture 作用范围
fixture 包含一个叫 scope(作用范围)的可选参数,用于控制 fixture 执行配置和销毁逻辑的频率。@pytest.fixture() 的 scope 参数有四个待选值:
- function
- class
- module
- session(默认值)
以下是对各个 scope 的概述
scope=“function”
函数级别的 fixture 每个测试函数只需要运行一次。配置代码在测试用例运行之前运行,销毁代码在测试用例运行之后运行。是默认值
scope=“class”
类级别的fixture 每个测试类只需要运行一次,无论测试类里面有多少类方法都可以共享这个fixture
scope="module"
模块级别的fixture每个模块只需要运行一次,无论模块里有多少个测试函数、类方法或其他fixture 都可以共享这个fixture
scope=“session”
会话级别的 fixture 每次会话只需要运行一次。一次 pytest 会话中所有测试函数、方法都可以共享这个 fixture。
import pytest @pytest.fixture(scope="function") def func_scope(): """A function scope fixture.""" @pytest.fixture(scope="module") def mod_scope(): """A module scope fixture.""" @pytest.fixture(scope="session") def sess_scope(): """A session scope fixture.""" @pytest.fixture(scope="class") def class_scope(): """A class scope fixture.""" def test_1(sess_scope,mod_scope,func_scope): """Demo is more fun with multiple tests""" @pytest.mark.usefixtures("class_scope") class TestSomething(): """Demo class scope fixtures.""" def test_3(self): """Test using a class scope fixture.""" def test_4(self): """Again,multiple tests are more fun."""
执行结果
(venv) C:\Users\admin\Desktop\ch2>pytest --setup-show test_scope.py ================================== test session starts ================================== platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch2 collected 3 items test_scope.py 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). TEARDOWN F func_scope SETUP C class_scope test_scope.py::TestSomething::test_3 (fixtures used: class_scope). test_scope.py::TestSomething::test_4 (fixtures used: class_scope). TEARDOWN C class_scope TEARDOWN M mod_scope TEARDOWN S sess_scope ================================== 3 passed in 0.01s ==================================
使用 --setup-show 命令行选项观察每个 fixture 被调用的次数,以及在各自作用范围下执行配置、销毁逻辑的顺序。
F 代表函数级别,S 代表会话级别,C 代表类级别,M 代表模块级别
fixture 只能使用同级别的fixture,或比自己级别更高的fixture。
3.7 使用 usefixtures 指定fixture
@pytest.mark.usefixtures("class_scope") class TestSomething(): """Demo class scope fixtures.""" def test_3(self): """Test using a class scope fixture.""" def test_4(self): """Again,multiple tests are more fun."""
使用 usefixtures 和在测试方法中添加 fixture 参数,二者大体上是差不多的。区别之一在于只有后者才能够使用fixture的返回值。
3.8 为常用 fixture 添加 autouse 选项
之前用到的 fixture 都是根据测试本身来命名的(或者针对示例的测试类使用 usefixtures)。我们可以通过制定 autouse=True选项,使作用域内的测试函数都自动运行 fixture
下面是一个比较生硬的例子
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 foot_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(1) def test_2(): time.sleep(1.4)
3.9 为 fixture 重命名
@pytest.fixture(name="another")
3.10 fixture 的参数化
import pytest from collections import namedtuple Task = namedtuple('Task', ['summary', 'owner', 'done', 'id']) Task.__new__.__defaults__ = (1, 1, 1, 1) str_to_try = [Task(2, 2, 2, 2), Task(3, 3, 3, 3), Task(4, 4, 4, 4), Task(5, 5, 5, 5)] @pytest.fixture(params=str_to_try) def a_task(request): """Demonstrate paramertrize with one parameter""" return request.param def test_add_a(a_task): assert a_task == Task()
fixture 参数列表中的request 也是 pytest 内建的fixture 之一。代表 fixture 的调用状态。
它有一个 param 字段,会被@pytest.fixture(params = tasks_to_try) 的params 列表中的一个元素填充。
也可以指定 ids。(只不过这里的 ids 也是函数,不是列表)
import pytest from collections import namedtuple Task = namedtuple('Task', ['summary', 'owner', 'done', 'id']) Task.__new__.__defaults__ = (1, 1, 1, 1) str_to_try = [Task(2, 2, 2, 2), Task(3, 3, 3, 3), Task(4, 4, 4, 4), Task(5, 5, 5, 5)] def id_func(fixture_value): t = fixture_value return "Task({}{}{}{}".format(t.summary,t.owner,t.done,t.id) @pytest.fixture(params=str_to_try,ids=id_func) def c_task(request): """Demonstrate paramertrize with one parameter""" return request.param def test_add_c(c_task): assert c_task == Task()
四、内置 fixture
4.1 使用 tmpdir 和 tmpdir_factory
内置的 tmpdir 和 tmpdir_factory 负责在测试开始运行前创建临时文件目录,并在测试结束后删除。
tmpdir 的作用范围是函数级别,tmpdir_factory 的作用范围是会话级别。
import pytest, time def test_tmpdir(tmpdir): # 创建一个文件 a_file = tmpdir.join("something.txt") # 创建一个文件夹 anything a_sub_dir = tmpdir.mkdir("anything") # 在创建的文件夹中再创建一个文件 another_file = a_sub_dir.join("something_sele.txt") # 在文件中写入数据 a_file.write("contents mat settle during shipping") another_file.write("something different") # 读取并比对 assert a_file.read() == "contents mat settle during shipping" assert another_file.read() == "something different"
使用 tmpdir_factory 替换这个脚本
import pytest, time def test_tmpdir_factory(tmpdir_factory): # 相当于创建一个文件夹,相当于比 tmpdir要多做这一步操作。目录mydir a_dir = tmpdir_factory.mktemp("mydir") base_temp = tmpdir_factory.getbasetemp() print("base:", base_temp) #test_scope.py base: C:\Users\admin\AppData\Local\Temp\pytest-of-admin\pytest-11
a_file = a_dir.join("something.txt") a_sub_dir = a_dir.mkdir("anything") another_file = a_sub_dir.join("something_else.txt") a_file.write("contents may settle during shipping") another_file.write("something different") assert a_file.read() == "contents may settle during shipping" assert another_file.read() == "something different"
pytest-num 会随着会话的递增而递增。pytest 会记录最近几次会话使用的根目录,更早的根目录记录则会被清理掉。(默认保留 3 次)
在其他作用范围内使用临时目录
tmpdir_factory 的作用范围是会话级别的,tmpdir 的作用范围是函数级别的。如果需要模块或类级别作用范围的目录,该怎么办?可以利用 tmpdir_factory 再创建一个 fixture
假设有一个测试模块,其中有很多测试用例要读取一个json文件。有下例:(放在 conftest.py下面)
import json import pytest @pytest.fixture(scope="module") def author_file_json(tmpdir_factory): python_author_data = { "Ned":{"City":"Boston"}, "Brian":{"City":"Portland"}, "Luciano":{"City":"Sau Paulo"} } file = tmpdir_factory.mktemp("data").join("author_file.json") print("file:{}".format((str(file)))) with file.open("w") as f: json.dump(python_author_data,f) return file
上述代码创建了一个 json 文件。因为这个新 fixture 的作用范围是模块级别的,所以该 json 文件只需要被每个模块创建一次。
import json def test_brian_in_portland(author_file_json): with author_file_json.open() as f: authors = json.load(f) assert authors["Brian"]["City"] == "Portland" def test_all_hava_cities(author_file_json): with author_file_json.open() as f: authors = json.load(f) for a in authors: assert len(authors[a]["City"]) > 0
这里两个 测试用例 将使用同一个 json 文件。
4.2 使用 pytestconfig
内置 的 pytestconfig 可以通过命令行参数、选项、配置文件、插件、运行目录等方式来控制 pytest。
下面使用 pytest 的 hook 函数 pytest_addoption 添加几个命令行选项
import pytest def pytest_addoption(parser): parser.addoption("--myopt",action="store_true",help="some boolean option") parser.addoption("--foo",action="store",default="bar",help="foo:bar or baz")
以 pytest_addoption 添加的命令行选项必须通过插件来实现,或者在项目顶层目录的 conftest.py 文件中完成。它所在的 conftest.py 不能处于测试子目录下。
再运行 help
pytest --help ... custom options: --myopt some boolean option --foo=FOO foo:bar or baz ...
加下来就可以使用这些选项了
test_config.py import pytest def test_option(pytestconfig): print('"foo" set to:', pytestconfig.getoption("foo")) print('"myopt" set to:',pytestconfig.getoption("myopt"))
结果如下
(venv) C:\Users\admin\Desktop\ch2>pytest -s -q test_config.py::test_option "foo" set to: bar "myopt" set to: False . 1 passed in 0.01s —————————————————————————————————— (venv) C:\Users\admin\Desktop\ch2>pytest -s -q --myopt test_config.py::test_option "foo" set to: bar "myopt" set to: True . 1 passed in 0.01s —————————————————————————————————— (venv) C:\Users\admin\Desktop\ch2>pytest -s -q --myopt --foo baz test_config.py::test_option "foo" set to: baz "myopt" set to: True . 1 passed in 0.01s
因为 pytestconfig 是一个fixture,所以它也可以被其他 fixture 使用。
import pytest @pytest.fixture() def foo(pytestconfig): return pytestconfig.option.foo @pytest.fixture() def myopt(pytestconfig): return pytestconfig.option.myopt def test_fixtures_for_options(for,myopt): print('"foo" set to:',foo) print('"myopt" set to:',myopt)
4.3 使用 cache
有时需要从一段测试会话传递信息给下一段会话很有用。
cache 的作用是存储一段测试会话的信息,在下一段测试会话中使用。使用 pytest 内置的 --last-failed 和 --failed-first 标识可以很好的展示 cache的功能。
如果要清空缓存,可以在测试会话开始前传入 --cache-clear
cache 的接口很简单
cache.get(key,default)
cache.set(key,value)
习惯上,键名以应用名字或插件名字开始,接着是 / ,然后是分隔开的键字符串。键值可以是任何可转化为 JSON 的东西,因为在 .cache 目录里是用 JSON 格式存储的。
以下是一个 fixture ,记录测试的耗时,并存储到 cache ,如果接下来的测试耗时大于之前的两倍,就抛出超时异常。
import pytest, datetime @pytest.fixture(autouse=True) def check_duration(request, cache): key = "duration/" + request.node.nodeid.replace(":", "_") start_time = datetime.datetime.now() yield stop_time = datetime.datetime.now() this_duration = (stop_time - start_time).total_seconds() last_duration = cache.get(key, None) cache.set(key, this_duration) if last_duration is not None: errorstring = "test duration over 2X last duration" assert this_duration <= last_duration * 2, errorstring
因为 fixture 设置为了 autouse,所以它不需要被测试用例引用。request 对象用来抓取键名中的 nodeid。nodeid是一个独特的标识,即便实在参数化测试中也能使用。
import pytest, datetime,time,random @pytest.fixture(autouse=True) def check_duration(request, cache): key = "duration/" + request.node.nodeid.replace(":", "_") start_time = datetime.datetime.now() yield stop_time = datetime.datetime.now() this_duration = (stop_time - start_time).total_seconds() last_duration = cache.get(key, None) cache.set(key, this_duration) if last_duration is not None: errorstring = "test duration over 2X last duration" assert this_duration <= last_duration * 2, errorstring @pytest.mark.parametrize("i",range(5)) def test_slow_stuff(i): time.sleep(random.random())
运行之后,看看 cache 里有什么:
(venv) C:\Users\admin\Desktop\ch2>pytest -q --cache-show cachedir: C:\Users\admin\Desktop\ch2\.pytest_cache ----------------------------------- cache values for '*'----------------------------------- cache\lastfailed contains: {'test_add_variety.py': True, 'test_slower.py::test_slow_stuff[0]': True, 'test_slower.py::test_slow_stuff[1]': True, 'test_slower.py::test_slow_stuff[2]': True, 'test_slower.py::test_slow_stuff[3]': True, 'test_slower.py::test_slow_stuff[4]': True} cache\nodeids contains: ['test_api_exceptions.py::TestUpdate::test_bad_id', 'test_api_exceptions.py::TestUpdate::test_bad_task', 'test_api_exceptions.py::test_add_raises_true', 'test_config.py::test_fixtures_for_options', 'test_scope.py::test_all_hava_cities', 'test_scope.py::test_brian_in_portland', 'test_slower.py::test_slow_stuff[0]', 'test_slower.py::test_slow_stuff[1]', 'test_slower.py::test_slow_stuff[2]', 'test_slower.py::test_slow_stuff[3]', 'test_slower.py::test_slow_stuff[4]'] cache\stepwise contains: [] duration\test_slower.py__test_slow_stuff[0] contains: 0.480436 duration\test_slower.py__test_slow_stuff[1] contains: 0.769699 duration\test_slower.py__test_slow_stuff[2] contains: 0.78271 duration\test_slower.py__test_slow_stuff[3] contains: 0.380345 duration\test_slower.py__test_slow_stuff[4] contains: 0.569517 no tests ran in 0.01s
接下来的每个测试都将读/写 cache。可以把原先的 fixture 拆分为两个小 fixture:一个作用范围是函数级别,用于测量运行时间;另一个作用范围是会话级别,用来读/写 cache。可如果这样做,就不能使用 cache fixture了,因为它的作用范围是函数级别的。
import pytest,datetime,time,random from collections import namedtuple Duration = namedtuple("Duration", ["current", "last"]) @pytest.fixture(scope="session") def duration_cache(request): key = "duration/testdurations" d = Duration({}, request.config.cache.get(key, {})) yield d request.config.cache.set(key, d.current) @pytest.fixture(autouse=True) def check_duration(request, duration_cache): d = duration_cache nodeid = request.node.nodeid start_time = datetime.datetime.now() yield duration = (datetime.datetime.now() - start_time).total_seconds() d.current[nodeid] = duration if d.last.get(nodeid,None) is not None: errorstring = "test duration over 2X last duration" assert duration <= (d.last[nodeid] * 2),errorstring @pytest.mark.parametrize("i",range(5)) def test_slow_stuff(i): time.sleep(random.random())
duration_cache 的作用范围是会话级别的。在所有测试用例运行之前,它会读取之前的 cache 记录(如果没有记录,就是一个空字典)。在上面的代码中,我们把读取后的字段和一个空字典都存储在名为 Duration 的 namedtuple中,并使用 current 和 last 来访问之。之后将这个 namedtuple 传递给 check_duration,check_duration的作用范围是函数级别的。当测试用例运行时,相同的 namedtuple 被传递给每个测试用例。当前测试的运行时间被存储在 d.current 字典里。测试结束后,汇总的 current 字段被保存在 cache 里。
(venv) C:\Users\admin\Desktop\ch2>pytest -q --cache-clear test_slower_2.py ..... [100%] 5 passed in 3.32s (venv) C:\Users\admin\Desktop\ch2>pytest -q --tb=no test_slower_2.py ..... [100%] 5 passed in 2.84s (venv) C:\Users\admin\Desktop\ch2>pytest -q --cache-show cachedir: C:\Users\admin\Desktop\ch2\.pytest_cache ------------------------------------------------------------------------------------------------------------------------------- cache values for '*' ------------------------------------------------------------------------------------------------------------------------------- cache\nodeids contains: ['test_slower_2.py::test_slow_stuff[0]', 'test_slower_2.py::test_slow_stuff[1]', 'test_slower_2.py::test_slow_stuff[2]', 'test_slower_2.py::test_slow_stuff[3]', 'test_slower_2.py::test_slow_stuff[4]'] cache\stepwise contains: [] duration\testdurations contains: {'test_slower_2.py::test_slow_stuff[0]': 0.190172, 'test_slower_2.py::test_slow_stuff[1]': 0.930845, 'test_slower_2.py::test_slow_stuff[2]': 0.340309, 'test_slower_2.py::test_slow_stuff[3]': 0.652593, 'test_slower_2.py::test_slow_stuff[4]': 0.709644} no tests ran in 0.00s
4.4 使用 capsys
pytest 内置的 capsys 有两个功能:允许使用代码读取 stdout 和 strerr;可以临时禁止抓取日志输出。
假设某个函数要把欢迎信息输出到 stdout
def greeting(name): print("Hi,{}".format(name))
这时候不能使用返回值来测试它,只能测试 stdout。可使用capsys来测试。
def greeting(name): print("Hi,{}".format(name)) def test_greeting(capsys): greeting("Earthling") out, err = capsys.readouterr() assert out == "Hi,Earthling\n" assert err == "" greeting("Brian") greeting("Nerd") out, err = capsys.readouterr() assert out == "Hi,Brian\nHi,Nerd\n" assert err == ""
使用 strerr 的例子
import sys def yikes(problem): print("YIKES!{}".format(problem), file=sys.stderr) def test_yikes(capsys): yikes("Out of coffee!") out,err = capsys.readouterr() assert out == "" assert "Out of coffee!" in err
pytest 通常会抓取测试用例及被测试代码的输出。仅当全部测试会话运行结束后,抓取到的输出才会随着失败的测试显示出来。-s 参数可以关闭这个功能,在测试仍在运行期间就把输出直接发送到 stdout。
有时候就是想用 print 打印,但是又不想被捕获。这时候可以是用 capsys.disabled() 临时让输出绕过默认的输出捕获机制。
def test_capsys_disabled(capsys): with capsys.disabled(): print("\nalways print this") print("normal print, usually captured")
运行如下:
(venv) C:\Users\admin\Desktop\ch2>pytest -q test_capsys.py always print this . [100%] 1 passed in 0.01s (venv) C:\Users\admin\Desktop\ch2>pytest -q test_capsys.py -s always print this normal print, usually captured . 1 passed in 0.01s
正如你所看到的,不管有没有捕获输出,始终会显示 “always print this”。其他的打印正常,仅当 -s 标识的时候才会显示。(-s 表示关闭输出捕获)
也可以使用 capsys.readouterr() 捕获。这时候 就算 -s 也不能输出。
def test_capsys_disabled(capsys): with capsys.disabled(): print("\nalways print this") print("normal print, usually captured") out, err = capsys.readouterr() assert out == "normal print, usually captured\n" def test_capsys_disabled2(capsys): print("\nalways print this") print("normal print, usually captured") out, err = capsys.readouterr() assert out == "\nalways print this\nnormal print, usually captured\n"
4.5 使用 monkeypatch
monkey patch 可以在运行期间对类或模块进行动态修改,在测试中,monkey patch 常用于替换被测试代码的部分运行环境,或者将输入依赖或输出依赖替换成更容易测试的对象或函数。
monkeypatch 提供以下函数:
setattr(target, name, value=<notset>, raising=True):设置一个属性 delattr(target, name=<notset>, raising=True):删除一个属性 setitem(dic, name, value):设置字典中的一条记录 delitem(dic, name, raising=True):删除字典中的一条记录 setenv(name, value, prepend=None):设置一个环境变量 delenv(name, raising=True):删除一个环境变量 syspath_prepend(path):将路径 path 加入 sys.path 并放在最前,sys.path 是 Python 导入的系统路径列表 chdir(path):改变当前的工作目录
raising 参数用于指示 pytest 是否在记录不存在时抛出异常。setenv() 函数里的 prepend 参数可以是一个字符,如果这样设置的话,name环境变量的值就是 value + prepend + <old value>
为了理解 monkeypatch 的实际应用方式,以下是用于生成配置文件的代码。
import os import json def read_cheese_preferences(): full_path = os.path.expanduser("~/.cheese.json") with open(full_path, "r") as f: prefs = json.load(f) return prefs def write_cheese_preferences(prefs): full_path = os.path.expanduser("~/.cheese.json") with open(full_path, "w") as f: json.dump(prefs, f, indent=4) def write_default_cheese_preferences(): write_cheese_preferences(_default_prefs) _default_prefs = { "slicing": ["manchego", "sharp cheddar"], "spreadable": ["Saint Andre", "camembert", "bucheron", "goat", "humbolt fog", "cambozola"], "salads": ["crumbled feta"] }
write_default_cheese_preferences() 函数既不含参数,又没有返回值,那么如何测试?它在当前用户目录中编写了一个文件,我们可以利用这点从测试测试。
一种方法是直接运行代码,检查文件的生成情况。在我们足够信任 read_cheese_preferences() 函数测试结果的前提下,可以直接把它运用到 write_default_cheese_preferences() 函数的测试里。
def test_def_prefs_full(): write_default_cheese_preferences() expected = _default_prefs actual = read_cheese_preferences() assert expected == actual
但是有一个问题,这样测试,预设值文件会被覆盖,这样不合适。
如果用户设置了 HOME 变量,那么 os.path.expanduser() 函数会把 ~ 替换为 HOME 环境变量的值。让我们创建一个临时目录并将 HOME 指向它。
def test_def_prefs_change_home(tmpdir, monkeypatch): monkeypatch.setenv("HOME",tmpdir.mkdir("home")) write_default_cheese_preferences() expected = _default_prefs actual = read_cheese_preferences() assert expected == actual
看起来不错,但其中的 HOME 变量依赖于操作系统。查询 Python 官方文档,可以在 os.path.expanduser() 的介绍中找到这样一句话:“On Windows,HOME and USERPROFILE will be used if set,otherwise a combination of”。这个测试不适合 Windows。
用 expanduser 替换 HOME 环境变量
def test_def_prefs_change_expanduser(tmpdir, monkeypatch): fake_home_dir = tmpdir.mkdir("home") monkeypatch.setattr(os.path, "expanduser", (lambda x: x.replace("~", str(fake_home_dir)))) write_default_cheese_preferences() expected = _default_prefs actual = read_cheese_preferences() assert expected == actual
在测试中,cheese 模块中调用的 os.path.expanduser() 函数会被 lambda 表达式替换。原先该函数使用正则表达式模块的 re.sub() 函数,将 ~ 替换为我们新建的临时目录。现在已经使用了 setenv() 和 setattr() 函数来修改环境变量和属性。下面使用 setitem() 函数
有可能文件已经存在,所有要确保当 write_default_cheese_preferences() 被调用时,文件会被默认内容覆盖。
def test_def_prefs_change_defaults(tmpdir,monkeypatch): # write the file once fake_home_dir = tmpdir.mkdir("home") monkeypatch.setattr(os.path,"expanduser",(lambda x: x.replace("~", str(fake_home_dir)))) write_default_cheese_preferences() defaults_before = copy.deepcopy(_default_prefs) # change the defaults monkeypatch.setitem(_default_prefs,"slicing",["provolone"]) monkeypatch.setitem(_default_prefs,"spreadable",["brie"]) monkeypatch.setitem(_default_prefs,"salads",["pepper jack"]) defaults_modified = _default_prefs # write it again with modified defaults write_default_cheese_preferences() # read, and check actual = read_cheese_preferences() assert defaults_modified == actual assert defaults_modified != defaults_before
由于 _default_prefs 是字典,所有可以在测试运行时用 monkeypatch.setitem() 来修改字典中的条目。
我们使用过 setenv(),setattr() 和 setitem() 。有关 del 的几个函数在形式上与 set 非常相似,只不过它们是用来删除环境变量、属性和字典条目。最后的两个 monkeypatch 函数是有关路径操作的。
syspath_prepend(path) 在 sys.path 列表前加入一条路径,这可以提高你的新路径在模块搜索时的优先级。比如你可以采用这个方法,使用自定义的包、模块替换原先作用于系统范围的版本,接着使用 monkeypatch.syspatch_prepend() 函数来加入含有新版本模块的路径,这样,要测试的代码就会使用新版本的模块。
chdir(path) 可以在测试运行时改变当前的工作目录。这对于测试命令行脚本和其他依赖于当前目录的工具都很有用。你可以设置一个临时目录,然后使用 monkeypatch.chdir(the_tmpdir)。
4.6 使用 doctest_namespace
doctest 模块是 Python 标准库的一部分,借助它,可以在函数的文档字符串中放入示例代码,并通过测试确保有效。你可以使用 --doctest-modules 标识搜寻并运行 doctest 测试用例。
在构建被标注为 autouse 的fixture时,可以使用内置的 doctest_namespace,这能够使doctest 中的测试用例在运行时识别某些作用于 pytest 命名空间的字符标识,从而增强文档字符串的可读性。
下面的模块 unnecessary_math.py 有两个函数:multiply() 和 divide(),我们希望每个人都清楚地了解这两个函数。所以在文件和函数的文档字符传中都加入了一些使用例子:
""" This module defines multiply(a, b) and divide(a, b). >>> import unnecessary_math as um Here's how you use multiply: >>> um.multiply(4, 3) 12 >>> um.multiply('a', 3) 'aaa' Here's how you use divide: >>> um.divide(10, 5) 2.0 """ def multiply(a, b): """ Returns a multiplied by b. >>> um.multiply(4, 3) 12 >>> um.multiply("a", 3) "aaa" """ return a * b def divide(a, b): """ Returns a multiplied by b. >>> um.divide(10, 5) 2.0 """ return a / b
unnecessary_math 名字太长了,我们决定使用 um 来代替它,所以在文档顶部使用了 import unnecessary_math as um。后面的文档字符串里的代码不包含import语句,但一直在使用 um。问题是 pytest 将每个字符串里的代码看成是不同的测试用例,顶部的 import 语句可以保证第一个例子通过,但是后面的会失败
(venv) C:\Users\admin\Desktop\ch2>pytest -v --doctest-modules --tb=short unnecessary_math.py ============================== test session starts ============================== platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 -- c:\users\admin\desktop\ch2\venv\scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\admin\Desktop\ch2 collected 3 items unnecessary_math.py::unnecessary_math PASSED [ 33%] unnecessary_math.py::unnecessary_math.divide FAILED [ 66%] unnecessary_math.py::unnecessary_math.multiply FAILED [100%] ============================== FAILURES ============================== ________________________________________[doctest] unnecessary_math.divide ________________________________________ 030 031 Returns a multiplied by b. 032 >>> um.divide(10, 5) UNEXPECTED EXCEPTION: NameError("name 'um' is not defined",) Traceback (most recent call last): File "C:\python36\lib\doctest.py", line 1330, in __run compileflags, 1), test.globs) File "<doctest unnecessary_math.divide[0]>", line 1, in <module> NameError: name 'um' is not defined _______________________________________________________________________________________________________________________ [doctest] unnecessary_math.multiply ________________________________________________________________________________________________________________________ 019 020 Returns a multiplied by b. 021 >>> um.multiply(4, 3) UNEXPECTED EXCEPTION: NameError("name 'um' is not defined",) Traceback (most recent call last): File "C:\python36\lib\doctest.py", line 1330, in __run compileflags, 1), test.globs) File "<doctest unnecessary_math.multiply[0]>", line 1, in <module> NameError: name 'um' is not defined C:\Users\admin\Desktop\ch2\unnecessary_math.py:21: UnexpectedException ============================== short test summary info ============================== FAILED unnecessary_math.py::unnecessary_math.divide FAILED unnecessary_math.py::unnecessary_math.multiply ============================== 2 failed, 1 passed in 0.03s ==============================
一种解决办法是在每个文档字符串中加入 import 语句。
""" This module defines multiply(a, b) and divide(a, b). >>> import unnecessary_math as um Here's how you use multiply: >>> um.multiply(4, 3) 12 >>> um.multiply('a', 3) 'aaa' Here's how you use divide: >>> um.divide(10, 5) 2.0 """ def multiply(a, b): """ Returns a multiplied by b. >>> import unnecessary_math as um >>> um.multiply(4, 3) 12 >>> um.multiply('a', 3) 'aaa' """ return a * b def divide(a, b): """ Returns a multiplied by b. >>> import unnecessary_math as um >>> um.divide(10, 5) 2.0 """ return a / b
但是这样做分隔了文档字符串,。
第二种方法,是在 conftest.py 中使用内置的 doctest_namespace ,构建标记为 autouse 的 fixture,就可以解决之前的问题而且不用修改代码。
import pytest import unnecessary_math @pytest.fixture(autouse=True) def add_um(doctest_namespace): doctest_namespace["um"] = unnecessary_math
pytest 会把 um 添加到 doctest_namespace 中,并把它作为 unnecessary_math 模块的别名。这样设置 conftest.py 之后,在 conftest.py 的作用范围内的任意一个 doctest 测试用例都可以使用um
4.7 使用 recwarn
内置的 recwarn 可以用来检查待测代码产生的警告信息。在Python 里,可以添加警告信息,它们很像断言,但是并不阻止程序运行。
例如,我们想停止支持一个原本不该发布的函数,则可以在代码里设置警告信息。
import warnings import pytest def lame_function(): warnings.warn("Please stop using this",DeprecationWarning) # rest of function
可以用下面的测试用例来确保警告信息显示正确。
import warnings import pytest def lame_function(): warnings.warn("Please stop using this", DeprecationWarning) # rest of function def test_lame_function(recwarn): lame_function() assert len(recwarn) == 1 w = recwarn.pop() print("\nfilename", w.filename) # filename C:\Users\admin\Desktop\ch2\test_warnings.py print("\nlineno",w.lineno) # lineno 6 assert w.category == DeprecationWarning assert str(w.message) == "Please stop using this"
recwarn 的值就像是一个警告信息列表,列表里的每个警告信息都有 4 个属性 category、message、filename、lineno,从上面的代码中可以看到。
警告信息在测试开始后收集。如果你在意的警告信息出现在测试尾部,则可以在信息收集前使用 recwarn.clear() 清除不需要的内容。
除了 recwarn,pytest 还可以使用 pytest.warns() 来检查警告信息。
def test_lame_function_2(): with pytest.warns(None) as warning_list: lame_function() assert len(warning_list) == 1 w = warning_list.pop() assert w.category == DeprecationWarning assert str(w.message) == "Please stop using this"
pytest.warns() 上下文管理器可以优雅地标识哪些代码需要检查警告信息。recwarn 提供了相似的功能。可以自己选择
五、配置configuration
5.1 理解 pytest 的配置文件
pytest.ini:pytest 的主配置文件,可以改变 pytest 的默认行为,其中有很多可配置的选项。
conftest.py:是本地的插件库,其中的hook函数和fixture将作用域该文件所在的目录以及所有子目录
__init__.py:每个测试子目录都包含该文件时,那么在多个测试目录中可以出现同名测试文件。
tox.ini:它与pytest.ini 类似,只不过是 tox 的配置文件。你可以把 pytest 的配置都写在 tox.ini里,这样就不用同时使用 tox.ini 和 pytest.ini 两个文件。
5.2 用 pytest --help 查看 ini文件选项
(venv) C:\Users\admin\Desktop\ch2>pytest --help ... [pytest] ini-options in the first pytest.ini|tox.ini|setup.cfg file found: markers (linelist): markers for test functions empty_parameter_set_mark (string): default marker for empty parametersets norecursedirs (args): directory patterns to avoid for recursion testpaths (args): directories to search for tests when no files or directories are given in the command line. usefixtures (args): list of default fixtures to be used with this project python_files (args): glob-style file patterns for Python test module discovery python_classes (args): prefixes or glob names for Python test class discovery python_functions (args): prefixes or glob names for Python test function and method discovery disable_test_id_escaping_and_forfeit_all_rights_to_community_support (bool): disable string escape non-ascii characters, might cause unwanted side effects(use at your own risk) console_output_style (string): console output: "classic", or with additional progress information ("progress" (percentage) | "count"). xfail_strict (bool): default for the strict parameter of xfail markers when not given explicitly (default: False) enable_assertion_pass_hook (bool): Enables the pytest_assertion_pass hook.Make sure to delete any previously generated pyc cache files. junit_suite_name (string): Test suite name for JUnit report junit_logging (string): Write captured log messages to JUnit report: one of no|log|system-out|system-err|out-err|all junit_log_passing_tests (bool): Capture log information for passing tests to JUnit report: junit_duration_report (string): Duration time to report: one of total|call junit_family (string): Emit XML for schema: one of legacy|xunit1|xunit2 doctest_optionflags (args): option flags for doctests doctest_encoding (string): encoding used for doctest files cache_dir (string): cache directory path. filterwarnings (linelist): Each line specifies a pattern for warnings.filterwarnings. Processed after -W/--pythonwarnings. log_level (string): default value for --log-level log_format (string): default value for --log-format log_date_format (string): default value for --log-date-format log_cli (bool): enable log display during test run (also known as "live logging"). log_cli_level (string): default value for --log-cli-level log_cli_format (string): default value for --log-cli-format log_cli_date_format (string): default value for --log-cli-date-format log_file (string): default value for --log-file log_file_level (string): default value for --log-file-level log_file_format (string): default value for --log-file-format log_file_date_format (string): default value for --log-file-date-format log_auto_indent (string): default value for --log-auto-indent faulthandler_timeout (string): Dump the traceback of all threads if a test takes more than TIMEOUT seconds to finish. addopts (args): extra command line options minversion (string): minimally required pytest version required_plugins (args): plugins that must be present for pytest to run ...
5.3 更改默认命令行选项
如果测试的时候,经常要用到某些选项,又不想重复输入,这时可以使用 pytest.ini 文件里的 addopts 设置。下面是我自己常用的设置。
[pytest]
addopts = -rsxX -l --tb=short -strict
--rsxX 表示 pytest 报告所有测试用例被跳过、预计失败、预计失败但实际通过的原因。
-l 表示 pytest 报告所有失败测试的堆栈中的局部变量。
--tb=short 表示简化堆栈回溯信息,只保留文件和行数。
--strict 选项表示禁止使用未在配置文件中注册的标记。
5.4 注册标记来防范拼写错误
如果我们要标记,@pytest.mark.smoke ,但是拼错,@pytest.mark.somke , 默认情况下,这不会引起程序错误。pytest 会以为这是你创建的另一个标记。为了避免拼写错误,可以在 pytest.ini 文件里注册标记。
[pytest]
markers =
smoke: run the smoke test functions for tasks project
get: run the test functions that test tasks.get()
标记注册好后,可以通过 pytest --markers 来查看
没有注册的标记不会出现在 --markers 列表里。如果使用了 --strict 选项,遇到拼写错误的标记或未注册的标记就会报错。
import pytest @pytest.mark.sooke def test_capsys_disabled(capsys): with capsys.disabled(): print("\nalways print this") print("normal print, usually captured") out, err = capsys.readouterr() assert out == "normal print, usually captured\n" def test_capsys_disabled2(capsys): print("\nalways print this") print("normal print, usually captured") out, err = capsys.readouterr() assert out == "\nalways print this\nnormal print, usually captured\n"
这里 @pytest.mark.sooke 写错了。
(venv) C:\Users\admin\Desktop\ch2>pytest test_capsys.py --strict ================================ test session starts ================================ platform win32 -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 rootdir: C:\Users\admin\Desktop\ch2, configfile: pytest.ini collected 0 items / 1 error ================================ ERRORS ================================ _______________________________ ERROR collecting test_capsys.py _______________________________ 'sooke' not found in `markers` configuration option ================================ short test summary info ================================ ERROR test_capsys.py !!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ================================ 1 error in 0.07s ================================
如果你在 pytest.ini 文件里注册了标记,那么可以同时在 addopts 里加入 --strict
[pytest] addopts = -rsxX -l --tb=short -strict markers = smoke: run the smoke test functions for tasks project get: run the test functions that test tasks.get()
5.5 指定 pytest 的最低版本号
minversion 选项可以指定运行测试用例的 pytest 的最低版本。例如,测试两个浮点数的值是否非常接近。比如 approx()函数,但是这个功能 直到 pytest 3.0 才出现。
为了避免混淆,可以在使用 approx() 函数的项目中增加一行配置
[pytest]
minversion = 3.0
5.6 指定 pytest 忽略某些目录
pytest 执行测试搜索时,会递归遍历所有子目录,包括某些本来不想遍历的目录。
可以使用 norecurse 选项简化 pytest 的搜索工作。norecurse 的默认设置是 .* build dist CVS_darcs {arch} 和 *.egg。因为有 .* ,所以将虚拟环境命名为 .venv 是一个好主意,所有以 . 开头的目录都不会被访问。但是,我习惯将它命名为 venv,那么需要把它加入 norecursedirs里。
[pytest]
norecursedirs = .* venv src *.egg dist build
5.7 指定测试目录
norecursedirs 告诉pytest 哪些路径不用访问,而 testpaths 则指示 pytest 去哪里访问。
testpaths 是一系列相对于根目录的路径,用于限定测试用例的搜索范围。只有在pytest未指定文件目录参数或测试用例标识符时,该选项才会启用
5.8 更改测试搜索的规则
pytest 根据一定的规则搜索并运行测试。标准的测试搜索规则如下:
- 从一个或多个目录开始查找。你可以在命令行指定文件名或目录名。如果未指定,则使用当前目录。
- 在该目录和所有子目录下递归查找测试模块。
- 测试模块是指文件名为 test_*.py 或 *_test.py 的文件。
- 在测试模块中查找以 test_开头的函数名。
- 查找名字以 Test开头的类。其中,首先筛选掉包含 __init_函数的类,再查找类中以 test_ 开头的类方法。
pytest_classes
通常,pytest 的测试搜索规则是寻找以 Test*开头的测试类,而且这个类不能有 __init__() 函数。要改类搜索命名格式的话可以如下。
[pytest] python_classes = *Test Test* *Suite 这样设置后允许我们像下面这样给类取名 class DeleteSuite(): def test_delete_1(): ... def test_delete_2(): ...
python_files
可以更改默认的测试搜索规则,而不是仅查找以 test_* 开头的文件和以 *_test 结尾的文件。
[pytest]
python_files = test_* *_test check_*
python_functions
更改测试函数和方法的搜索方式
[pytest]
python_functions = test_* check_*
6.8 禁用XPASS
[pytest]
xfail_strict = true