pytest 是一个高效的 Python 测试框架,可以用于大型项目的单元测试、集成测试、性能测试等测试场景
一、pytest 介绍
简单介绍
pytest 是一个成熟且功能完善的 Python 测试工具,可以辅助我们把程序写得更好。pytest 使用简单,可读性好,并且具有强大的扩展能力,支持对应用程序或库的进行复杂的功能测试
功能介绍
- 基于 Python 标准 assert 的增强语法
- 自动发现测试用例
- 简单易用、模块化、可扩展的 fixtures
- pytest 有非常强大的插件,并且这些插件能够实现很多的实用的操作
集成介绍
- pytest 可以和 selenium、requests、appinum 结合实现 Web 自动化、接口自动化、App 自动化
- pytest 支持跳过测试用例、重试失败用例
- pytest 可以和 aliure 生成非常美观的测试报告
- pytest 可以和 Jenkins 持续集成
二、pytest 基本语法
规则
文件名
test_*.py 或者 *_test.py
测试类与测试方法
测试方法以 test 开头。示例如下
def test_method():
pass
如果以 OOP 方式组织测试用例,测试类以 Test 开头,这种方式可以在父类编写通用测试逻辑、抽取通用代码。示例如下
class TestDemo:
def test_method():
pass
增强的 assert 语法
标准 Python assert
语法
assert expression
# 等价于
if not expression:
raise AssertionError
# assert 后可以紧跟参数
assert expression [, arguments]
# 等价于
if not expression:
raise AssertionError(arguments)
示例
assert a == b
assert a == b, "a should equal b"
pytest 关于异常的增强 assert
pytest 中使用 pytest.raises() 可以非常方便地测试某段代码会抛出指定异常
import pytest
def test_zero_division():
def by_zero():
1 / 0
with pytest.raises(ZeroDivisionError) as exception_info:
by_zero()
assert "division by zero" in str(exception_info.value)
该示例中的方法 by_zero 会抛出 ZeroDivisionError 异常,并且异常信息为 division by zero
其中 exception_info 是 pytest 的 ExceptionInfo 实例,包装了真正的异常对象,3 个核心的属性为 type, value, traceback
def test_foo_not_implemented():
def foo():
raise NotImplementedError
with pytest.raises(NotImplementedError) as exception_info:
foo()
assert exception_info.type is NotImplementedError
使用 pytest.raises() 时还可以传入 match 参数
import pytest
def myfunc():
raise ValueError("Exception 12 raised")
def test_match():
with pytest.raises(ValueError, match="12"):
myfunc()
在执行时,match 参数由 re.search() 检查是否匹配
pytest.mark.xfail
这是 pytest 中更常用的异常处理方式,其中的 raises 参数与 pytest.raises() 相似
import pytest
def index_error():
raise IndexError()
@pytest.mark.xfail(raises=IndexError)
def test_ index_error():
index_error()
fixtures
fixtures 语法
pytest fixtures 通常是一些方法,这些方法为测试提供了脉络清晰的上下文
1. 基本语法
使用 @pytest.fixture 标识一个方法为 fixture,然后可以在另一个 fixture 或测试方法中作为参数使用
import pytest
class Fruit:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return self.name == other.name
@pytest.fixture
def an_apple():
return Fruit("apple")
@pytest.fixture
def fruit_basket(an_apple):
return [Fruit("banana"), an_apple]
def test_my_fruit_in_basket(an_apple, fruit_basket):
assert an_apple in fruit_basket
2. 默认使用 fixture
有的场景下,需要定义所有测试都依赖的 fixture。这时可以使用 autouse 属性
示例:打印测试方法执行日志
test.py
import pytest
@pytest.fixture(autouse=True)
def method_invocation_log(request):
func_name = request.function.__name__
print(f"\nStart to invoke method {func_name} ...")
def test_a():
pass
def test_b():
pass
执行命令
pytest -s test.py
部分日志如下
collected 2 items
test.py
Start to invoke method test_a ...
.
Start to invoke method test_b ...
.
fixtures 生命周期
一个 fixture 实例在第一次被使用时创建,在生命周期范围结束后被销毁
定义 pytest fixture 时,共有 5 种可选范围
- function 默认值,fixture 在测试方法结束后销毁
- class fixture 在测试类的最后一个测试方法结束后销毁
- module fixture 在定义模块的最后一个测试方法结束后销毁
- package fixture 在定义包的最后一个测试方法结束后销毁
- session fixture 在测试 session 结束后销毁
fixtures 可用性
只有在 fixture scope 范围内 的测试方法才能使用这些 fixture。
- 如果一个 fixture 定义在类中,则只能被当前类的测试方法使用
import pytest
class TestFixtureAvailability:
@pytest.fixture
def class_scope_fixture():
print("This fixture is inside a class")
def test_method(class_scope_fixture):
assert 1 != 2
- 如果 fixture 定义在一个模块的全局范围内,则整个模块的测试方法都可以使用
- 一个 fixture 也可以使用其他的 fixture
fixtures 特征
-
fixture 可以调用其他 fixture
-
fixture 可复用
可用于定义通用初始化步骤/初始化数据,在多个测试中共享,但多个测试之间互不影响
-
一个测试或 fixture 可以使用多个 fixtures
-
可以在一个测试中多次使用同一个 fixture,其返回值会被缓存起来
被多次使用的 fixture,只会在第 1 次使用时执行,并且返回对象被缓存,第 2 次以后的使用,直接复用缓存对象
触发测试
1. 执行所有测试用例
pytest
该命令会执行当前目录及其子目录下符合上述规则的测试用例
2. 执行指定目录下的用例
pytest tests/
3. 执行某个文件下的用例
# 执行文件下全部用例
pytest tests/test_mod.py
# 执行文件下某个测试类的全部用例
pytest tests/test_mod.py::TestDemo
4. 执行单个用例
# 执行某个文件下直接编写的 1 个用例
pytest tests/test_mod.py::test_func
# 执行某个测试类下的 1 个 用例
pytest tests/test_mod.py::TestDemo::test_func
# 执行单个用例时传入参数
pytest tests/test_mod.py::test_func[param1,param2]
5. 执行某些包下的用例
pytest --pyargs pkg.testing
执行该命令时,会导入 pkg.testing 包并使用它的文件系统路径,发现并执行测试用例
6. 执行包含 marker 的用例
假设当前目录下有一个测试文件定义如下
import pytest
def test_method_a():
pass
@pytest.mark.slow()
def test_method_b():
pass
@pytest.mark.slow()
def test_method_c():
pass
以下命令将会执行所有含 slow 装饰器的用例
pytest -m slow
7. 按表达式触发测试
pytest -k "有效的 Python 表达式,忽略大小写"
示例 1,触发类名 / 方法名中包含 test_basic 或 test_perf 的用例
pytest -k "test_basic or test_perf"
示例 2,触发类名 / 方法名中不包含 test_slow 的用例
pytest -k "not test_slow"
8. 通过 python 命令触发测试
python -m pytest [...]
该命令与直接使用 pytest [...] 相同,但该方式会将当前目录添加到 sys.path 中,这在脚本中触发测试非常有用
9. 在 Python 代码中触发测试
pytest 作为一个 Python 框架,支持直接在 Python 代码中执行
import pytest
retcode = pytest.main()
# 指定参数
retcode = pytest.main(["-x", "test-dir"])
三、pytest 执行机制
用例发现机制
pytest 的标准发现机制步骤如下
-
确定开始位置
未传入任何参数
a. 如果配置了
testpaths,从该配置定义的目录开始pytest.ini
[pytest] testpaths = test-dirctoryb. 未配置
testpaths,从当前目录开始 -
从开始位置递归处理子目录,重复 3-4 步
跳过
norecursedirs配置的子目录,如skipped-test-dirctorypytest.ini
[pytest] norecursedirs = skipped-test-dirctory -
收集
test_*.py或*_test.py测试文件 -
在每个测试文件中收集用例
a. 收集类之外以
test开头的方法b. 收集以
Test开头的类(不含__init__)下以test开头的方法,包括带有@staticmethod和@classmethods注解的方法
fixture 执行机制
pytest 执行一个测试时,会遍历方法签名中的参数,然后在可见的 fixtures 中查看是否有同名的 fixture。如果存在同名的 fixture,执行并捕获返回结果,然后传递给测试方法作为入参
插件集成机制
使用插件之前,需要先安装
pip install pytest-{plugin-name}
安装插件之后,不需要其他操作,pytest 会自动发现并集成已安装的插件
四、pytest 使用技巧
跳过测试用例
有的场景下,部分测试用例必然会失败,而修复测试用例很耗时。此外,有的测试用例依赖于特定的操作系统
可以使用 @pytest.mark.skip 跳过这些测试用例,也可以直接在代码中调用 pytest.skip(reason) 方法
import pytest
@pytest.mark.skip(reason="wait for fixing")
def test_wait_for_fixing():
pass
def test_skip_by_code():
pytest.skip("skiped by code")
def test_normal():
pass
使用 pytest 执行该示例,会发现有 2 个测试被跳过了
...
collected 3 items
...
=== 1 passed, 2 skipped ===
此外,还可以使用 @pytest.mark.skipif(condition, reason) 按条件跳过测试用例,当 condition 为 True 时会跳过测试
import sys
import pytest
@pytest.mark.skipif(sys.platform == "darwin", reason="not supported on Mac")
def test_skip_on_mac():
pass
自动重试失败测试用例
有的测试用例很容易失败,一般被称为 Flaky tests。可以使用 pytest-rerunfailures 插件,当这些测试失败时,自动重试
import pytest
# reruns=3 表示如果当前测试失败,最多重试 3 次
# reruns_delay 表示重试时延,单位为 s
@pytest.mark.flaky(reruns=3, reruns_delay=1)
def test_example():
import random
assert random.choice([True, False])
并行测试
如果项目中的测试用例非常多,pytest 串行执行耗费的总时间很长,这将会成为 DevOps 流水线中阻塞的步骤。可以考虑使用 pytest-xdist 插件,实现测试用例并行执行,提高执行速度
安装 pytest-xdist 插件之后,就可以在 pytest 命令中传入 -n 参数,指定测试运行在多个进程上
# pytest-xdist 自动检测系统 CPU 核心数
pytest -n auto
# 自定义
pytest -n 3
使用 conftest.py 管理 fixtures
一般而言,在 conftest.py 中定义 fixture,可以被所在目录下的多个测试文件使用。此外,可以在嵌套的目录中分别定义 conftest.py,如果有这些 conftest.py 中定义的 fixture 中有同名的,则子目录中的 fixture 会覆盖父级目录中同名的 fixture
示例文件结构
tests
├── __init__.py
├── conftest.py
├── module-a
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_01.py
│ ├── test_02.py
│ └── test_03.py
└── module-b
├── __init__.py
├── conftest.py
├── test_04.py
└── test_05.py
假设 tests/conftest.py 的内容如下
import pytest
@pytest.fixture
def jewels():
return ["ruby", "sapphire", "agate"]
@pytest.fixture
def my_configs():
return {"name": "jewels"}
假设 tests/module-a/conftest.py 的内容如下
import pytest
@pytest.fixture
def my_configs():
return {"name": "jewels-a", "score": 100}
module-a 下的 my_configs fixture 会覆盖 tests/conftest.py 中定义的 my_configs,test_01.py 可以直接使用 jewels
def test_jewels(jewels, my_configs):
assert len(jewels) == 3
assert my_configs["name"] == "jewels-1"