pytest 从入门到最佳实践

2,534 阅读8分钟

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。

  1. 如果一个 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
  1. 如果 fixture 定义在一个模块的全局范围内,则整个模块的测试方法都可以使用
  2. 一个 fixture 也可以使用其他的 fixture

fixtures 特征

  1. fixture 可以调用其他 fixture

  2. fixture 可复用

    可用于定义通用初始化步骤/初始化数据,在多个测试中共享,但多个测试之间互不影响

  3. 一个测试或 fixture 可以使用多个 fixtures

  4. 可以在一个测试中多次使用同一个 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_basictest_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 的标准发现机制步骤如下

  1. 确定开始位置

    未传入任何参数

    a. 如果配置了 testpaths,从该配置定义的目录开始

    pytest.ini

    [pytest]
    testpaths = test-dirctory
    

    b. 未配置 testpaths,从当前目录开始

  2. 从开始位置递归处理子目录,重复 3-4 步

    跳过 norecursedirs 配置的子目录,如 skipped-test-dirctory

    pytest.ini

    [pytest]
    norecursedirs = skipped-test-dirctory
    
  3. 收集 test_*.py 或 *_test.py 测试文件

  4. 在每个测试文件中收集用例

    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_configstest_01.py 可以直接使用 jewels

def test_jewels(jewels, my_configs):
    assert len(jewels) == 3
    assert my_configs["name"] == "jewels-1"

参考文档

pytest 官方文档

pytest fixtures guidance