pytest 是一个非常灵活且强大的测试框架,它支持简单的单元测试到复杂的功能测试。显著特点是其简洁的语法,可以无需继承 TestCase 类直接使用函数来编写测试用例,并通过 assert语句 进行断言。还支持参数化测试、插件系统以及丰富的 assertion rewriting 功能,使编写测试更加便捷和直观。我们可以使用它进行自动化测试。

pytest 本身有一些内置的 mark 装饰器,需要介绍的包括:

  • @pytest.mark.skip
  • @pytest.mark.skipif
  • @pytest.mark.xfail
  • @pytest.mark.parametrize
  • @pytest.mark.usefixtures

这些装饰器分别提供不同的功能。

PS: 本文基于pytest 8.3.3,建议使用PyCharm编写并运行测试套件,方便。

参数化测试

@pytest.mark.parametrize 用于参数化测试,允许你对同一个测试函数传入多个参数集,参数集是一个元组,所有参数集构成一个列表:

# C:\PythonTest\Test\test_module1.py

import pytest

# 第一个参数是字符串,里面定义需要传递的参数。
# 第二个参数是列表,列表中一个元组是一个参数集合
@pytest.mark.parametrize("n, expected", [
    (1 + 2, 3),
    (2 + 2, 4),
    (3 * 3, 9)
])
def test_math(n, expected):
    assert n == expected

python .\app.py ,运行结果

========================================== test session starts =======================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collected 3 items                                                                                                                                                                                                                   

Config\test_mod.py::test_math[3-3] PASSED                                                                      [ 33%] 
Config\test_mod.py::test_math[4-4] PASSED                                                                      [ 66%] 
Config\test_mod.py::test_math[9-9] PASSED                                                                      [100%] 

=========================================== 3 passed in 0.01s ======================================================== 

通过标记测试组(类),可以参数化测试组中的所有测试用例:

# C:\PythonTest\Test\test_module1.py
import pytest

pytestmark = pytest.mark.parametrize("n,expected", [(1, 2), (3, 4)])


class TestClass:
    def test_simple_case(self, n, expected):
        assert n + 1 == expected

    def test_weird_simple_case(self, n, expected):
        assert (n * 1) + 1 == expected

python .\app.py ,运行结果:

========================================== test session starts =======================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collected 4 items                                                                                                                                                                                                                   

Config\test_mod.py::TestClass::test_simple_case[1-2] PASSED                                                      [ 25%] 
Config\test_mod.py::TestClass::test_simple_case[3-4] PASSED                                                      [ 50%] 
Config\test_mod.py::TestClass::test_weird_simple_case[1-2] PASSED                                                [ 75%] 
Config\test_mod.py::TestClass::test_weird_simple_case[3-4] PASSED                                                [100%]  

=========================================== 4 passed in 0.01s ======================================================== 

要参数化模块中的所有测试,可以分配给 pytestmark 全局变量:

# C:\PythonTest\Test\test_mod.py
import pytest

pytestmark = pytest.mark.parametrize("n,expected", [(1, 2), (2, 4)])


def test_one(n, expected):
    assert n+n == expected


def test_two(n, expected):
    assert n+n == expected

python .\app.py ,运行结果:

========================================== test session starts =======================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collected 4 items                                                                                                                                                                                                                   

Config\test_mod.py::test_one[1-2] PASSED                                                                        [ 25%] 
Config\test_mod.py::test_one[2-4] PASSED                                                                        [ 50%] 
Config\test_mod.py::test_two[1-2] PASSED                                                                        [ 75%] 
Config\test_mod.py::test_two[2-4] PASSED                                                                        [100%]  

=========================================== 4 passed in 0.01s ======================================================== 

要获取多个参数化参数的所有组合,可以堆叠 parametrize装饰器

# C:\PythonTest\Test\test_module1.py
import pytest


@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_foo(x, y):
    pass

python .\app.py ,运行结果:

