Skip to content

pytest 是一个三方的单元测试框架,框架可以轻松编写小型、可读的测试,并且可以扩展以支持应用程序和库的复杂功能测试。

bash
pip install pytest
# uv
uv add pytest

# 验证版本 pytest 8.4.2
uv run pytest --version

快速开始

pytest 中不再追求固定范式,可以是函数、也可以是类。但是希望你的被测试方法函数名以 test_ 开头。当测试用例过多时,我仍然建议你使用 class 去组织测试用例。

py
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
指定方法或者类::
bash
# 执行单个文件
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 传入。

py
@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 之间引用的目的。当然我只是想为你演示他们之间执行的过程,没有别的意思。

py
@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 为你做的事情,如果是自己执行,则需要以下方案

py
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_add

Fixture 中的数据复用

还有一个比较重要的点 fixture 和用例之间的数据是能够被复用。最好不要有通过用例去改变他返回值的想法。

py
@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 的自动执行函数的特性。

py
@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 他都会被执行。

py
@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。 取值包含 functionclassmodulepackagesession。默认值为 function

  • function:默认范围,在测试结束时被销毁。
  • class:在拆卸类中的最后一个测试时,被销毁。
  • module:在拆卸模块中的最后一个测试时,被销毁。
  • package:在拆卸包中的最后一个测试时,包括其中的子包和子目录。被销毁。
  • session:在整个测试会话结束时,被销毁。

实际上这些作用域的大小,是根据你项目中文件的组织结构来定的。

function

function 作用域,每个函数都会执行一次

py
@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 作用域,在类中只会调用一次

py
@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 作用域,在模块(一个文件)中会调用一次,无论有多少个测试用例或者类和方法

py
@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 也会被当做包。

py
@pytest.fixture(scope='package')
def mock_test_dict():
    print('package scope starting...')

# package scope starting...

session

session 作用域,在整个测试会话结束时,被销毁。 从作用的范围来看在项目中 packagesession 的使用没有太大区别。

py
@pytest.fixture(scope='session')
def mock_test_dict():
    print('session scope starting...')

# session scope starting...

执行顺序与逻辑抽离

执行顺序上来看,session -> package -> module -> class -> function

py
@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 后面可以做一些清理的操作。

比如我们要连接一个数据库,那操作之后应该及时的去断开它

py
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")

目前我不能理解为什么要这么做。

py
@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

参数化是个非常重要的概念,它可以让你运行一组测试,并且使用不同的数据。

py
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.py
py
import pytest

# 普通的 fixture
@pytest.fixture
def plain_fixture():
    return 'plain fixture'

# 参数化的 fixture
@pytest.fixture(params=[1, 2, 3])
def params_fixture(request):
    return request.param
py
def test_plain_fixture(plain_fixture):
    assert plain_fixture == 'plain fixture'

def test_params_fixture(params_fixture):
    assert params_fixture in [1, 2, 3]
py
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'
py
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 可以实现测试用例的参数化。接收两个参数

  • 输入和预期结果的字段
  • 输入和预期结果的值和表达式,数组套元组的模式
py
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

如果使用类的模式则可以为用例指定更多的测试模式,类中的每个用例都会被参数化

py
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 来命名,算是一种语法糖

py
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

多个参数化模式

可以使用堆叠装饰器的模式获取多个参数化模式,执行的次数等于所有参数的排列组合数。适用场景应该不多

py
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 来跳过或者标记失败这些用例。

py
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, 可以在配置文件中进行配置

py
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 制定参数化用例的跳过或者失败

py
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 指定标记是失败还是跳过。

py
import pytest

@pytest.fixture(params=[1, 2, pytest.param(3, marks=pytest.mark.skip)])
def params_data(request):
    return request.param

断言和异常

pytest 中的断言更加的简约,使用了原生的 assert 语句来判定结果是否符合预期。

py
assert 1 == 1

有些时候,可能为了捕获一些程序的异常,你也可以断言一些异常。比如有个接口请求超时了。

py
import pytest

# 模拟接口请求,获取数据超时
def api_data():
    raise TimeoutError()

def test_api_timeout():
    with pytest.raises(TimeoutError) as error:
        api_data()

    assert error.type == TimeoutError

警告信息的过滤

在执行的时候,如果出现了一些警告信息,你也可以通过 filterwarnings 来过滤掉。

py
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