从简单的断言和测试用例组织到更先进的参数化和夹具管理,pytest提供了强大的功能和灵活性。让我们一起探索这些技巧,使你的测试变得更加高效精准!
无需担心阅读时间过长,本文已经为您准备了详尽的解析和实际示例。立即开始,您将发现pytest的强大之处!
1、assert的各种使用场景
包含了几十种断言失败的各种场景,涉及断言的,可以查看pytest文档的 5.1.1(以 PDF 格式下载最新版本),举例几个常用的断言:
@pytest.mark.parametrize("param1, param2", [(3, 6)])
def test_generative(param1, param2):
assert param1 * 2 < param2
assert (3 * 2) < 6
def test_simple(self):
def f():
return 42
def g():
return 43
assert f() == g()
assert 42 == 43
def test_eq_long_text(self):
a = "1" * 100 + "a" + "2" * 100
b = "1" * 100 + "b" + "2" * 100
assert a == b
2、基本模式和示例
2.1 更改命令行选项的默认值
# content of pytest.ini
[pytest]
addopts = -ra -q
2.2 构建一个依赖于命令行选项的测试
根据命令行选项将不同的值传递给测试函数,运行用例:pytest -q test_sample.py --cmdopt=type3
# content of conftest.py
import pytest
def type_checker(value):
msg = "cmdopt must specify a numeric type as typeNNN"
if not value.startswith("type"):
raise pytest.UsageError(msg)
try:
int(value[4:])
except ValueError:
raise pytest.UsageError(msg)
return value
def pytest_addoption(parser):
parser.addoption(
"--cmdopt",
action="store",
default="type1",
help="my option: type1 or type2",
choices=("type1", "type2"),
type=type_checker,
)
@pytest.fixture
def cmdopt(request):
return request.config.getoption("--cmdopt")
# content of test_sample.py
def test_answer(cmdopt):
if cmdopt == "type1":
print("first")
elif cmdopt == "type2":
print("second")
assert 0
2.3 动态地添加命令行选项
# setuptools plugin
import sys
def pytest_load_initial_conftests(args):
if "xdist" in sys.modules: # pytest-xdist plugin
import multiprocessing
num = max(multiprocessing.cpu_count() / 2, 1)
args[:] = ["-n", str(num)] + args
2.4 控制跳过测试根据命令行选项
# content of conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption("--runslow", action="store_true", default=False, help="run slow tests" )
def pytest_configure(config):
config.addinivalue_line("markers", "slow: mark test as slow to run")
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
# --runslow given in cli: do not skip slow tests
return
skip_slow = pytest.mark.skip(reason="need --runslow option to run")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
# content of test_module.py
import pytest
def test_func_fast():
pass
@pytest.mark.slow
def test_func_slow():
pass
2.5 编写良好的断言程序
# content of test_checkconfig.py
import pytest
def checkconfig(x):
__tracebackhide__ = True
if not hasattr(x, "config"):
pytest.fail("not configured: {}".format(x))
def test_something():
checkconfig(42)
2.6 检测是否从 pytest 运行中运行
# content of your_module.py
_called_from_test = False
# content of conftest.py
def pytest_configure(config):
your_module._called_from_test = True
2.7 向测试报告题头添加信息
# content of conftest.py
def pytest_report_header(config):
return "project deps: mylib-1.1"
2.8 分析测试持续时间
pytest --durations=3 test_some_are_slow.py
# content of test_some_are_slow.py
import time
def test_funcfast():
time.sleep(0.1)
def test_funcslow1():
time.sleep(0.2)
def test_funcslow2():
time.sleep(0.3)
2.11 处理过程后的测试报告/失败
# content of conftest.py
import pytest
import os.path
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
# execute all other hooks to obtain the report object
outcome = yield
rep = outcome.get_result()
# we only look at actual failing test calls, not setup/teardown
if rep.when == "call" and rep.failed:
mode = "a" if os.path.exists("failures") else "w"
with open("failures", mode) as f:
# let's also access a fixture for the fun of it
if "tmp_path" in item.fixturenames:
extra = " ({})".format(item.funcargs["tmp_path"])
else:
extra = "" f.write(rep.nodeid + extra + "\n")
# content of test_module.py
def test_fail1(tmp_path):
assert 0
def test_fail2():
assert 0
2.12 在固定设备中提供测试结果信息
# content of conftest.py
import pytest
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
# execute all other hooks to obtain the report object
outcome = yield
rep = outcome.get_result()
# set a report attribute for each phase of a call, which can
# be "setup", "call", "teardown"
setattr(item, "rep_" + rep.when, rep)
@pytest.fixture
def something(request):
yield
# request.node is an "item" because we use the default
# "function" scope
if request.node.rep_setup.failed:
print("setting up a test failed!", request.node.nodeid)
elif request.node.rep_setup.passed:
if request.node.rep_call.failed:
print("executing test failed", request.node.nodeid)
# content of test_module.py
import pytest
@pytest.fixture
def other():
assert 0
def test_setup_fails(something, other):
pass
def test_call_fails(something):
assert 0
def test_fail2():
assert 0
2.9 测试步骤的增量测试
2.10 包装/目录级固定装置(设置)
2.13 PYTEST_CURRENT_TEST环境变量
2.14 Freezing pytest
3、用例参数化
3.1 生成参数组合,取决于命令行
pytest -vs test_sample.py
pytest -vs test_sample.py --all
# content of conftest.py
def pytest_addoption(parser):
parser.addoption("--all", action="store_true", help="run all combinations")
def pytest_generate_tests(metafunc):
if "param1" in metafunc.fixturenames:
if metafunc.config.getoption("all"): # 如果在命令行中获取到了字符串all:pytest -vs test_sample.py --all
end = 5
else: # 如果在命令什么也没有:pytest -vs test_sample.py
end = 2
print("2")
metafunc.parametrize("param1", range(end))
# content of test_compute.py
def test_compute(param1):
assert param1 < 4
3.2 参数中设置不同的测试ID
# content of test_time.py
from datetime import datetime, timedelta
import pytest
testdata = [
(datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1)),
(datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1)),
]
@pytest.mark.parametrize("a,b,expected", testdata)
def test_timedistance_v0(a, b, expected):
diff = a - b
assert diff == expected
@pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
def test_timedistance_v1(a, b, expected):
diff = a - b
assert diff == expected
def idfn(val):
if isinstance(val, (datetime,)):
# note this wouldn't show any hours/minutes/seconds
return val.strftime("%Y%m%d")
@pytest.mark.parametrize("a,b,expected", testdata, ids=idfn)
def test_timedistance_v2(a, b, expected):
diff = a - b
assert diff == expected
@pytest.mark.parametrize(
"a,b,expected",
[
pytest.param(datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1), id="forward"),
pytest.param(datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1), id="backward"),
],
)
def test_timedistance_v3(a, b, expected):
diff = a - b
assert diff == expected
pytest test_time.py --collect-only
,--collect-only 用来显示测试ID
collected 8 items
<Package test>
<Module test_time.py>
<Function test_timedistance_v0[a0-b0-expected0]>
<Function test_timedistance_v0[a1-b1-expected1]>
<Function test_timedistance_v1[forward]>
<Function test_timedistance_v1[backward]>
<Function test_timedistance_v2[20011212-20011211-expected0]>
<Function test_timedistance_v2[20011211-20011212-expected1]>
<Function test_timedistance_v3[forward]>
<Function test_timedistance_v3[backward]>
3.3 “测试场景”的快速移植
# content of test_scenarios.py
def pytest_generate_tests(metafunc):
idlist = []
argvalues = []
for scenario in metafunc.cls.scenarios:
idlist.append(scenario[0])
items = scenario[1].items()
argnames = [x[0] for x in items]
argvalues.append([x[1] for x in items])
metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class")
scenario1 = ("basic", {"attribute": "value"})
scenario2 = ("advanced", {"attribute": "value2"})
class TestSampleWithScenarios:
scenarios = [scenario1, scenario2]
def test_demo1(self, attribute):
assert isinstance(attribute, str)
def test_demo2(self, attribute):
assert isinstance(attribute, str)
3.4 延迟对参数化资源的设置
3.5 间接参数化
在参数化测试时,使用indirect=True
参数允许参数化测试之前参数化测试:
import pytest
@pytest.fixture
def fixt(request):
return request.param * 3
@pytest.mark.parametrize("fixt", ["a", "b"], indirect=True)
def test_indirect(fixt):
assert len(fixt) == 3
3.6 间接地应用于特定的参数
对@pytest.mark.parametrize
中的值再次做参数化,使用indirect=["x"]
指定引用固定装置X再次对值进行参数化处理:
# content of test_indirect_list.py
import pytest
@pytest.fixture(scope="function")
def x(request):
return request.param * 3
@pytest.fixture(scope="function")
def y(request):
return request.param * 2
# indirect给了值X:引用了上面的固定装置X,没给Y,没引用固定装置Y,所以Y的取值是b
@pytest.mark.parametrize("x, y", [("a", "b")], indirect=["x"])
def test_indirect(x, y):
assert x == "aaa"
assert y == "b"
3.7 通过每个类的配置来参数化测试方法
这里是一个示例pytest_generate_tests函数实现一个参数化方案
# content of ./test_parametrize.py
import pytest
def pytest_generate_tests(metafunc):
# called once per each test function
funcarglist = metafunc.cls.params[metafunc.function.__name__]
argnames = sorted(funcarglist[0])
metafunc.parametrize(argnames, [[funcargs[name] for name in argnames] for funcargs in funcarglist])
class TestClass:
# a map specifying multiple argument sets for a test method
params = { "test_equals": [dict(a=1, b=2), dict(a=3, b=3)], "test_zerodivision": [dict(a=1, b=0)],}
def test_equals(self, a, b):
assert a == b
def test_zerodivision(self, a, b):
with pytest.raises(ZeroDivisionError):
a / b
3.8 使用多种固定装置的间接参数化
import shutil
import subprocess
import textwrap
import pytest
pythonlist = ["python3.5", "python3.6", "python3.7", "python3.9.6"]
@pytest.fixture(params=pythonlist)
def python1(request, tmp_path):
picklefile = tmp_path / "data.pickle"
return Python(request.param, picklefile)
@pytest.fixture(params=pythonlist)
def python2(request, python1):
return Python(request.param, python1.picklefile)
class Python:
def __init__(self, version, picklefile):
self.pythonpath = shutil.which(version)
if not self.pythonpath:
pytest.skip(f"{version!r} not found")
self.picklefile = picklefile
def dumps(self, obj):
dumpfile = self.picklefile.with_name("dump.py")
dumpfile.write_text(textwrap.dedent(
r"""
import pickle
f = open({!r}, 'wb')
s = pickle.dump({!r}, f, protocol=2)
f.close()
""".format(str(self.picklefile), obj)
))
subprocess.check_call((self.pythonpath, str(dumpfile)))
def load_and_is_true(self, expression):
loadfile = self.picklefile.with_name("load.py")
loadfile.write_text(
textwrap.dedent(
r"""
import pickle
f = open({!r}, 'rb')
obj = pickle.load(f)
f.close()
res = eval({!r})
if not res:
raise SystemExit(1)
""".format(
str(self.picklefile), expression
)))
print(loadfile)
subprocess.check_call((self.pythonpath, str(loadfile)))
@pytest.mark.parametrize("obj", [42, {}, {1: 3}])
def test_basic_objects(python1, python2, obj):
python1.dumps(obj)
python2.load_and_is_true(f"obj == {obj}")
3.9 可选实现/导入的间接参数化
3.10 为个别参数化测试设置标记或测试ID
# content of test_pytest_param_example.py
import pytest
@pytest.mark.parametrize(
"test_input,expected",
[
("3+5", 8),
pytest.param("1+7", 8, marks=pytest.mark.basic),
pytest.param("2+4", 6, marks=pytest.mark.basic, id="basic_2+4"),
pytest.param("6*9", 42, marks=[pytest.mark.basic, pytest.mark.xfail], id="basic_6*9"),
],
)
def test_eval(test_input, expected):
assert eval(test_input) == expected
3.11 参数化条件提升
from contextlib import contextmanager
import pytest
@contextmanager
def does_not_raise():
yield
@pytest.mark.parametrize(
"example_input,expectation",
[
(3, does_not_raise()),
(2, does_not_raise()),
(1, does_not_raise()),
(0, pytest.raises(ZeroDivisionError)),
],
)
def test_division(example_input, expectation):
"""Test how much I know division."""
with expectation:
assert (6 / example_input) is not None
4、自定义标记
4.1 标记测试函数并运行
pytest test_module.py -m webtest
pytest test_module.py -v -m "not webtest
# content of test_server.py
import pytest
@pytest.mark.webtest
def test_send_http():
pass # perform some webtest test for your app
def test_something_quick():
pass
def test_another():
pass
class TestClass:
def test_method(self):
pass
4.2 根据节点选择测试
pytest -v test_server.py::TestClass
pytest -v test_server.py::TestClass::test_method
pytest -v test_server.py::TestClass test_server.py::test_send_http
4.3 根据正则表达式选择测试用例
pytest -v -k http test_server.py
pytest -v -k "not send_http" test_server.py
pytest -v -k "http or quick" test_server.py
# content of test_server.py
import pytest
@pytest.mark.webtest
def test_send_http():
pass # perform some webtest test for your app
def test_something_quick():
pass
def test_another():
pass
class TestClass:
def test_method(self):
pass
4.4 注册标记
为测试套件注册标记很简单,查看注册的标记:pytest --markers
# content of pytest.ini
[pytest]
markers =
webtest: mark a test as a webtest.
slow: mark test as slow.
4.5 标记整个类或模块
# content of test_mark_classlevel.py
import pytest
@pytest.mark.webtest
class TestClass:
def test_startup(self):
pass
def test_startup_and_more(self):
pass
要在模块级别应用标记,也可以使用全局变量定义:
import pytest
# 单个标记
pytestmark = pytest.mark.webtest
# 多个标记:
pytestmark = [pytest.mark.webtest, pytest.mark.slowtest]
class TestClass:
def test_startup(self):
pass
def test_startup_and_more(self):
pass
import pytest
class TestClass:
pytestmark = pytest.mark.webtest
def test_startup(self):
pass
4.6 参数化中标记各个测试
import pytest
@pytest.mark.foo
@pytest.mark.parametrize(("n", "expected"), [(1, 2), pytest.param(1, 3, marks=pytest.mark.bar), (2, 3)])
def test_increment(n, expected):
assert n + 1 == expected
4.7 自定义标记和命令行选项
pytest -E stage2 test_moule.py
# content of conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption(
"-E",
action="store",
metavar="NAME",
help="only run tests matching the environment NAME.", )
def pytest_configure(config):
# register an additional marker
config.addinivalue_line(
"markers", "env(name): mark test to run only on named environment" )
def pytest_runtest_setup(item):
envnames = [mark.args[0] for mark in item.iter_markers(name="env")]
if envnames:
if item.config.getoption("-E") not in envnames:
pytest.skip(f"test requires env in {envnames!r}")
# content of test_someenv.py
import pytest
@pytest.mark.env("stage1")
def test_basic_db_operation():
pass
4.8 从多个地方读取标记
# content of test_mark_three_times.py
import pytest
pytestmark = pytest.mark.glob("module", x=1)
@pytest.mark.glob("class", x=2)
class TestClass:
@pytest.mark.glob("function", x=3)
def test_something(self):
pass
# content of conftest.py`在这里插入代码片`
import sys
def pytest_runtest_setup(item):
for mark in item.iter_markers(name="glob"):
print(f"glob args={mark.args} kwargs={mark.kargs}")
sys.stdout.flush()
4.9 标记不同的测试平台环境
pytest -m linux test_plat.py
# content of conftest.py
#
import sys
import pytest
ALL = set("darwin linux win32".split())
def pytest_runtest_setup(item):
supported_platforms = ALL.intersection(mark.name for mark in item.iter_markers())
plat = sys.platform
if supported_platforms and plat not in supported_platforms:
pytest.skip(f"cannot run on platform {plat}")
# content of test_plat.py
import pytest
@pytest.mark.darwin
def test_if_apple_is_evil():
pass
@pytest.mark.linux
def test_if_linux_works():
pass
@pytest.mark.win32
def test_if_win32_crashes():
pass
def test_runs_everywhere():
pass
4.10 基于测试名称自动添加标记
pytest -m interface --tb=short test_moudle.py
pytest -m "interface or event" --tb=short test_moudle.py
# content of test_module.py
def test_interface_simple():
assert 0
def test_interface_complex():
assert 0
def test_event_simple():
assert 0
def test_something_else():
assert 0
# content of conftest.py
import pytest
def pytest_collection_modifyitems(items):
for item in items:
if "interface" in item.nodeid:
item.add_marker(pytest.mark.interface)
elif "event" in item.nodeid:
item.add_marker(pytest.mark.event)
5、session级别的固定装置
会话范围的夹具有效地访问所有收集的测试项目。 这是一个fixture函数的例子 遍历所有收集的测试并查看他们的测试类是否定义了 callme 方法并调用它:
# content of conftest.py
import pytest
@pytest.fixture(scope="session", autouse=True)
def callattr_ahead_of_alltests(request):
print("callattr_ahead_of_alltests called")
seen = {None}
session = request.node
for item in session.items:
cls = item.getparent(pytest.Class)
if cls not in seen:
if hasattr(cls.obj, "callme"):
cls.obj.callme()
seen.add(cls)
# content of test_module.py
class TestHello:
@classmethod
def callme(cls):
print("callme called!")
def test_method1(self):
print("test_method1 called")
def test_method2(self):
print("test_method2 called")
class TestOther:
@classmethod
def callme(cls):
print("callme other called")
def test_other(self):
print("test other")
# works with unittest as well ...
import unittest
class SomeTest(unittest.TestCase):
@classmethod
def callme(self):
print("SomeTest callme called")
def test_unit1(self):
print("test_unit1 method called")
C:\Users\mc\Desktop\python>pytest -q -s test_module.py
callattr_ahead_of_alltests called
callme called!
callme other called
SomeTest callme called
test_method1 called
.test_method2 called
.test other
.test_unit1 method called
6、更改标准 (Python) 发现测试用例
6.1 在测试收集期间忽略路径: --ignore
- 通过传递
--ignore=path
选项,您可以在收集过程中轻松忽略某些测试目录和模块cli,pytest 允许多个 --ignore 选项。 例如test目录上执行:pytest --ignore=tests/foobar/test_foobar_03.py --ignore=tests/hello/
- --ignore-glob 选项允许忽略基于 Unix shell 样式通配符的测试文件路径。 如果要排除以 _01.py 结尾的测试模块:
pytest --ignore-glob='*_01.py'
tests/
|-- example
| |-- test_example_01.py
| |-- test_example_02.py
| |-- test_example_03.py
|-- foobar
| |-- test_foobar_01.py
| |-- test_foobar_02.py
| |-- test_foobar_03.py
|-- hello
|-- world
|-- test_world_01.py
|-- test_world_02.py
|-- test_world_03.py
6.2 在测试收集期间忽略测试用例:--deselect
pytest --deselect tests/foobar/test_foobar_01.py::test_a
6.3 更改目录递归
告诉 pytest 不要递归到典型的 subversion 或 sphinx-build 目录或任何以 tmp 为前缀的目录。
# content of pytest.ini
[pytest]
norecursedirs = .svn _build tmp*
6.4 更改命名约定
在你的配置文件中,您可以通过设置 python_files、python_classes 和 python_functions :pytest --collect-only
# content of pytest.ini
# Example 1: have pytest look for "check" instead of "test"
[pytest]
python_files = check_*.py test_*.py example_*.py
python_classes = Check
python_functions = *_check
# content of check_myapp.py
class CheckMyApp:
def simple_check(self):
pass
def complex_check(self):
pass
注意:对unittest发现用例的方法不管用,因为pytest将测试用例方法的发现委托给unittest
6.5 查看测试用例树
pytest --collect-only test_module.py
# content of test_module.py
class TestHello:
@classmethod
def callme(cls):
print("callme called!")
def test_method1(self):
print("test_method1 called")
def test_method2(self):
print("test_method2 called")
class TestOther:
@classmethod
def callme(cls):
print("callme other called")
def test_other(self):
print("test other")
# works with unittest as well ...
import unittest
class SomeTest(unittest.TestCase):
@classmethod
def callme(self):
print("SomeTest callme called")
def test_unit1(self):
print("test_unit1 method called")
C:\Users\mc\Desktop\python基础>pytest --collect-only test_module.py
======================================================================= test session starts ========================================================================
platform win32 -- Python 3.9.6, pytest-7.1.1, pluggy-0.13.1
rootdir: C:\Users\mc\Desktop\python基础
collected 4 items
<Module test_module.py>
<Class TestHello>
<Function test_method1>
<Function test_method2>
<Class TestOther>
<Function test_other>
<UnitTestCase SomeTest>
<TestCaseFunction test_unit1>
==================================================================== 4 tests collected in 0.03s ====================================================================
6.6 忽略收集的某些文件
然而,许多项目会有一个他们不想被导入的 setup.py。 此外,可能只有文件可由特定的 python 版本导入。 对于这种情况,您可以通过将它们列出来动态定义要忽略的文件一个 conftest.py 文件
# content of pytest.ini
[pytest]
python_files = *.py
# content of conftest.py
import sys
collect_ignore = ["setup.py"]
if sys.version_info[0] > 2:
collect_ignore.append("pkg/module_py2.py")
# content of pkg/module_py2.py
def test_only_on_python2():
try:
assert 0
except Exception, e:
pass
7、使用非python进行测试
7.1 在Yaml文件中指定测试的基本示例
这是一个示例 conftest.py( pytest-yamlwsgi 插件),conftest.py 将收集 test*.yaml 文件并将 yaml 格式的内容作为自定义测试执行:pytest -vs test_simple.yaml
collected 2 items
test_simple.yaml::hello FAILED
test_simple.yaml::ok PASSED
# content of conftest.py
import pytest
def pytest_collect_file(parent, file_path):
if file_path.suffix == ".yaml" and file_path.name.startswith("test"):
return YamlFile.from_parent(parent, path=file_path)
class YamlFile(pytest.File):
def collect(self):
# We need a yaml parser, e.g. PyYAML.
import yaml
raw = yaml.safe_load(self.path.open())
for name, spec in sorted(raw.items()):
yield YamlItem.from_parent(self, name=name, spec=spec)
class YamlItem(pytest.Item):
def __init__(self, *, spec, **kwargs):
super().__init__(**kwargs)
self.spec = spec
def runtest(self):
for name, value in sorted(self.spec.items()):
# Some custom test execution (dumb example follows).
if name != value:
raise YamlException(self, name, value)
def repr_failure(self, excinfo):
"""Called when self.runtest() raises an exception."""
if isinstance(excinfo.value, YamlException):
return "\n".join(
[ "usecase execution failed", " spec failed: {1!r}: {2!r}".format(*excinfo.value.args),
" no further details known at this point.", ] )
def reportinfo(self):
return self.path, 0, f"usecase: {self.name}"
class YamlException(Exception):
"""Custom exception for error reporting."""
# test_simple.yaml
ok:
sub1: sub1
hello:
world: world
some: other
结语
这篇贴子到这里就结束了,最后,希望看这篇帖子的朋友能够有所收获。
获取方式:留言【软件测试学习】即可
如果你觉得文章还不错,请大家 点赞、分享、留言 下,因为这将是我持续输出更多优质文章的最强动力!