========================================== test session starts =======================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collected 4 items                                                                                                                                                                                                                   

Config\test_mod.py::test_foo[2-0] PASSED                                                                        [ 25%] 
Config\test_mod.py::test_foo[2-1] PASSED                                                                        [ 50%] 
Config\test_mod.py::test_foo[3-0] PASSED                                                                        [ 75%] 
Config\test_mod.py::test_foo[3-1] PASSED                                                                        [100%]  

=========================================== 4 passed in 0.01s ======================================================== 

会按照 x=0/y=2 x=1/y=2 x=0/y=3 x=1/y=3的顺序耗尽参数。

也可以在参数化中标记单个测试实例:

# C:\PythonTest\Test\test_module1.py
import pytest


@pytest.mark.parametrize(
    "n,expected",
    [("3+5", 8), ("2+4", 6), pytest.param("6*7", 42, marks=pytest.mark.user)],
)
def test_eval(n, expected):
    assert eval(n) == expected

python .\app.py -m user ,运行结果:

========================================== test session starts =======================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collected 3 items / 2 deselected / 1 selected                                                                                                                                                                                                                    

Config\test_mod.py::test_eval[6*7-42] PASSED                                                                     [100%]  

==================================== 1 passed, 2 deselected in 0.01s =================================================== 

读取 json 文件中的数据进行参数化:

新增 Tools 和 Data 目录,Tools 存放工具类,Data 存放数据文件:

Project/
├── Config/
│   └── pytest.ini            # 自定义 mark 标记
├── Data/
│   └── parametrize.json      # 根据策略模块
├── Package/                  # 程序目录
│   ├── __init__.py           # 包初始化文件,可以定义一些变量或执行一些操作。当然里面什么都不写也可以。
│   ├── module1.py            # 应用程序模块,比如连接数据库操作数据,接口请求等操作,推荐按功能封装成类
│   └── module2.py            # 应用程序模块,比如连接数据库操作数据,接口请求等操作,推荐按功能封装成类
├── Test/                     # 测试用例目录
│   ├── __init__.py           # 包初始化文件
│   ├── test_module1.py       # 测试 module1 的测试用例
│   └── test_module2.py       # 测试 module2 的测试用例
├── Tools/                    # 工具目录
│   ├── __init__.py           # 包初始化文件
│   └── files_processor.py    # 文件处理工具
├── app.py                    # 项目启动文件
├── requirements.txt          # 项目依赖项列表
└── README.md                 # 项目说明文档

在 parametrize.json 中设计数据结构,根据 @pytest.mark.parametrize 的特性可以这样设计:

{
    "test1":{
        "param": "x,y,z",
        "data": [
            [1,8,9],
            [2,5,7],
            [11,9,20]
        ]
    },
    "test2": {
        "param": "n,m",
        "data": [
            [2,2],
            [3,3],
            [4,4]
        ]
    }
}

在 parametrize.json 中写入所有测试用例需要的数据,例如有 test_test1和 test_test2 的测试用例,就在文件中添加 test1 和 test2 的键对值。json 中没有元组的概念,使用列表代替。

根据数据文件的结构,在 files_processor.py 中编写处理文件的工具类。示例处理 parametrize.json 文件:

# files_processor.py
# 设计文件处理工具
import json
from abc import ABC, abstractmethod
import os

# 定义 FileHandler 接口
class FileStrategy(ABC):
    @abstractmethod  # 任何继承自 FileStrategy 的类都必须实现read方法
    def _read(self, path):
        """Read Data from the file."""
        pass


# 实现Json文件处理策略
class JsonHandler(FileStrategy):
    def _read(self, path):
        with open(path, 'r', encoding='utf-8') as file:
            return json.load(file)

    # 定义读取Json文件用于 parametrize 的方法
    def parametrize_data(self, path, case_name):
        """

        :param case_name: 测试用例的名称(去掉 test_ ),它是一个字符串
        :param path: Json文件的路径
        :return: 返回参数名和值的结果对象

        用于 @pytest.mark.parametrize(param, data,)
        """
		json_data = self._read(path)
        json_param = json_data[case_name]["param"]
		data1 = json_data[case_name]["data"]
		data2 = [tuple(x) for x in data1]
		return json_param, data2  


