pytest 是一个三方的单元测试框架,框架可以轻松编写小型、可读的测试,并且可以扩展以支持应用程序和库的复杂功能测试。
pip install pytest
# uv
uv add pytest
# 验证版本 pytest 8.4.2
uv run pytest --version快速开始
在 pytest 中不再追求固定范式,可以是函数、也可以是类。但是希望你的被测试方法函数名以 test_ 开头。当测试用例过多时,我仍然建议你使用 class 去组织测试用例。
def test_math_add():
assert 1 + 2 == 3
def test_math_sub():
assert 1 - 2 == -1
class TestString:
def test_string_upper(self):
assert "hello".upper() == "HELLO"
def test_string_lower(self):
assert "HELLO".lower() == "hello"执行 uv run pytest test_demo.py 上述四个用例会被全部执行。
命令行
pytest 的测试发现规则是 test_*.py、 *_test.py。在文件中也会寻找包含 test 的类或者方法。
| 描述 | 参数 |
|---|---|
指定测试用例,文本匹配,支持 and or not 执行不包含 math 的测试用例 | -k |
| 安静模式 | -q | --quiet |
| 允许输出 print 信息 | -s |
| 运行更详细的测试用例 | -v | -vv | -vvv |
| 最多运行 n 个失败用例 | --maxfail= |
| 排除不执行的目录或文件, 可以使用多个 | --ignore= |
| 排除不执行的目录或文件 | --ignore-glob= |
| 指定标记 | -m |
| 指定方法或者类 | :: |
# 执行单个文件
pytest test_demo.py
# 执行目录
pytest testing/
# -k 指定测试用例
# 不包含 math 的测试都要被执行。
# 可以被使用的关键字 and not or
pytest -k "not math" testing/
# -q 安静模式 或 --quiet
# -v 运行更详细的测试用例。控制台输出更多的信息
# -vv 更详细的测试用例
# -vvv 不是标准,但可用于某些设置中的更多细节
pytest -v testing/
# -s 允许输出 print 信息
pytest -s testing/
# --maxfail= 最多运行 2 个失败用例
pytest --maxfail=2 testing/
# --ignore 排除不执行的目录或文件
pytest --ignore=testing/test_demo.py testing/
# 可以指定多个
pytest --ignore=testing/test_demo.py --ignore=testing/test_string.py testing/
# --ignore-glob 排除不执行的目录或文件 正则方式
pytest --ignore-glob='*.py' testing/
# -m
# 运行所有 @pytest.mark.slow 标记的测试用例
# 也可以是这样 "slow(phase=1)"
pytest -m show
# 也可以通过 :: 指定方法或者类的运行
# 1. 指定类下的指定方法
pytest test_demo.py::TestString::test_string_upper
# 2. 指定类
pytest test_demo.py::TestString
# 3. 为方法传递参数
pytest test_demo.py::test_math_add[1, 2]需要注意的是,当指定方法的时候它就只是一个文件中的方法(pytest test_demo.py::test_math_add),不然你应该做的是指定类下面的方法 pytest test_demo.py::TestString::test_string_upper
TestFixture
这应该是 pytest 出圈的主要功能了,提供的复用能力比较与 unittest 强大了很多。强的头皮发麻....
快速开始
从示例中看,被 @pytest-fixture 装饰器所包裹的方法都会被执行并且被测试函数所使用。此外他们自身还可以被其他 fixture 所使用。
可能有点绕,举个例子,以测试加法运算为例。可是把预期和参数都作为 fixture 传入。
@pytest.fixture
def add_params():
return 1, 2
@pytest.fixture
def add_result():
return 3
# pytest.fixture 会执行被装饰的方法并且拿到返回值
def test_add(add_params, add_result):
assert sum(add_params) == add_result再或者甚至可以将运算的过程放在 add_result 中。以达到 fixture 之间引用的目的。当然我只是想为你演示他们之间执行的过程,没有别的意思。
@pytest.fixture
def add_params():
return 1, 2
# 接收 add_params 这个 fixture
@pytest.fixture
def get_add_result_is_successed(add_params):
return sum(add_params) == 3
def test_add(get_add_result_is_successed):
assert get_add_result_is_successed实际上这都是 pytest 为你做的事情,如果是自己执行,则需要以下方案
def add_params():
return 1, 2
def get_add_result_is_successed(add_params):
return sum(add_params) == 3
def test_add(get_add_result_is_successed):
assert get_add_result_is_successed
if __name__ == "__main__":
add_params = add_params() # 执行 add_params
get_add_result_is_successed(add_params) # 执行 get_add_result_is_successed
test_add(get_add_result_is_successed) # 执行 test_addFixture 中的数据复用
还有一个比较重要的点 fixture 和用例之间的数据是能够被复用。最好不要有通过用例去改变他返回值的想法。
@pytest.fixture
def add_params():
return [1, 2]
def test_add_params(add_params):
add_params.append(3)
assert add_params == [1, 2, 3]
def test_add(add_params):
assert add_params == [1, 2]在多个 fixture 内部数据的数据可以被复用,并且会被最终返回。用例中则会拿到最终数据,这一切的前提是你使用了更改那个数据的 fixture。这一切还是主要归功于 @pytest.fixture 的自动执行函数的特性。
@pytest.fixture
def list_data():
return [1, 2]
@pytest.fixture
def append_list_data(list_data):
list_data.append(3)
return list_data
# Error
def test_01_add_params(list_data):
assert list_data == [1, 2, 3]
# Success
def test_02_add_params(append_list_data, list_data):
assert list_data == [1, 2, 3]自动使用 autouse
当为 @pytest.fixture 增加此参数后,无论是否使用了该 fixture 他都会被执行。
@pytest.fixture
def mock_test_data():
return 'test data'
@pytest.fixture
def mock_test_container():
return []
# 无论有没有被使用,都会被执行
# 其背部已经更改了 mock_test_container 的返回值
@pytest.fixture(autouse=True)
def mock_autouse(mock_test_data, mock_test_container):
return mock_test_container.append(mock_test_data)
# Successed
def test_01(mock_test_container):
assert mock_test_container == ['test data']Fixture 的作用域
这是一个比较重要的功能,就像在 unittest 中的 setUp 一样。理论上来说作用域越大,复用性越高。但是也要结合实际情况。
同样的,你需要向 @pytest.fixture 传递参数,名为 scope。 取值包含 function、class、module、package、session。默认值为 function。
function:默认范围,在测试结束时被销毁。class:在拆卸类中的最后一个测试时,被销毁。module:在拆卸模块中的最后一个测试时,被销毁。package:在拆卸包中的最后一个测试时,包括其中的子包和子目录。被销毁。session:在整个测试会话结束时,被销毁。
实际上这些作用域的大小,是根据你项目中文件的组织结构来定的。
function
function 作用域,每个函数都会执行一次
@pytest.fixture
def mock_test_data():
print('function scope staring....')
def test_01(mock_test_data):
print('function test ... 🚀')
def test_02(mock_test_data):
print('function test ... 🚀')
# function scope staring....
# function test ... 🚀
# function scope staring....
# function test ... 🚀class
class 作用域,在类中只会调用一次
@pytest.fixture(scope='class')
def mock_test_dict():
print('class scope starting...')
class TestDict:
def test_dict_01(self, mock_test_dict):
print('class test ... 🚀')
def test_dict_02(self, mock_test_dict):
print('class test ... 🚀')
# class scope starting...
# class test ... 🚀
# class test ... 🚀module
module 作用域,在模块(一个文件)中会调用一次,无论有多少个测试用例或者类和方法
@pytest.fixture(scope='module')
def mock_test_dict():
print('module scope starting...')
def test_dict_01(mock_test_dict):
print('module test ... 🚀')
def test_dict_02(mock_test_dict):
print('module test ... 🚀')
# module scope starting...
# module test ... 🚀
# module test ... 🚀package
package 作用域,在包(一个目录,可以嵌套)中会调用一次,无论有多少个测试用例或者类和方法。实际上你不存在 __init__.py 也会被当做包。
@pytest.fixture(scope='package')
def mock_test_dict():
print('package scope starting...')
# package scope starting...session
session 作用域,在整个测试会话结束时,被销毁。 从作用的范围来看在项目中 package 和 session 的使用没有太大区别。
@pytest.fixture(scope='session')
def mock_test_dict():
print('session scope starting...')
# session scope starting...执行顺序与逻辑抽离
执行顺序上来看,session -> package -> module -> class -> function。
@pytest.fixture(scope='session')
def session_fixture():
print('session scope starting...')
@pytest.fixture(scope='package')
def package_fixture():
print('package scope starting...')
@pytest.fixture(scope='module')
def module_fixture():
print('module scope starting...')
@pytest.fixture(scope='class')
def class_fixture():
print('class scope starting...')
@pytest.fixture(scope='function')
def function_fixture():
print('function scope starting...')
class TestClass:
def test_method_01(self, session_fixture, package_fixture, module_fixture, class_fixture, function_fixture):
print('test_method_01')
# session scope starting...
# package scope starting...
# module scope starting...
# class scope starting...
# function scope starting...
# test_method_01如果你想将这个 fixture 抽离到单独的文件去管理,可以使用 conftest.py。这是一个约定的文件名。在这个文件中定义的 fixture 会被自动导入到所有测试文件中。无需手动 import。
在实际的项目中 conftest.py 通常会放在测试文件的根目录下。当然他不止一个,如果项目比较复杂,他可能存在于多个子目录下。
project/
├── conftest.py # 全局fixture:数据库、配置等
├── tests/
│ ├── conftest.py # 测试根目录的共享fixture
│ ├── api/
│ │ ├── conftest.py # API测试专用fixture
│ │ ├── test_users.py
│ │ └── test_products.py
│ ├── models/
│ │ ├── conftest.py # 模型测试专用fixture
│ │ ├── test_user_model.py
│ │ └── test_product_model.py
│ └── utils/
│ ├── conftest.py # 工具函数测试专用fixture
│ └── test_helpers.py
└── src/Fixture 的卸载与清理
在前面的示例中,一直在输出一个理念。fixture return 出来的内容会被测试用例所接收。实际上也可以把 return 替换成 yield。
这样在 yield 后面可以做一些清理的操作。
比如我们要连接一个数据库,那操作之后应该及时的去断开它
import pytest
# 假设在项目中有一个创建用户的接口
class User:
def __init__(self):
self.users = []
def create_user(self, user):
self.users.append(user)
return self.users[-1]
@pytest.fixture(scope='class')
def user_fixture():
# 这个 fixture 类中的用例执行之前的前置操作
user = User()
yield user
# 这个 fixture 类中的用例执行之后的后置操作
user.users = []
class TestUser:
def test_create_user_01(self, user_fixture):
user = user_fixture.create_user({"id": 1, "name": "test"})
assert user["id"] == 1
def test_create_user_02(self, user_fixture):
user = user_fixture.create_user({"id": 2, "name": "test2"})
assert user["id"] == 2当然,如果在用例的执行过程中,出现了异常,那么 yield 之后的内容就不会被执行。
将测试数据传递给 Fixture
通过给测试用例增加 @pytest.mark.fixt_data() 传递参数。
fixture 则是通过 request 进行获取。request.node.get_closest_marker("fixt_data")
目前我不能理解为什么要这么做。
@pytest.fixture
def fixt(request):
return request.node.get_closest_marker("fixt_data").args[0]
@pytest.mark.fixt_data("test data")
def test_01(fixt):
assert fixt == "test data"参数化的 Fixture
参数化是个非常重要的概念,它可以让你运行一组测试,并且使用不同的数据。
import pytest
@pytest.fixture(params=[1, 2, 3])
def data_set(request):
return request.param
def test_data(data_set):
print(data_set)
# src/tests/test_case2.py::test_data[1] 1
# src/tests/test_case2.py::test_data[2] 2
# src/tests/test_case2.py::test_data[3] 3复写各个级别的 Fixture
可以在子文件夹中复写外层的 fixture。说白了只要你在子文件夹的 confset.py 声明重名的 fixture,在使用的时候就会以就近原则使用。达到复写的目的。
假设文件结构如下
project/
├── conftest.py
├── tests/
│ ├── conftest.py
│ └── test_case1.py
│ └── sub_tests/
│ └── conftest.py
│ └── sub_test_case1.pyimport pytest
# 普通的 fixture
@pytest.fixture
def plain_fixture():
return 'plain fixture'
# 参数化的 fixture
@pytest.fixture(params=[1, 2, 3])
def params_fixture(request):
return request.paramdef test_plain_fixture(plain_fixture):
assert plain_fixture == 'plain fixture'
def test_params_fixture(params_fixture):
assert params_fixture in [1, 2, 3]import pytest
# 在 sub 中将 plain_fixture 改为参数化的 fixture
@pytest.fixture(params=['plain changed params'])
def plain_fixture(request):
return request.param
# 在 sub 中将 params_fixture 改为普通 fixture
@pytest.fixture()
def params_fixture():
return 'params changed plain'def sub_test_plain_fixture(plain_fixture):
assert plain_fixture in ['plain changed params']
def sub_test_params_fixture(params_fixture):
assert params_fixture == 'params changed plain'测试用例参数化
使用 @pytest.mark.parametrize 可以实现测试用例的参数化。接收两个参数
- 输入和预期结果的字段
- 输入和预期结果的值和表达式,数组套元组的模式
import pytest
@pytest.mark.parametrize('test_input, expected', [('1+2', 3), ('1+3', 4)])
def test_params_data(test_input, expected):
assert eval(test_input) == expected如果使用类的模式则可以为用例指定更多的测试模式,类中的每个用例都会被参数化
import pytest
@pytest.mark.parametrize('test_input, expected', [(2, 3), (4, 5)])
class TestMath:
def test_add(self, test_input, expected):
assert test_input + 1 == expected
def test_sub(self, test_input, expected):
assert test_input - 1 != expected也可以直接为模块定义参数化,它就像 module fixture 可以被整个文件所使用。注意此种方式必须用 pytestmark 来命名,算是一种语法糖
import pytest
pytestmark = pytest.mark.parametrize('test_input, expected', [(2, 3), (4, 5)])
class TestMath:
def test_add(self, test_input, expected):
assert test_input + 1 == expected
def test_sub(self, test_input, expected):
assert test_input - 1 != expected多个参数化模式
可以使用堆叠装饰器的模式获取多个参数化模式,执行的次数等于所有参数的排列组合数。适用场景应该不多
import pytest
@pytest.mark.parametrize('x', [1, 2])
@pytest.mark.parametrize('y', [3, 4])
def test_params(x, y):
# 执行次数为 4 此
# 1, 3
# 2, 3
# 1, 3
# 2, 4
print(x, y)
pass跳过测试与标记失败
测试用例的跳过与标记失败
随着系统的迭代,不免有一些用例会陷入短暂的不可用状态。或者说它目前可能就是需要一个失败的状态。此时可以使用 pytest.mark 来跳过或者标记失败这些用例。
import pytest
@pytest.mark.xfail
def test_case_01():
assert 1 == 2
@pytest.mark.skip(reason='老版本的测试用例,暂时跳过')
def test_case_02():
assert 1 == 1
@pytest.mark.skipif(condition=True , reason='满足条件就跳过这个用例')
def test_case_03():
assert 1 in [1]当然 xfail 也有一些参数可以指定。
condition满足条件就标记失败,比如判断是否是 windows 系统sys.platform == 'win32'reason标记失败的原因raises指定异常类型run指定是否运行该用例
此外 xfail还存在一种严格模式xfail_strict=true, 可以在配置文件中进行配置
import sys
import pytest
@pytest.mark.xfail(condition=sys.platform == 'win32', reason='满足条件就标记失败', raises=Exception, run=True)
def test_case_04():
assert 1 == 2参数化的用例标记失败、跳过
使用 pytest.param 制定参数化用例的跳过或者失败
import pytest
@pytest.mark.parametrize('test_input, expected', [(2, 3), pytest.param(4, 4, marks=pytest.mark.xfail)])
def test_params_data(test_input, expected):
assert test_input + 1 == expected参数化的 fixture 标记失败、跳过
使用 pytest.param 指定 fixture 的参数化,通过 marks 指定标记是失败还是跳过。
import pytest
@pytest.fixture(params=[1, 2, pytest.param(3, marks=pytest.mark.skip)])
def params_data(request):
return request.param断言和异常
pytest 中的断言更加的简约,使用了原生的 assert 语句来判定结果是否符合预期。
assert 1 == 1有些时候,可能为了捕获一些程序的异常,你也可以断言一些异常。比如有个接口请求超时了。
import pytest
# 模拟接口请求,获取数据超时
def api_data():
raise TimeoutError()
def test_api_timeout():
with pytest.raises(TimeoutError) as error:
api_data()
assert error.type == TimeoutError警告信息的过滤
在执行的时候,如果出现了一些警告信息,你也可以通过 filterwarnings 来过滤掉。
import pytest
import warnings
def api_v1():
warnings.warn(UserWarning('api v1, 这是老的 api 尽快更新到 api v2'))
return 1
@pytest.mark.filterwarnings('ignore:api v1')
def test_case_01():
assert api_v1() == 2