我们先了解一下软件的测试,软件测试一般分为如下四个方面
- 单元测试:称模块测试,针对软件设计中的最小单位——程序模块,进行正确性检查的测试工作
- 集成测试:称组装测试,通常在单元测试的基础上,将所有程序模块进行有序的、递增测试,重点测试不同模块的接口部分
- 系统测试:将整个软件系统看成一个整体进行测试,包括对功能、性能以及软件所运行的软硬件环境进行测试
- 验收测试:指按照项目任务书或合同、供需双方约定的验收依据文档进行的对整个系统的测试与评审,决定是否接收或拒收系统
pytest最初是作为一个单元测试框架而设计的,但它也提供了许多功能,使其能够进行更广泛的测试,包括集成测试,系统测试,他是一个非常成熟的全功能的python 测试框架。 它通过收集测试函数和模块,并提供丰富的断言库来简化测试的编写和运行,是一个非常成熟且功能强大的 Python 测试框架,具有以下几个特点:
- 简单灵活:Pytest 容易上手,且具有灵活性。
- 支持参数化:您可以轻松地为测试用例提供不同的参数。
- 全功能:Pytest 不仅支持简单的单元测试,还可以处理复杂的功能测试。您甚至可以使用它来进行自动化测试,如 Selenium 或 Appium 测试,以及接口自动化测试(结合 Pytest 和 Requests 库)。
- 丰富的插件生态:Pytest 有许多第三方插件,您还可以自定义扩展。一些常用的插件包括:
pytest-selenium
:集成 Selenium。pytest-html
:生成HTML测试报告。pytest-rerunfailures
:在失败的情况下重复执行测试用例。pytest-xdist
:支持多 CPU 分发。
- 与 Jenkins 集成良好。
- 支持 Allure 报告框架。
本文将基于测试需求简单介绍pytest的用法。
Pytest安装
# 安装pytest:
pip install pytest
# 升级pytest
pip install -U pytest
# 查看pytest版本
pytest --version
# 查看已安装包列表
pip list
# 查看pytest帮助文档
pytest -h
# 安装第三方插件
pip install pytest-sugar
pip install pytest-rerunfailures
pip install pytest-xdist
pip install pytest-assume
pip install pytest-html
##linux终端会出现问题 pytest:找不到命令
方法1:添加环境变量到 ~/.bashrc文件
pip show pytest #查找位置命令1 find / -name pytest 2>/dev/null #查找可执行文件位置命令2
添加所得到路径到
~/.bashrc
的 PATH 中#pytest export PATH=/home/tom/.local/bin:$PATH #~/.bashrc 中添加上面两行!
source ~/.bashrc
方法2:直接使用 Python 执行 pytest
另外一种可行的方法是直接通过
python
来运行pytest
,而不是依赖于命令行程序。例如:
python -m pytest --version
Pytest使用
命名规则
# 首先在使用pytest 时我们的模块名通常是以test开头或者test结尾,也可以修改配置文件,自定义命名规则
# test_*.py 或 *_test.py
test_demo1
demo2_test
# 模块中的类名要以Test 开始且不能有init 方法
class TestDemo1:
class TestLogin:
# 类中定义的测试方法名要以test_开头
test_demo1(self)
test_demo2(self)
# 测试用例
class test_one:
def test_demo1(self):
print("测试用例1")
def test_demo2(self):
print("测试用例2")
Pytest 参数
pytest支持很多参数,可以通过help命令查看
pytest -help
我们在这里列出来常用的几个:
-m: 用表达式指定多个标记名。 pytest 提供了一个装饰器 @pytest.mark.xxx,用于标记测试并分组(xxx是你定义的分组名),以便你快速选中并运行,各个分组直接用 and、or 来分割。
-v: 运行时输出更详细的用例执行信息 不使用-v参数,运行时不会显示运行的具体测试用例名称;使用-v参数,会在 console 里打印出具体哪条测试用例被运行。
-q: 类似 unittest 里的 verbosity,用来简化运行输出信息。 使用 -q 运行测试用例,仅仅显示很简单的运行信息, 例如:
.s.. [100%]
3 passed, 1 skipped in 9.60s
-k: 可以通过表达式运行指定的测试用例。 它是一种模糊匹配,用 and 或 or 区分各个关键字,匹配范围有文件名、类名、函数名。
-x: 出现一条测试用例失败就退出测试。 在调试时,这个功能非常有用。当出现测试失败时,停止运行后续的测试。
-s: 显示print内容 在运行测试脚本时,为了调试或打印一些内容,我们会在代码中加一些print内容,但是在运行pytest时,这些内容不会显示出来。如果带上-s,就可以显示了。
pytest test_se.py -s
Pytest 选择测试用例执行
在 Pytest 中,您可以按照测试文件夹、测试文件、测试类和测试方法的不同维度来选择执行测试用例。
- 按照测试文件夹执行
# 执行所有当前文件夹及子文件夹下的所有测试用例
pytest .
# 执行跟当前文件夹同级的tests文件夹及子文件夹下的所有测试用例
pytest ../tests
# 按照测试文件执行
# 运行test_se.py下的所有的测试用例
pytest test_se.py
# 按照测试类执行,必须以如下格式:
pytest 文件名 .py:: 测试类,其中“::”是分隔符,用于分割测试module和测试类。
# 运行test_se.py文件下的,类名是TestSE下的所有测试用例
pytest test_se.py::TestSE
# 测试方法执行,必须以如下格式:
pytest 文件名 .py:: 测试类 :: 测试方法,其中 “::” 是分隔符,用于分割测试module、测试类,以及测试方法。
# 运行test_se.py文件下的,类名是TestSE下的,名字为test_get_new_message的测试用例
pytest test_se.py::TestSE::test_get_new_message
# 以上选择测试用例的方法均是在**命令行**,如果您想直接在测试程序里执行可以直接在main函数中**调用pytest.main()**,其格式为:
pytest.main([模块.py::类::方法])
此外,Pytest 还支持控制测试用例执行的多种方式,例如过滤执行、多进程运行、重试运行等。
使用Pytest编写验证
- 在测试过程中,我们使用之前验证过的加法器,进入Adder文件夹,在picker_out_adder目录下新建一个test_adder.py文件,内容如下:
# 导入测试模块和所需的库
from UT_Adder import *
import pytest
import ctypes
import random
# 使用 pytest fixture 来初始化和清理资源
@pytest.fixture
def adder():
# 创建 DUTAdder 实例,加载动态链接库
dut = DUTAdder()
# 执行一次时钟步进,准备 DUT
dut.Step(1)
# yield 语句之后的代码会在测试结束后执行,用于清理资源
yield dut
# 清理DUT资源,并生成测试覆盖率报告和波形
dut.Finish()
class TestFullAdder:
# 将 full_adder 定义为静态方法,因为它不依赖于类实例
@staticmethod
def full_adder(a, b, cin):
cin = cin & 0b1
Sum = ctypes.c_uint64(a).value
Sum += ctypes.c_uint64(b).value + cin
Cout = (Sum >> 64) & 0b1
Sum &= 0xffffffffffffffff
return Sum, Cout
# 使用 pytest.mark.usefixtures 装饰器指定使用的 fixture
@pytest.mark.usefixtures("adder")
# 定义测试方法,adder 参数由 pytest 通过 fixture 注入
def test_adder(self, adder):
# 进行多次随机测试
for _ in range(114514):
# 随机生成 64 位的 a 和 b,以及 1 位的进位 cin
a = random.getrandbits(64)
b = random.getrandbits(64)
cin = random.getrandbits(1)
# 设置 DUT 的输入
adder.a.value = a
adder.b.value = b
adder.cin.value = cin
# 执行一次时钟步进
adder.Step(1)
# 使用静态方法计算预期结果
sum, cout = self.full_adder(a, b, cin)
# 断言 DUT 的输出与预期结果相同
assert sum == adder.sum.value
assert cout == adder.cout.value
if __name__ == "__main__":
pytest.main(['-v', 'test_adder.py::TestFullAdder'])
- 运行测试之后输出如下:
collected 1 item
test_adder.py ✓ 100% ██████████
Results (4.33s):
测试成功表明,在经过114514次循环之后,我们的设备暂时没有发现bug。然而,使用多次循环的随机数生成测试用例会消耗大量资源,并且这些随机生成的测试用例可能无法有效覆盖所有边界条件。在下一部分,我们将介绍一种更有效的测试用例生成方法。
Pytest 使用手册 — learning-pytest 1.0 文档https://learning-pytest.readthedocs.io/zh/latest/
第一个测试函数
Pytest 使用 Python 的 assert
进行条件判断,最简单的测试函数如:
# test1.py
def test_passing():
assert (1, 2, 3) == (1, 2, 3)
运行测试函数
使用命令 pytest
运行测试函数:
$ pytest tests/test1.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\test1.py . [100%]
========================== 1 passed in 0.09 seconds ===========================
pytest 使用
.
标识测试成功(PASSED
)。可以使用
-v
选项,显示测试的详细信息。使用
pytest -h
查看 pytest 的所有选项。
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0 -- c:\anaconda3\python.exe
cachedir: .pytest_cache
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests/test1.py::test_passing PASSED [100%]
========================== 1 passed in 0.03 seconds ===========================
测试失败
下面是一个失败的测试函数:
# test2.py
def test_failing():
assert (1, 2, 3) == (3, 2, 1)
运行结果为:
$ pytest tests/test2.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\test2.py F [100%]
================================== FAILURES ===================================
________________________________ test_failing _________________________________
def test_failing():
> 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
tests\test2.py:2: AssertionError
========================== 1 failed in 0.19 seconds ===========================
pytest 使用 F
标识测试失败(FAILED
)。
pytest 对失败的测试给出了非常人性化的提示。
断言
在 pytest 中,assert
是编写测试的最基础工具。如:
assert a == b
assert a <= b
具体的 assert
语法参考 The assert statement 。
捕获异常
在测试过程中,经常需要测试是否如期抛出预期的异常,以确定异常处理模块生效。在 pytest 中使用 pytest.raises()
进行异常捕获:
# test_raises.py
def test_raises():
with pytest.raises(TypeError) as e:
connect('localhost', '6379')
exec_msg = e.value.args[0]
assert exec_msg == 'port type must be int'
运行结果如下:
$ pytest test_raises.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\test-function\test_raise.py . [100%]
========================== 1 passed in 0.07 seconds ===========================
标记函数
Pytest 查找测试策略
默认情况下,pytest 会递归查找当前目录下所有以 test
开始或结尾的 Python 脚本,并执行文件内的所有以 test
开始或结束的函数和方法。
对于下面脚本:
# test_no_mark.py
def test_func1():
assert 1 == 1
def test_func2():
assert 1 != 1
直接执行测试脚本会同时执行所有测试函数:
$ pytest tests/test-function/test_no_mark.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items
tests\test-function\test_no_mark.py .F [100%]
================================== FAILURES ===================================
_________________________________ test_func2 __________________________________
def test_func2():
> assert 1 != 1
E assert 1 != 1
tests\test-function\test_no_mark.py:6: AssertionError
===================== 1 failed, 1 passed in 0.07 seconds ======================
标记测试函数
由于某种原因(如 test_func2
的功能尚未开发完成),我们只想执行指定的测试函数。在 pytest 中有几种方式可以解决:
第一种,显式指定函数名,通过 ::
标记。
$ pytest tests/test-function/test_no_mark.py::test_func1
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\test-function\test_no_mark.py . [100%]
========================== 1 passed in 0.02 seconds ===========================
第二种,使用模糊匹配,使用 -k
选项标识。
$ pytest -k func1 tests/test-function/test_no_mark.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items / 1 deselected
tests\test-function\test_no_mark.py . [100%]
=================== 1 passed, 1 deselected in 0.03 seconds ====================
import pytest #源代码需要添加这行
注解
以上两种方法,第一种一次只能指定一个测试函数,当要进行批量测试时无能为力;第二种方法可以批量操作,但需要所有测试的函数名包含相同的模式,也不方便。
第三种,使用 pytest.mark
在函数上进行标记。
带标记的测试函数如:
# test_with_mark.py
@pytest.mark.finished
def test_func1():
assert 1 == 1
@pytest.mark.unfinished
def test_func2():
assert 1 != 1
测试时使用 -m
选择标记的测试函数:
$ pytest -m finished tests/test-function/test_with_mark.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items / 1 deselected
tests\test-function\test_with_mark.py . [100%]
=================== 1 passed, 1 deselected in 0.10 seconds ====================
使用 mark,我们可以给每个函数打上不同的标记,测试时指定就可以允许所有被标记的函数。
注解
一个函数可以打多个标记;多个函数也可以打相同的标记。
运行测试时使用 -m
选项可以加上逻辑,如:
$ pytest -m "finished and commit"
$ pytest -m "finished and not merged"
跳过测试
上一节提到 pytest 使用标记过滤测试函数,所以对于那些尚未开发完成的测试,最好的处理方式就是略过而不执行测试。
按正向的思路,我们只要通过标记指定要测试的就可以解决这个问题;但有时候的处境是我们能进行反向的操作才是最好的解决途径,即通过标记指定要跳过的测试。
Pytest 使用特定的标记 pytest.mark.skip
完美的解决了这个问题。
# test_skip.py
@pytest.mark.skip(reason='out-of-date api')
def test_connect():
pass
执行结果可以看到该测试已被忽略:
$ pytest tests/test-function/test_skip.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\test-function\test_skip.py s [100%]
========================== 1 skipped in 0.13 seconds ==========================
pytest 使用 s
表示测试被跳过(SKIPPED
)。
Pytest 还支持使用 pytest.mark.skipif
为测试函数指定被忽略的条件。
@pytest.mark.skipif(conn.__version__ < '0.2.0',
reason='not supported until v0.2.0')
def test_api():
pass
1. 创建测试文件
将上述代码保存到一个 Python 测试文件中,比如
test_conditional_skip.py
,确保您有一个名为conn
的模块,或者您可以创建一个简单的conn.py
文件来模拟版本属性:# conn.py 示例 __version__ = '0.1.0' # 您可以修改这个版本号来测试不同情况
2. 创建一个测试文件
在同一目录下创建
test_conditional_skip.py
文件并写入您的代码:import pytest import conn @pytest.mark.skipif(conn.__version__ < '0.2.0', reason='not supported until v0.2.0') def test_api(): pass
3. 运行测试
打开终端,导航到包含您的测试文件和
conn.py
的目录,然后运行:
pytest test_conditional_skip.py
4. 查看测试结果
根据
conn.__version__
的值,您会看到不同的输出:
如果
conn.__version__
的值是'0.1.0'
,您应该会看到类似以下内容的输出,其中表明测试被跳过:============================= test session starts ============================= ... collected 1 item test_conditional_skip.py skipped: not supported until v0.2.0
如果您将
conn.__version__
修改为'0.2.0'
或更高版本,然后再次运行测试,您会看到您的测试被运行:... collected 1 item test_conditional_skip.py . # 点表示测试通过
预见的错误
如果我们事先知道测试函数会执行失败,但又不想直接跳过,而是希望显示的提示。
Pytest 使用 pytest.mark.xfail
实现预见错误功能:
# test_xfail.py
@pytest.mark.xfail(gen.__version__ < '0.2.0',
reason='not supported until v0.2.0')
def test_api():
id_1 = gen.unique_id()
id_2 = gen.unique_id()
assert id_1 != id_2
执行结果:
$ pytest tests/test-function/test_xfail.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\test-function\test_xfail.py x [100%]
========================== 1 xfailed in 0.12 seconds ==========================
pytest 使用
x
表示预见的失败(XFAIL
)。如果预见的是失败,但实际运行测试却成功通过,pytest 使用
X
进行标记(XPASS
)。
参数化
当对一个测试函数进行测试时,通常会给函数传递多组参数。比如测试账号登陆,我们需要模拟各种千奇百怪的账号密码。
当然,我们可以把这些参数写在测试函数内部进行遍历。不过虽然参数众多,但仍然是一个测试,当某组参数导致断言失败,测试也就终止了。
通过异常捕获,我们可以保证程所有参数完整执行,但要分析测试结果就需要做不少额外的工作。
在 pytest 中,我们有更好的解决方法,就是参数化测试,即每组参数都独立执行一次测试。使用的工具就是 pytest.mark.parametrize(argnames, argvalues)
。
这里是一个密码长度的测试函数,其中参数名为 passwd
,其可选列表包含三个值:
# test_parametrize.py
@pytest.mark.parametrize('passwd',
['123456',
'abcdefdfs',
'as52345fasdf4'])
def test_passwd_length(passwd):
assert len(passwd) >= 8
运行可知执行了三次测试:
$ pytest tests/test-function/test_parametrize.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 3 items
tests\test-function\test_parametrize.py F.. [100%]
================================== FAILURES ===================================
再看一个多参数的例子,用于校验用户密码:
# test_parametrize.py
@pytest.mark.parametrize('user, passwd',
[('jack', 'abcdefgh'),
('tom', 'a123456a')])
def test_passwd_md5(user, passwd):
db = {
'jack': 'e8dc4081b13434b45189a720b77b6818',
'tom': '1702a132e769a623c1adb78353fc9503'
}
import hashlib
assert hashlib.md5(passwd.encode()).hexdigest() == db[user]
使用 -v
执行测试
$ pytest -v tests/test-function/test_parametrize.py::test_passwd_md5
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0 -- c:\anaconda3\python.exe
cachedir: .pytest_cache
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items
tests/test-function/test_parametrize.py::test_passwd_md5[jack-abcdefgh] PASSED [ 50%]
tests/test-function/test_parametrize.py::test_passwd_md5[tom-a123456a] PASSED [100%]
========================== 2 passed in 0.04 seconds ===========================
错误信息 `ERROR: found no collectors for /home/tom/pytest/test_parametrize.py::test_passwd_md5` 表示 `pytest` 找不到要运行的测试用例。这通常发生在以下几种情况下:
### 1. **函数名称不符合 pytest 的约定**
`pytest` 识别测试函数的标准是以 `test_` 开头。确保您的测试函数 `test_passwd_md5` 确实以 `test_` 开头。如果函数名称拼写错误或没有遵循这一约定,pytest 就不会将其视为有效的测试用例。
例如,确保您有如下定义:
```python
def test_passwd_md5():
# 测试逻辑
assert True # 替换为您的实际测试断言
```### 2. **文件或目录名称的约定**
确保测试文件名以 `test_` 开头或以 `_test.py` 结尾,例如,您应该有一个文件名为 `test_parametrize.py` 或 `parametrize_test.py`。
### 3. **文件内容包含有效的测试**
请确保 `test_parametrize.py` 文件实际上包含可识别的测试函数。有时候,文件就是存在,但没有定义有效的测试函数。
### 4. **使用 `pytest` 的收集机制**
如果您在运行 `pytest` 时仅指定文件名称,它会查找所有以 `test_` 开头的函数。确认文件内的测试函数能够被正常识别并符合所有命名规范。
### 5. **检查 pytest 的输出**
运行以下命令来诊断可收集的测试项:
```bash
pytest --collect-only
```这将列出 `pytest` 检测到的所有测试用例,如果您的测试函数未被列出,说明这些函数未被正确识别。
### 6. **确保 pytest 安装无误**
有时候,环境或安装问题也会导致这种情况。您可以尝试重新安装 `pytest`:
```bash
pip uninstall pytest
pip install pytest
```### 7. **验证代码是否有语法错误或导入问题**
如果有语法错误或者导入问题,可能导致 `pytest` 无法收集测试项。确保代码中没有错误。可以在 Python 解释器中单独运行文件确认从头到尾没有问题:
```bash
python test_parametrize.py
```### 总结
检查测试函数是否命名正确、文件名格式是否合法,以及文件内部是否定义了有效的测试函数。如果上述方法都不能解决问题,请检查输出的内容,提供更多细节,这样可以帮助进一步诊断问题。
///********************上面的没用!!1***********
# test_parametrize.py import pytest import hashlib @pytest.mark.parametrize('user, passwd', [('jack', 'abcdefgh'), ('tom', 'a123456a')]) def test_passwd_md5(user, passwd): db = { 'jack': 'e8dc4081b13434b45189a720b77b6818', 'tom': '1702a132e769a623c1adb78353fc9503' } assert hashlib.md5(passwd.encode()).hexdigest() == db[user]
如果觉得每组测试的默认参数显示不清晰,我们可以使用 pytest.param
的 id
参数进行自定义。
# test_parametrize.py
@pytest.mark.parametrize('user, passwd',
[pytest.param('jack', 'abcdefgh', id='User<Jack>'),
pytest.param('tom', 'a123456a', id='User<Tom>')])
def test_passwd_md5_id(user, passwd):
db = {
'jack': 'e8dc4081b13434b45189a720b77b6818',
'tom': '1702a132e769a623c1adb78353fc9503'
}
import hashlib
assert hashlib.md5(passwd.encode()).hexdigest() == db[user]
现在的执行结果为:
$ pytest -v tests/test-function/test_parametrize.py::test_passwd_md5_id
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0 -- c:\anaconda3\python.exe
cachedir: .pytest_cache
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items
tests/test-function/test_parametrize.py::test_passwd_md5_id[User<Jack>] PASSED [ 50%]
tests/test-function/test_parametrize.py::test_passwd_md5_id[User<Tom>] PASSED [100%]
========================== 2 passed in 0.07 seconds ===========================
什么是固件
固件(Fixture)是一些函数,pytest 会在执行测试函数之前(或之后)加载运行它们。
我们可以利用固件做任何事情,其中最常见的可能就是数据库的初始连接和最后关闭操作。
Pytest 使用 pytest.fixture()
定义固件,下面是最简单的固件,只返回北京邮编:
# test_postcode.py
@pytest.fixture()
def postcode():
return '010'
def test_postcode(postcode):
assert postcode == '010'
固件可以直接定义在各测试脚本中,就像上面的例子。更多时候,我们希望一个固件可以在更大程度上复用,这就需要对固件进行集中管理。Pytest 使用文件 conftest.py
集中管理固件。
在复杂的项目中,可以在不同的目录层级定义
conftest.py
,其作用域为其所在的目录和子目录。不要自己显式调用
conftest.py
,pytest 会自动调用,可以把 conftest 当做插件来理解。
预处理和后处理
很多时候需要在测试前进行预处理(如新建数据库连接),并在测试完成进行清理(关闭数据库连接)。
当有大量重复的这类操作,最佳实践是使用固件来自动化所有预处理和后处理。
Pytest 使用 yield
关键词将固件分为两部分,yield
之前的代码属于预处理,会在测试前执行;yield
之后的代码属于后处理,将在测试完成后执行。
以下测试模拟数据库查询,使用固件来模拟数据库的连接关闭:
# test_db.py
@pytest.fixture()
def db():
print('Connection successful')
yield
print('Connection closed')
def search_user(user_id):
d = {
'001': 'xiaoming'
}
return d[user_id]
def test_search(db):
assert search_user('001') == 'xiaoming'
执行时使用 -s
阻止消息被吞:
$ pytest -s tests/fixture/test_db.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\fixture\test_db.py Connection successful
.Connection closed
========================== 1 passed in 0.02 seconds ===========================
可以看到在测试成功的 .
标识前后有数据库的连接和关闭操作。
如果想更细的跟踪固件执行,可以使用 --setup-show
选项:
$ pytest --setup-show tests/fixture/test_db.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\fixture\test_db.py
SETUP F db
tests/fixture/test_db.py::test_search (fixtures used: db).
TEARDOWN F db
========================== 1 passed in 0.03 seconds ===========================
作用域
固件的作用是为了抽离出重复的工作和方便复用,为了更精细化控制固件(比如只想对数据库访问测试脚本使用自动连接关闭的固件),pytest 使用作用域来进行指定固件的使用范围。
在定义固件时,通过 scope
参数声明作用域,可选项有:
function
: 函数级,每个测试函数都会执行一次固件;class
: 类级别,每个测试类执行一次,所有方法都可以使用;module
: 模块级,每个模块执行一次,模块内函数和方法都可使用;session
: 会话级,一次测试只执行一次,所有被找到的函数和方法都可用。
默认的作用域为 function
。
@pytest.fixture(scope='function')
def func_scope():
pass
@pytest.fixture(scope='module')
def mod_scope():
pass
@pytest.fixture(scope='session')
def sess_scope():
pass
@pytest.fixture(scope='class')
def class_scope():
pass
最简单使用固件方式是作为测试函数参数:
# test_scope.py
def test_multi_scope(sess_scope, mod_scope, func_scope):
pass
执行结果如下,可以清楚看到各固件的作用域和执行顺序:
$ pytest --setup-show tests/fixture/test_scope.py::test_multi_scope
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\fixture\test_scope.py
SETUP S sess_scope
SETUP M mod_scope
SETUP F func_scope
tests/fixture/test_scope.py::test_multi_scope (fixtures used: func_scope, mod_scope, sess_scope).
TEARDOWN F func_scope
TEARDOWN M mod_scope
TEARDOWN S sess_scope
========================== 1 passed in 0.10 seconds ===========================
# test_scope.py
import pytest
@pytest.fixture(scope='function')
def func_scope():
pass
@pytest.fixture(scope='module')
def mod_scope():
pass
@pytest.fixture(scope='session')
def sess_scope():
pass
@pytest.fixture(scope='class')
def class_scope():
pass
def test_multi_scope(sess_scope, mod_scope, func_scope):
pass
对于类使用作用域,需要使用 pytest.mark.usefixtures
(对函数和方法也适用):
# test_scope.py
@pytest.mark.usefixtures('class_scope')
class TestClassScope:
def test_1(self):
pass
def test_2(self):
pass
执行结果如下,可见所有测试函数都在固件作用范围内:
$ pytest --setup-show tests/fixture/test_scope.py::TestClassScope
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items
tests\fixture\test_scope.py
SETUP C class_scope
tests/fixture/test_scope.py::TestClassScope::()::test_1 (fixtures used: class_scope).
tests/fixture/test_scope.py::TestClassScope::()::test_2 (fixtures used: class_scope).
TEARDOWN C class_scope
========================== 2 passed in 0.03 seconds ===========================
# test_scope1.py
import pytest
@pytest.fixture(scope='class')
def class_scope():
pass
@pytest.mark.usefixtures('class_scope')
class TestClassScope:
def test_1(self):
pass
def test_2(self):
pass
自动执行
目前为止,所有固件的使用都是手动指定,或者作为参数,或者使用 usefixtures
。
如果我们想让固件自动执行,可以在定义时指定 autouse
参数。
下面是两个自动计时固件,一个用于统计每个函数运行时间(function
作用域),一个用于计算测试总耗时(session
作用域):
# test_autouse.py
DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
@pytest.fixture(scope='session', autouse=True)
def timer_session_scope():
start = time.time()
print('\nstart: {}'.format(time.strftime(DATE_FORMAT, time.localtime(start))))
yield
finished = time.time()
print('finished: {}'.format(time.strftime(DATE_FORMAT, time.localtime(finished))))
print('Total time cost: {:.3f}s'.format(finished - start))
@pytest.fixture(autouse=True)
def timer_function_scope():
start = time.time()
yield
print(' Time cost: {:.3f}s'.format(time.time() - start))
注意下面的两个测试函数并都没有显式使用固件:
def test_1():
time.sleep(1)
def test_2():
time.sleep(2)
执行测试可看到,固件自动执行并完成计时任务:
$ pytest -s tests/fixture/test_autouse.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items
tests\fixture\test_autouse.py
start: 2018-06-12 10:16:27
. Time cost: 1.003s.
. Time cost: 2.003s.
finished: 2018-06-12 10:16:30
Total time cost: 3.016s.
========================== 2 passed in 3.11 seconds ===========================
错误信息 `NameError: name 'time' is not defined` 表示在您的测试代码中使用了 `time` 模块,但未导入该模块。Python 在执行时找不到 `time` 这个名称,因此抛出了 `NameError`。
### 解决方案
要解决这个问题,您需要在 `test_autouse.py` 文件的开头添加对 `time` 模块的导入。请确保您的代码类似于以下示例:
```python
import time
import pytestdef test_1():
start_time = time.time()
# 在这里执行一些操作
duration = time.time() - start_time
assert duration < 1 # 示例断言def test_2():
start_time = time.time()
# 在这里执行一些操作
duration = time.time() - start_time
assert duration < 1 # 示例断言
```### 检查步骤
1. **导入时间模块**:确保在文件顶部有 `import time`。
2. **检查其他模块**:如果使用其他模块(如 `pytest`),也要确保它们已被导入。
3. **运行测试**:保存更改后,重新运行测试以确认问题是否已解决。### 总结
只需在文件的开头添加 `import time`,就可以解决此问题。
# test_autouse.py
import pytest
import time
DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
@pytest.fixture(scope='session', autouse=True)
def timer_session_scope():
start = time.time()
print('\nstart: {}'.format(time.strftime(DATE_FORMAT, time.localtime(start))))
yield
finished = time.time()
print('finished: {}'.format(time.strftime(DATE_FORMAT, time.localtime(finished))))
print('Total time cost: {:.3f}s'.format(finished - start))
@pytest.fixture(autouse=True)
def timer_function_scope():
start = time.time()
yield
print(' Time cost: {:.3f}s'.format(time.time() - start))
def test_1():
time.sleep(1)
def test_2():
time.sleep(2)
重命名
固件的名称默认为定义时的函数名,如果不想使用默认,可以通过 name
选项指定名称:
# test_rename.py
@pytest.fixture(name='age')
def calculate_average_age():
return 28
def test_age(age):
assert age == 28
参数化
在“第二部分 测试函数”中,介绍了函数的参数化测试:
# test-function/test_parametrize.py
@pytest.mark.parametrize('passwd',
['123456',
'abcdefdfs',
'as52345fasdf4'])
def test_passwd_length(passwd):
assert len(passwd) >= 8
因为固件也是函数,我们同样可以对固件进行参数化。在什么情况下需要对固件参数化?
假设现在有一批 API 需要测试对不同数据库的支持情况(对所有数据库进行相同操作),最简单的方法就是针对每个数据库编写一个测试用例,但这包含大量重复代码,如数据库的连接、关闭,查询等。
进一步,可以使用固件抽离出数据库的通用操作,每个 API 都能复用这些数据库固件,同时可维护性也得到提升。
更进一步,可以继续将这些固件合并为一个,而通过参数控制连接到不同的数据库。这就需要使用固件参数化来实现。固件参数化需要使用 pytest 内置的固件 request
,并通过 request.param
获取参数。
@pytest.fixture(params=[
('redis', '6379'),
('elasticsearch', '9200')
])
def param(request):
return request.param
@pytest.fixture(autouse=True)
def db(param):
print('\nSucceed to connect %s:%s' % param)
yield
print('\nSucceed to close %s:%s' % param)
def test_api():
assert 1 == 1
执行结果:
$ pytest -s tests/fixture/test_parametrize.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items
tests\fixture\test_parametrize.py
Succeed to connect redis:6379
.
Succeed to close redis:6379
Succeed to connect elasticsearch:9200
.
Succeed to close elasticsearch:9200
========================== 2 passed in 0.10 seconds ===========================
# test_parametrize2.py
import pytest
@pytest.fixture(params=[
('redis', '6379'),
('elasticsearch', '9200')
])
def param(request):
return request.param
@pytest.fixture(autouse=True)
def db(param):
print('\nSucceed to connect %s:%s' % param)
yield
print('\nSucceed to close %s:%s' % param)
def test_api():
assert 1 == 1
与函数参数化使用
@pytest.mark.parametrize
不同,固件在定义时使用params
参数进行参数化。固件参数化依赖于内置固件
request
及其属性param
。
内置固件
tmpdir & tmpdir_factory
用于临时文件和目录管理,默认会在测试结束时删除。
tmpdir
只有function
作用域,只能在函数内使用。使用
tmpdir.mkdir()
创建临时目录,tmpdir.join()
创建临时文件(或者使用创建的目录)。
def test_tmpdir(tmpdir):
a_dir = tmpdir.mkdir('mytmpdir')
a_file = a_dir.join('tmpfile.txt')
a_file.write('hello, pytest!')
assert a_file.read() == 'hello, pytest!'
tmpdir_factory
可以在所有作用域使用,包括 function, class, module, session
。
@pytest.fixture(scope='module')
def my_tmpdir_factory(tmpdir_factory):
a_dir = tmpdir_factory.mktemp('mytmpdir')
a_file = a_dir.join('tmpfile.txt')
a_file.write('hello, pytest!')
return a_file
pytestconfig
使用 pytestconfig
,可以很方便的读取命令行参数和配置文件。
下面示例演示命令行参数解析:首先在 conftest.py
中使用函数 pytest_addoption
(特定的 hook function ):
# conftest.py
def pytest_addoption(parser):
parser.addoption('--host', action='store',
help='host of db')
parser.addoption('--port', action='store', default='8888',
help='port of db')
然后就可以在测试函数中通过 pytestconfig
获取命令行参数:
# test_config.py
def test_option1(pytestconfig):
print('host: %s' % pytestconfig.getoption('host'))
print('port: %s' % pytestconfig.getoption('port'))
pytestconfig
其实是 request.config
的快捷方式,所以也可以自定义固件实现命令行参数读取。
# conftest.py
def pytest_addoption(parser):
parser.addoption('--host', action='store',
help='host of db')
parser.addoption('--port', action='store', default='8888',
help='port of db')
@pytest.fixture
def config(request):
return request.config
# test_config.py
def test_option2(config):
print('host: %s' % config.getoption('host'))
print('port: %s' % config.getoption('port'))
执行结果:
$ pytest -s --host=localhost tests/fixture/test_config.py::test_option2
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item
tests\fixture\test_config.py host: localhost
port: 8888
.
========================== 1 passed in 0.06 seconds ===========================
错误信息 `NameError: name 'pytest' is not defined` 表示在您的 `conftest.py` 文件中使用了 `pytest` 模块,但未导入该模块。这通常发生在您尝试使用 `pytest` 的功能(如装置、标记等)时,但忘记在文件顶部添加相应的导入语句。
### 解决方案
要解决这个问题,您需要在 `conftest.py` 文件的开头添加对 `pytest` 模块的导入。确保您的代码类似于以下示例:
```python
import pytest@pytest.fixture
def sample_fixture():
# 设置代码
yield
# 清理代码
```### 检查步骤
1. **导入 pytest**:确保在 `conftest.py` 文件的顶部有 `import pytest`。
2. **检查其他模块**:如果您在 `conftest.py` 中使用了其他模块,也要确保它们已被导入。
3. **运行测试**:保存更改后,重新运行测试以确认问题是否已解决。### 总结
只需在 `conftest.py` 文件的开头添加 `import pytest`,就可以解决此问题。
capsys
capsys
用于捕获 stdout
和 stderr
的内容,并临时关闭系统输出。
# test_capsys.py 教程的
def ping(output):
print('Pong...', file=output)
def test_stdout(capsys):
ping(sys.stdout)
out, err = capsys.readouterr()
assert out == 'Pong...\n'
assert err == ''
def test_stderr(capsys):
ping(sys.stderr)
out, err = capsys.readouterr()
assert out == ''
assert err == 'Pong...\n'
# test_capsys.py
import pytest
import sys
def ping(output):
print('Pong...', file=output)
def test_stdout(capsys):
ping(sys.stdout)
out, err = capsys.readouterr()
assert out == 'Pong...\n'
assert err == ''
def test_stderr(capsys):
ping(sys.stderr)
out, err = capsys.readouterr()
assert out == ''
assert err == 'Pong...\n'
monkeypatch
monkeypath
用于运行时动态修改类或模块。
In Python, the term monkey patch only refers to dynamic modifications of a class or module at runtime, motivated by the intent to patch existing third-party code as a workaround to a bug or feature which does not act as you desire.
在Python中,monkey patch一词仅指在运行时对类或模块进行动态修改,其动机是为了修补现有的第三方代码,作为对不符合您期望的错误或功能的解决方案。
一个简单的 monkeypatch 如:
from SomeOtherProduct.SomeModule import SomeClass
def speak(self):
return "ook ook eee eee eee!"
SomeClass.speak = speak
Pytest 内置 monkeypatch
提供的函数有:
setattr(target, name, value, raising=True)
,设置属性;delattr(target, name, raising=True)
,删除属性;setitem(dic, name, value)
,字典添加元素;delitem(dic, name, raising=True)
,字典删除元素;setenv(name, value, prepend=None)
,设置环境变量;delenv(name, raising=True)
,删除环境变量;syspath_prepend(path)
,添加系统路径;chdir(path)
,切换目录。
其中
raising
用于通知 pytest 在元素不存在时是否抛出异常;prepend
如果设置,环境变量将变为value+prepend+<old value>
。
下面使用保存配置文件示例说明 monkeypatch 的作用和使用。
假设我们需要切换某个服务到国内科大源以加速,有以下脚本用于修改配置文件 .conf.json
:
# test_monkeypatch.py
def dump_config(config):
path = os.path.expanduser('~/.conf.json')
with open(path, 'w', encoding='utf-8') as wr:
json.dump(config, wr, indent=4)
def test_config():
dump_config(config)
path = os.path.expanduser('~/.conf.json')
expected = json.load(open(path, 'r', encoding='utf-8'))
assert expected == config
似乎测试正常执行完全没有问题,但如果我们的家目录下恰好有这个配置文件并且维护了许多配置,运行测试将会覆盖原有配置,这太可怕了!
所以我们需要修改测试,最好能在临时目录里完成。但程序已经写死了文件路径,怎么办?
这种在运行时控制程序的功能就需要 monkeypatch 来实现,下面在测试过程中修改了环境变量:
# test_monkeypatch.py
def test_config_monkeypatch(tmpdir, monkeypatch):
monkeypatch.setenv('HOME', tmpdir.mkdir('home'))
dump_config(config)
path = os.path.expanduser('~/.conf.json')
expected = json.load(open(path, 'r', encoding='utf-8'))
assert expected == config
现在测试会来临时目录中执行,但环境变量可能对系统有依赖,所以更好的解决方法能自己控制路径中 ~
的替换,这次通过改变 os.path.expanduser
的行为来实现:
# test_monkeypatch.py
def test_config_monkeypatch2(tmpdir, monkeypatch):
fake_home = tmpdir.mkdir('home')
monkeypatch.setattr(os.path, 'expanduser',
lambda x: x.replace('~', str(fake_home)))
dump_config(config)
path = os.path.expanduser('~/.conf.json')
expected = json.load(open(path, 'r', encoding='utf-8'))
assert expected == config
recwarn
recwarn
用于捕获程序中 warnings
产生的警告。
# test_recwarn.py
def warn():
warnings.warn('Deprecated function', DeprecationWarning)
def test_warn(recwarn):
warn()
assert len(recwarn) == 1
w = recwarn.pop()
assert w.category == DeprecationWarning
此外,pytest 可以使用 pytest.warns()
捕获警告:
def test_warn2():
with pytest.warns(None) as warnings:
warn()
assert len(warnings) == 1
w = warnings.pop()
assert w.category == DeprecationWarning