# 文件处理器工厂
class FileHandlerFactory:
    @staticmethod
    def get_handler(file_type: str):
        if file_type == 'json':
            return JsonHandler()
        # 可以添加更多的条件分支来支持其他类型的文件处理器
        raise ValueError(f"Unsupported file type: {file_type}")


# 文件处理器客户端
class FileProcessor:
    @staticmethod
    def process_file(function_name, file_name, **kwargs):
        """

        :param function_name:
        parametrize_data:读取json文件中的数据,应用 @pytest.mark.parametrize 进行参数化。json 内数据格式应是
        {"test1":{"param":"x,y,z","data":[[1,8,9],[2,5,7],[11,9,20]]},"test2":{"param":"n,m","data":[[2,2],[3,3],[4,4]]}}

        还需指定 case_name = "测试用例的名称(去掉 test_ 前缀)"
        :param file_name: 文件的名字(带后缀)
        :return: 对应方法的返回值值 或 None
        """
        if function_name == "parametrize_data":
            file_type = "json"
            handler = FileHandlerFactory.get_handler(file_type)
            script_dir = os.path.dirname(os.path.abspath(__file__))
            while os.path.basename(script_dir) != 'Tools':
                script_dir = os.path.dirname(script_dir)
            project_root = os.path.dirname(script_dir)
            return handler.parametrize_data(project_root+"\\Data\\"+file_name, **kwargs)
		# 可以添加更多的条件分支来支持其他文件处理器
        else:
            return False, None


# 测试
if __name__ == "__main__":
    processor = FileProcessor()
    param, data = processor.process_file('parametrize_data', 'parametrize.json', case_name="test1")
    print(param)
    print(data)

设计测试用例,从 test_eval.json 中读取数据进行参数化:

# C:\PythonTest\Test\test_module1.py
import pytest

from Tools.files_processor import FileProcessor

processor = FileProcessor()
param, data = processor.process_file('parametrize_data', 'parametrize.json', case_name="test1")
@pytest.mark.parametrize(param,data,)
def test_test1(x, y, z):
    assert x+y == z


param, data = processor.process_file('parametrize_data', 'parametrize.json', case_name="test2")
@pytest.mark.parametrize(param,data,)
def test_test2(n,m):
    assert n == m

python .\app.py ,运行结果:

================================================ test session starts =================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collecting ... collected 5 items

Config\test_mod.py::test_test1[1-8-9] PASSED                                                                     [ 20%]
Config\test_mod.py::test_test1[2-5-7] PASSED                                                                     [ 40%]
Config\test_mod.py::test_test1[11-9-20] PASSED                                                                   [ 60%]
Config\test_mod.py::test_test2[2-2] PASSED                                                                       [ 80%]
Config\test_mod.py::test_test2[3-3] PASSED                                                                       [100%]

================================================= 5 passed in 0.01s ==================================================

上述示例没有对异常情况进行处理,仅作为示例,请根据实际情况编写工具。

如果测试时需要处理其它文件,可以在 files_processor.py 中构建其它工具,比如想要对 json 文件进行其它操作,就可以在 JsonHandler 类中编写其它方法。想要处理其它文件,比如 xlsx 文件,可以新建 XlsxHandler 类并构建方法。

跳过测试用例

pytest 内置 @pytest.mark.skip 和 @pytest.mark.skipif 用于跳过测试用例。 被 @pytest.mark.skip 装饰的测试用例,运行时会被跳过;被 @pytest.mark.skipif 装饰的测试用例,满足条件时被跳过:

@pytest.mark.skip:

# C:\PythonTest\Test\test_module1.py

import pytest

@pytest.mark.skip(reason="这个测试用例还没有写好")
def test_one():
    assert 1 == 1

def test_two():
    assert 1 == 1

python .\app.py ,运行结果:

================================================ test session starts =================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collected 2 items                                                                                                                                                                                                                     

Config\test_test.py::test_one SKIPPED(这个测试用例还没有写好)                                                      [ 50%] 
Config\test_test.py::test_two PASSED                                                                             [100%]

=============================================== 2 passed in 0.01s ====================================================== 

@pytest.mark.skipif:

# C:\PythonTest\Test\test_module1.py

import pytest

@pytest.mark.skipif(3 < 8, reason="满足 3<8 的条件,此测试被跳过")
def test_one():
    assert 1 == 1

def test_two():
    assert 1 == 1

python .\app.py ,运行结果:

============================================= test session starts =====================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collected 2 items                                                                                                                                                                                                                     

Config\test_test.py::test_one SKIPPED (满足 3<8 的条件,此测试被跳过)                                                [ 50%] 
Config\test_test.py::test_two PASSED                                                                             [100%]

========================================== 1 passed, 1 skipped in 0.01s ================================================= 

可以把 @pytest.mark.skipif 结合参数化使用:

# C:\PythonTest\Test\test_module1.py

import pytest

from Tools.files_processor import FileProcessor

processor = FileProcessor()
param, data = processor.process_file('parametrize_data', 'parametrize.json', case_name="test1")
if param:
    skip_if_code = False
else:
    skip_if_code = True
    param = "x,y,z"
    data = [(1,1,1)]
@pytest.mark.skipif(skip_if_code, reason="没有指定的function_name")
@pytest.mark.parametrize(param,data,)
def test_test1(x, y, z):
    assert x+y == z


param, data = processor.process_file('parametrize', 'parametrize.json', case_name="test2")
if param:
    skip_if_code = False
else:
    skip_if_code = True
    param = "n,m"
    data = [(1,1)]
@pytest.mark.parametrize(param,data,)
@pytest.mark.skipif(skip_if_code, reason="没有指定的function_name")
def test_test2(n,m):
    assert n == m

python .\app.py ,运行结果:

============================================= test session starts =====================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collected 4 items                                                                                                                                                                                                                   

Config\test_mod.py::test_test1[1-8-9] PASSED                                                                        [ 25%] 
Config\test_mod.py::test_test1[2-5-7] PASSED                                                                        [ 50%] 
Config\test_mod.py::test_test1[11-9-20] PASSED                                                                      [ 75%] 
Config\test_mod.py::test_test2[1-1] SKIPPED (没有指定的function_name)                                                 [100%] 

========================================== 3 passed, 1 skipped in 0.01s ================================================= 

标记已存在的问题

如果测试被 @pytest.mark.xfail 标记并且测试失败,测试会被记录为预期失败,而不是完全失败。用于已经知道测试的功能存在问题,进行标记:

# C:\PythonTest\Test\test_module1.py

import pytest

@pytest.mark.skip(reason="这个测试用例还没有写好")
def test_one():
    assert 1 == 1

def test_two():
    assert 1 == 1

python .\app.py ,运行结果:

============================================= test session starts =====================================================
platform win32 -- Python 3.12.5, pytest-8.3.3, pluggy-1.5.0 -- C:\PythonTest\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\PythonTest\Config
configfile: pytest.ini
collected 4 items                                                                                                                                                                                                                   

collected 2 items                                                                                                                                                                                                                   

Config\test_mod.py::test_one XPASS (已知问题,还未修复)                                                              [ 50%] 
Config\test_mod.py::test_two PASSED                                                                               [100%]  

========================================== 1 passed, 1 xpassed in 0.01s ================================================= 

@pytest.mark.usefixtures 会在介绍 @pytest.fixture 的篇章中说明。


THEEND



© 转载需要保留原始链接,未经明确许可,禁止商业使用。CC BY-NC-ND 4.0