Skip to content

Writing Test Functions

第2章:测试函数

在上一章中,你准好pytest环境并尝试运行了pytest,也学会了如何在指定的文件和目录中运行它,以及使用一些选项来运行pytest。

在本章中,我们将学习如何在Python包中编写测试函数。如果你使用pytest 来测试 Python 包以外的东西,这一章的大部分内容仍然适用;我们将为 Tasks 包编写测试函数。在此之前,我将讨论可发布的Python包的结构和测试用例。然后展示如何在测试中使用断言,如何处理异常,以及如何捕获预期的异常。

最后,我们会做很多测试。因此,你将学习如何将测试组织成类、模块和包。然后,如何标记测试,并讨论内置标记的使用。还有参数化测试,它允许用不同的数据调用测试函数。

📌 温馨提示:在本章中执行终端命令的基准目录一律为测试目录,即ch2/tasks_proj/tests。如果有指定cd命令的,则需要先切换到该目录后再执行对应命令。

2.1 下载项目

本章将使用示例项目Tasks ,来了解如何为 Python 包(package)编写测试函数。Tasks 是一个包含命令行工具tasks的Python包。

下载地址:https://media.pragprog.com/titles/bopytest/code/bopytest-code.zip

项目初始化

  • 第一步:下载解压。下载bopytest-code.zip并解压。将会得到tasks_proj目录。
  • 第一步:创建目录。进入studypytest项目根目录,创建名为ch2的子目录。
  • 第三步:将tasks_proj目录整个拷贝到studypytest/ch2/中。

让我们快速了解一下 tasks_proj 项目中的文件内容。

tasks_proj/
├── CHANGELOG.rst
├── LICENSE
├── MANIFEST.in
├── setup.py
├── src
│   └── tasks
│               ├── __init__.py
│               ├── api.py
│               ├── cli.py
│               ├── config.py
│               ├── tasksdb_pymongo.py
│               └── tasksdb_tinydb.py
└── tests
      ├── conftest.py
      ├── pytest.ini
      ├── func
      │         ├── __init__.py
      │         ├── test_add.py
      │         └── ...
      └── unit
            ├── __init__.py
            ├── test_task.py
            └── ...
上面列举了项目的完整列表 ,包含一些对测试至关重要的文件,比如 conftest.pypytest.ini__init__.py文件和 setup.py

顶层文件
所有顶层文件CHANGELOG.rstLICENSE, MANIFEST.insetup.py将在附录4中会有更详细的讨论(英文版第175页)。 setup.py 对于从包中构建发布包很重要,对于能够在本地安装包以便可以导入包也很重要。

源码目录
该项目包含两种类型的__init__.py文件:一种是在 src目录下的,另一种是在 test目录下的。

src/tasks/__init__.py 文件告诉 Python 该目录是一个包。当有人使用导入tasks包时,它还充当包的主入口。它包含从api.py模块导入的特定函数,这样cli.py 和我们的测试文件就可以访问包的功能,例如 tasks.add()这样的函数,而不必执行tasks.api.add() 这样的代码。

ch2/tasks_proj/src/tasks/__init__.py 文件内容:

# ch2/tasks_proj/src/tasks/__init__.py

"""Minimal Project Task Management."""

from .api import (  # noqa: F401
    Task,
    TasksException,
    add,
    get,
    list_tasks,
    count,
    update,
    delete,
    delete_all,
    unique_id,
    start_tasks_db,
    stop_tasks_db
)

__version__ = '0.1.0'

测试目录 tests
所有测试文件都保存在tests目录中,并与src 中的源文件分开。这不是 pytest 的要求,但是这是一个最佳实践。

tests目录中,可以看到,功能测试和单元测试被分隔到各自的目录中。将测试文件组织到多个目录中,可以方便运行测试子集。建议将功能测试和单元测试分开,因为只有当我们有意更改系统的功能时,功能测试才会中断,而单元测试在重构或更改实现期间可能随时会中断。

tests/func/__init__.pytests/unit/__init__.py 文件都是空的。它们告诉pytest 向上层目录查找 test 目录的根目录和 pytest.ini 文件。

  • pytest.ini文件

它是可选的,包含项目范围的pytest配置信息,它可以包含改变 pytest 行为的命令,比如设置一个每次执行pytest都要使用的选项列表。在你的项目中最多只能存在一个。关于pytest.ini 如何配置的更多知识详见第6章。

  • conftest.py 文件

它是可选的。pytest 认为它是一个本地插件,可以包含钩子函数(hook function)和装置( fixture)。

钩子函数是一种在pytest执行过程中插入代码的方法,可以改变pytest的工作方式。更多关于钩子函数的知识详见【第5章:插件】内容。

Hook functions are a way to insert code into part of the pytest execution process to alter how pytest works

装置可以是在测试函数之前和之后运行的前置/后置函数,还可以用来表示测试使用的资源和数据。在多个子目录中使用的钩子函数和装置最好统一写在 tests/conftest.py 文件中。一个项目中可以存在多个conftest.py 文件。例如,可以在tests目录中建一个,还可以在tests的每个子目录中搞一个。更多关于装置的知识详见【第3章:装置】和【第4章:内置插件】内容。

Fixtures are setup and teardown functions that run before and after test functions, and can be used to represent resources and data used by the tests.

📌 注意:在本章中执行终端命令的基准目录一律为测试目录,即ch2/tasks_proj/tests。如果有指定cd命令的,则需要先切换到该目录后再执行对应命令。

2.2 安装项目

我们之前在第1章中编写过test_three.pytest_four.py 文件,其中还写了有关Tasks的4个测试函数,那么现在全部拷贝到tests/unit/test_task.py中,并删除 Task 数据结构的定义,因为它实际上属于 api.py

test_task.py的完整代码:

"""Test the Task data type."""

from tasks import Task

def test_asdict():
    """_asdict() should return a dictionary."""
    t_task = Task('do something', 'okken', True, 21)
    t_dict = t_task._asdict()
    expected = {
        'summary': 'do something',
        'owner': 'okken',
        'done': True,
        'id': 21
    }

    assert t_dict == expected


def test_replace():
    """_replace() should change passed in fields."""
    t_before = Task('finish book', 'brian', False)
    t_after = t_before._replace(id=10, done=True)
    t_expected = Task('finish book', 'brian', True, 10)
    print("Hello")
    assert t_after == t_expected


def test_defaults():
    """Using no parameters should invoke defaults."""
    t1 = Task()
    t2 = Task(None, None, False, None)
    assert t1 == t2


def test_member_access():
    """Check .field functionality of namedtuple."""
    t = Task('buy milk', 'brian')
    assert t.summary == 'buy milk'
    assert t.owner == 'brian'
    assert (t.done, t.id) == (False, None)

注意到test_task.py 文件顶部的import语句:

from tasks import Task
不出意外的话,pycharm目前在tasks和Task底部显示了红色波浪线,因为目前还没有安装对应的依赖,所以没办法导入。最好的解决方法是在本地使用 pip 命令安装tasks依赖包。

tasks_proj目录中包含了 setup.py 文件,可以利用它的配置信息通过pip命令安装tasks依赖。

方式一:可以在ch2目录中执行pip install -e tasks_proj

$ cd ch2
$ pip install -e tasks_proj

方式二(推荐):进入tasks_proj目录,执行pip install .

$ cd ch2/tasks_proj
$ pip install .

方式三:如果你希望能够在安装任务时修改源代码,你需要使用 -e 选项 :pip install -e .。这里的e表示editable可编辑的意思。

$ cd ch2/tasks_proj
$ pip install -e .
tasks导入成功了! 安装成功后,可以看到test_task.pyfrom tasks import Task的红色波浪线消失了。

现在可以愉快地测试tasks了。

执行命令:

$ pytest unit/test_task.py
输出结果:
============================= test session starts ==============================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pytest.in
i
collected 4 items                                                               

test_task.py ....                                                         [100%]

============================== 4 passed in 0.02s ===============================
简直太酷了,来给自己一点掌声吧~

2.3 断言语句

当编写测试函数时,可以直接使用Python自带的 assert关键字来编写断言语句。pytest 中的这种易用性是非常棒的,也是为什么很多开发者选择 pytest 测试框架的原因。

如果你使用过其他的测试框架,你可能已经见过各种assert 断言辅助函数。例如,下面列出了一些assert 格式和 assert 辅助函数:
image.png
对于 pytest,可以对任何表达式使用 assert <expression>这样的写法 。如果表达式的结果对应的布尔值是True时,则宣告测试函数执行通过,否则宣告失败。

让我们看一个断言失败的例子。

ch2/tasks_proj/tests/unit/目录中添加新的测试模块test_task_fail.py,并编写以下代码:

"""Use the Task type to show test failures."""
from tasks import Task


def test_task_equality():
    """Different tasks should not be equal."""
    t1 = Task('sit there', 'brian')
    t2 = Task('do something', 'okken')
    assert t1 == t2


def test_dict_equality():
    """Different tasks compared as dicts should not be equal."""
    t1_dict = Task('make sandwich', 'okken')._asdict()
    t2_dict = Task('make sandwich', 'okkem')._asdict()
    assert t1_dict == t2_dict
执行命令:
$ pytest unit/test_task_fail.py
输出结果:
============================= test session starts ==============================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pytest.in
i
collected 2 items                                                               

test_task_fail.py FF                                                      [100%]

=================================== FAILURES ===================================
______________________________ test_task_equality ______________________________

def test_task_equality():
"""Different tasks should not be equal."""
t1 = Task('sit there', 'brian')
t2 = Task('do something', 'okken')
>       assert t1 == t2
E       AssertionError: assert Task(summary=...alse, id=None) == Task(summary=...
alse, id=None)
E         
E         Omitting 2 identical items, use -vv to show
E         Differing attributes:
E         ['summary', 'owner']
E         
E         Drill down into differing attribute summary:
E           summary: 'sit there' != 'do something'...
E         
E         ...Full output truncated (9 lines hidden), use '-vv' to show

test_task_fail.py:9: AssertionError
______________________________ test_dict_equality ______________________________

def test_dict_equality():
"""Different tasks compared as dicts should not be equal."""
t1_dict = Task('make sandwich', 'okken')._asdict()
t2_dict = Task('make sandwich', 'okkem')._asdict()
>       assert t1_dict == t2_dict
E       AssertionError: assert {'done': Fals...ake sandwich'} == {'done': Fals...
ake sandwich'}
E         Omitting 3 identical items, use -vv to show
E         Differing items:
E         {'owner': 'okken'} != {'owner': 'okkem'}
E         Use -v to get more diff

test_task_fail.py:16: AssertionError
=========================== short test summary info ============================
FAILED test_task_fail.py::test_task_equality - AssertionError: assert Task(sum...

FAILED test_task_fail.py::test_dict_equality - AssertionError: assert {'done':...

============================== 2 failed in 0.10s ===============================
对于每个失败的测试用例,断言行展示为一个> 符号指向断言语句。追踪行E显示了断言失败的堆栈信息,帮助你找到出错的地方。

之前的代码中故意在test_task_equality()中放置了两个不匹配项,输出结果中只显示了第一个。现在加上-v选项再执行一次。

执行命令:

$ pytest unit/test_task_fail.py::test_task_equality -v
输出结果:
============================= test session starts ==============================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\envs\myta
sks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pytest.in
i
collected 1 item                                                                

test_task_fail.py::test_task_equality FAILED                              [100%]

=================================== FAILURES ===================================
______________________________ test_task_equality ______________________________

    def test_task_equality():
        """Different tasks should not be equal."""
        t1 = Task('sit there', 'brian')
        t2 = Task('do something', 'okken')
>       assert t1 == t2
E       AssertionError: assert Task(summary=...alse, id=None) == Task(summary=...
alse, id=None)
E         
E         Omitting 2 identical items, use -vv to show
E         Differing attributes:
E         ['summary', 'owner']
E         
E         Drill down into differing attribute summary:
E           summary: 'sit there' != 'do something'...
E         
E         ...Full output truncated (13 lines hidden), use '-vv' to show

test_task_fail.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_task_fail.py::test_task_equality - AssertionError: assert Task(sum...

============================== 1 failed in 0.09s ===============================
真是太酷了,pytest不仅发现了两者的差异,而且还清晰地告诉我们差异在哪里。

这个示例只断言值相等的情况。

更多有关断言语句的用法,详见pytest官方网站。

http://doc.pytest.org/en/latest/example/reportingdemo.html

2.4 捕获异常

Tasks 的 API 接口中有一些地方可能会引发异常。而且,tasks_proj/src/tasks目录下的cli.py 中的 CLI 命令行代码和 api.py 中的 API 接口代码之间,有一个协议来管控哪些类型将被发送到 API 函数。

让我们快速浏览一下api.py 中定义的函数(详见tasks_proj/src/tasks/api.py文件):

def add(task):  # type: (Task) -> int
def get(task_id):   # type: (int) -> Task
def list_tasks(owner=None):   # type: (str|None) -> list of Task
def count():   # type: (None) -> int
def update(task_id, task):   # type: (int, Task) -> None
def delete(task_id):   # type: (int) -> None
def delete_all():   # type: () -> None
def unique_id():   # type: () -> int
def start_tasks_db(db_path, db_type):   # type: (str, str) -> None
def stop_tasks_db():   # type: () -> None

在这些 API 的调用中,如果出现类型错误,肯定会引发异常。

为了演示这些函数在非法调用时引发的异常,让我们在测试函数中使用错误的类型来故意引发TypeError错误,然后使用with pytest.raises(<expected exception>)语法进行捕获。

检查异常类型

💡在tests/func/目录中,新建名为test_api_exceptions.py的测试模块。并在其中编写test_add_raises测试函数。

import pytest
import tasks

def test_add_raises():
    """add() should raise an exception with wrong type param."""
    with pytest.raises(TypeError):
        tasks.add(task='not a Task object')
代码解释:在测试函数test_add_raises()中,第5行的with pytest.raises(TypeError):语句表示下一个代码块中的任何内容都应该引发TypeError 异常。如果没有引发异常,则宣告测试失败;如果引发的是一个其他的异常,同样意味着测试失败。

检查异常信息

我们刚刚在 test_add_raises() 中检查了异常的类型,当然也可以检查异常的参数。

例如,我们把目光移到api.py模块其中的一个函数上:start_tasks_db(db_path, db_type),它的参数db_type必须是一个字符串类型值,并且合法值只能是"tiny"或者"mongo",否则引起ValueError错误,并且提示错误信息("db_type must be a 'tiny' or 'mongo'")

具体代码如下:

# ch2/tasks_proj/src/tasks/api.py
# 代码片段

def start_tasks_db(db_path, db_type):  # type: (str, str) -> None
    """Connect API functions to a db."""
    if not isinstance(db_path, string_types):
        raise TypeError('db_path must be a string')
    global _tasksdb
    if db_type == 'tiny':
        import tasks.tasksdb_tinydb
        _tasksdb = tasks.tasksdb_tinydb.start_tasks_db(db_path)
    elif db_type == 'mongo':
        import tasks.tasksdb_pymongo
        _tasksdb = tasks.tasksdb_pymongo.start_tasks_db(db_path)
    else:
        raise ValueError("db_type must be a 'tiny' or 'mongo'")

接下来,我们来检查异常消息是否正确。

💡 在tests/func/test_api_exceptions.py测试模块中,添加新的测试函数test_start_tasks_db_raises()

# ch2/tasks_proj/tests/func/test_api_exceptions.py
# 代码片段

def test_start_tasks_db_raises():
    """确保遇到不支持的数据库会引起异常"""

    # 捕获异常
    with pytest.raises(ValueError) as excinfo:
        tasks.start_tasks_db('some/great/path', 'mysql')

    # 获取异常信息的第一个参数值
    exception_msg = excinfo.value.args[0]

    # 断言语句
    assert exception_msg == "db_type must be a 'tiny' or 'mongo'"
这让我们可以更仔细地查看异常。第7行中放在 as 后面的变量名excinfo包含着有关异常的具体信息,属于ExceptionInfo类。在第15行的断言语句中,是希望确保异常信息的第一个参数与预期字符串一致。

执行命令:

$ pytest func/test_api_exceptions.py::test_start_tasks_db_raises
输出结果:
============================= test session starts ==============================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pytest.in
i
collected 1 item                                                                

test_api_exceptions.py .                                                  [100%]

============================== 1 passed in 0.01s ===============================

2.5 匹配标记

Markers

pytest 提供了一个很酷的机制,允许在测试函数上打上自定义名称的标记(marker)。一个测试函数可以有多个标记,并且一个标记可以用于多个测试函数。这样可以当成一个测试套件(testsuites)一起运行。

假设我们现在需要运行冒烟测试(smoke test),以便对系统中是否存在重大缺陷有所了解。通常来说,冒烟测试不是完全彻底的测试套件,而是一个可以快速运行的测试子集,可以让开发和测试人员对系统的所有部分的健康状况有一个快速的了解。

添加标记

语法:@pytest.mark.标记名称

在 Tasks 项目添加冒烟测试套件,我们可以在一些测试函数上添加装饰器@mark.pytest.smoke。让我们给几个测试函数加上自定义的冒烟标记。

💡 在 ch2/tasks_proj/tests/functest_api_exceptions.py测试模块中,添加新的测试函数:

@pytest.mark.smoke
def test_list_raises():
    """list()接收到错误类型的参数时应当引起异常"""
    with pytest.raises(TypeError):
        tasks.list_tasks(owner=123)


@pytest.mark.get
@pytest.mark.smoke
def test_get_raises():
    """get()接收到错误类型的参数时应当引起异常"""
    with pytest.raises(TypeError):
        tasks.get(task_id='123')
备注:这里的 smokeget 标记的名称并不是 pytest 中要求的,可以根据实际需要取名就好。

基本用法

现在,让我们使用-m "smoke"选项,挑选标有@pytest.mark.smoke的测试函数来运行。

$ pytest func/test_api_exceptions.py -m "smoke" -v
解释:这里的-m "smoke" 表示匹配含有smoke标记的测试类和测试函数,注意标记表达式必须要用双引号"包围,不能使用单引号'-v是展示详细的输出结果。

输出结果:

========================== test session starts ===========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\env
s\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pyt
est.ini
collected 4 items / 2 deselected / 2 selected                             

test_api_exceptions.py::test_list_raises PASSED                     [ 50%]
test_api_exceptions.py::test_get_raises PASSED                      [100%]

============================ warnings summary ============================
test_api_exceptions.py:25
D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests\func\test_api_exception
s.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.smoke - is this a ty
po?  You can register custom marks to avoid this warning - for details, see
https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.smoke

test_api_exceptions.py:32
D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests\func\test_api_exception
s.py:32: PytestUnknownMarkWarning: Unknown pytest.mark.get - is this a typo
?  You can register custom marks to avoid this warning - for details, see h
ttps://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.get

test_api_exceptions.py:33
D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests\func\test_api_exception
s.py:33: PytestUnknownMarkWarning: Unknown pytest.mark.smoke - is this a ty
po?  You can register custom marks to avoid this warning - for details, see
https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.smoke

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
============== 2 passed, 2 deselected, 3 warnings in 0.01s ===============
可以看到,已经匹配并执行了2个含有smoke标记的测试函数:test_list_raises()test_get_raises()

现在,让我们使用-m "get"选项,挑选标有@pytest.mark.get的测试函数来运行。

$ pytest func/test_api_exceptions.py -m "get" -v 
解释:这里的-m "get" 表示匹配含有smoke标记的测试类和测试函数,注意标记表达式必须要用双引号"包围,不能使用单引号'-v表示展示详细的输出结果。

输出结果:

========================== test session starts ===========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\env
s\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pyt
est.ini
collected 4 items / 3 deselected / 1 selected                             

func/test_api_exceptions.py::test_get_raises PASSED                 [100%]

============================ warnings summary ============================
func\test_api_exceptions.py:25
  D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests\func\test_api_exception
s.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.smoke - is this a ty
po?  You can register custom marks to avoid this warning - for details, see
 https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.smoke

func\test_api_exceptions.py:32
  D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests\func\test_api_exception
s.py:32: PytestUnknownMarkWarning: Unknown pytest.mark.get - is this a typo
?  You can register custom marks to avoid this warning - for details, see h
ttps://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.get

func\test_api_exceptions.py:33
  D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests\func\test_api_exception
s.py:33: PytestUnknownMarkWarning: Unknown pytest.mark.smoke - is this a ty
po?  You can register custom marks to avoid this warning - for details, see
 https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.smoke

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
============== 1 passed, 3 deselected, 3 warnings in 0.01s ===============

综上,可以非常直观地看到,使用-m "smoke" 运行了两个标有@pytest.mark.smoke的测试函数,使用-m "get"运行了一个标有@pytest.mark.get的测试函数。

注册标记

另外,你应该注意到了,刚才的输出结果中,突然展示了一堆的warnings summary警告信息。不过别担心,它们不会影响测试的运行,可以暂时当作什么都没发生。只是pytest更推荐采用先注册再使用的方式,以便减少人工书写错误的可能性。因为哪怕标记名称写错了也会被pytest误以为是添加新的标记,因此可能导致组建测试子集出现遗漏。而如果提前注册了标记,打标记的时候可以获得智能提醒,防止出错。

如果确实感觉到厌烦,可以在测试目录ch2/tasks_proj/testspytest.ini配置文件中,加上以下配置信息:

[pytest]
markers =
    smoke: marks tests as smoke
    get
配置好之后,再次运行前面的含有smoke和get标记的命令,就不会再有标记相关的警告信息出现了。关于pytest.ini的更多内容详见【第六章:配置】。

匹配表达式

标记表达式中,还可以使用逻辑运算符 andornot来拼接多个标记名称。其中and表示“与”的意思,or表示“或”的意思,not表示“非”的意思。

执行命令:

$ pytest func/test_api_exceptions.py -m "smoke and get" -v
解释:-m "smoke and get" 表示匹配执行既含有smoke又含有get标记的测试函数;-v表示展示详细的输出结果。

输出结果:

========================== test session starts ===========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\env
s\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pyt
est.ini
collected 4 items / 3 deselected / 1 selected                             

func/test_api_exceptions.py::test_get_raises PASSED                 [100%]

==================== 1 passed, 3 deselected in 0.01s =====================
可以看到,在test_api_exceptions.py模块中,虽然一共有4个测试函数,但是同时含有smoke和get标记的只有test_get_raises()这一个测试函数满足标记表达式的条件,因此只有它一个被选中执行,其他3个测试函数都未被收集。

前面我们在标记表达式中使用and运算符。我们还可以使用not运算符。

$ pytest func/test_api_exceptions.py -m "smoke and not get" -v
解释:-m "smoke and not get" 表示匹配执行含有smoke并且不含get标记的测试函数;-v表示展示详细的输出结果。

输出结果:

========================== test session starts ===========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\env
s\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pyt
est.ini
collected 4 items / 3 deselected / 1 selected                             

func/test_api_exceptions.py::test_list_raises PASSED                [100%]

==================== 1 passed, 3 deselected in 0.01s =====================
可以看到,满足含有@pytest.mark.smoke并且不含@pytest.mark.get标记的测试函数这个条件的只有test_list_raises()函数,因此只有这一个函数被执行了。

冒烟测试

Filling Out the Smoke Test

之前的测试函数其实还不算合理的冒烟测试套件(Test Suites),因为实际上并没有连接数据库,或者添加任何task对象。当然,冒烟测试肯定可以做到这一点。

让我们编写两个测试函数,用来添加task对象,并将其中一个作为我们的冒烟测试套件的一部分。

💡 在ch2/tasks_proj/tests/func目录,添加新的测试模块test_add.py,并添加以下测试函数。

import pytest
import tasks
from tasks import Task


def test_add_returns_valid_id():
    """tasks.add(<valid task>) 应该返回一个整数"""
    # GIVEN an initialized tasks db
    # WHEN a new task is added
    # THEN returned task_id is of type int
    new_task = Task('do something')
    task_id = tasks.add(new_task)
    assert isinstance(task_id, int)


@pytest.mark.smoke
def test_added_task_has_id_set():
    """确保task_id字段被tasks.add()方法赋值了"""
    # GIVEN an initialized tasks db
    # AND a new task is added
    new_task = Task('sit in chair', owner='me', done=True)
    task_id = tasks.add(new_task)
    # WHEN task is retrieved
    task_from_db = tasks.get(task_id)
    # THEN task_id matches id field
    assert task_from_db.id == task_id

注意这两个测试函数中的注释都有一句GIVEN an initialized tasks db,但是当前测试用例中没有初始化好的数据库。我们可以定义一个专门用来在测试开始前初始化数据库,并在测试结束后清理数据库的装置(fixture)。

更多关于装置的知识详见【第三章:装置】

💡 在test_add.py模块中,添加新的装置。

# ch2/tasks_proj/tests/func/test_add.py

# 用来初始化和清理db的装置
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
    """测试之前连接数据库,测试之后断开数据库"""
    # Setup : start db
    tasks.start_tasks_db(str(tmpdir), 'tiny')
    yield # this is where the testing happens
    # Teardown : stop db
    tasks.stop_tasks_db()
第4行代码@pytest.fixture(autouse=True)中使用的 autouse 表示该文件中的所有测试函数都将使用fixture装置。yield 之前的代码在每个测试函数之前运行;yield之后的代码在测试函数执行之后运行。在必要的时候,yield还可以将数据返回给测试函数。

第5行代码def initialized_tasks_db(tmpdir):中用到的tmpdir装置是pytest的内置装置之一。

更多关于内置装置的知识详见【第4章:内置装置】。

在这里我们需要一些方法来设置用于测试的数据库,因此迫不及待地展示这个装置。当然,pytest 也支持古老的前置和后置功能,比如 unittest 和 nose 中所使用的 setup()teardown()setup_class()teardown_class()之类的,但是它们不够时尚。如果你感兴趣的话,可以参阅附录5的相关内容。

运行我们的冒烟测试套件。

$ pytest -m "smoke" -v
输出结果:
========================== test session starts ===========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\env
s\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pyt
est.ini
collected 12 items / 9 deselected / 3 selected                            

func/test_add.py::test_added_task_has_id_set PASSED                 [ 33%]
func/test_api_exceptions.py::test_list_raises PASSED                [ 66%]
func/test_api_exceptions.py::test_get_raises PASSED                 [100%]

==================== 3 passed, 9 deselected in 0.03s =====================
可以看到,来自三个不同模块的测试函数,只要都带有smoke冒烟标记,就能一起运行。

2.6 内置标记

我们前面使用的标记都是自定义的,其实pytest本身也自带了一些内置标记,常用的有 skipskipifxfailparametrize

标记跳过

Skipping Tests

我们来了解有关跳过测试的 skipskipif 标记,它们可以控制pytest跳过/略过不想运行的测试函数。

直接跳过 skip

语法:@pytest.mark.skip(reason='xxx')

假设,我们不确定tasks.unique_id()是如何工作的,每次调用它都返回不同的数字吗?还是说它只是一个数据库中不存在的数字?我们来编写一个测试函数,来验证以上的猜想。

💡 在tests/func目录创建新的测试模块test_unique_id_1.py,编写新的代码:

# ch2/tasks_proj/tests/func/test_unique_id_1.py

import pytest
import tasks

# 用来初始化和清理db的装置
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
    """测试之前连接数据库,测试之后断开数据库"""
    # Setup : start db
    tasks.start_tasks_db(str(tmpdir), 'tiny')
    yield # this is where the testing happens
    # Teardown : stop db
    tasks.stop_tasks_db()


def test_unique_id():
    """Calling unique_id() twice should return different numbers."""
    id_1 = tasks.unique_id()
    id_2 = tasks.unique_id()
    assert id_1 != id_2
执行命令:
$ pytest func/test_unique_id_1.py -v
输出结果:
============================== FAILURES ==============================
___________________________ test_unique_id ___________________________

    def test_unique_id():
        """Calling unique_id() twice should return different numbers.""
"
        id_1 = tasks.unique_id()
        id_2 = tasks.unique_id()
>       assert id_1 != id_2
E       assert 1 != 1

func\test_unique_id_1.py:19: AssertionError
====================== short test summary info =======================
FAILED func/test_unique_id_1.py::test_unique_id - assert 1 != 1
========================= 1 failed in 0.10s ==========================
哦豁,看来它并不会返回不同的数字。在进一步查看 API 进行确认,可以看到unique_id()函数的说明文档(api.py模块的第102行),明确说明了Return an integer that does not exist in the db.,表示它将返回一个在数据库中不存在的整数。

我们可以修复这个测试用例,但是现在要演示如何跳过测试,所以来编写新的测试函数,并且给它打上一个跳过标记:@pytest.mark.skip()

💡 在tests/func目录创建新的测试模块test_unique_id_2.py,编写新的代码:

# ch2/tasks_proj/tests/func/test_unique_id_1.py

import pytest
import tasks
from tasks import Task


# 用来初始化和清理db的装置
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
    """测试之前连接数据库,测试之后断开数据库"""
    # Setup : start db
    tasks.start_tasks_db(str(tmpdir), 'tiny')
    yield # this is where the testing happens
    # Teardown : stop db
    tasks.stop_tasks_db()


@pytest.mark.skip(reason='misunderstood the API')
def test_unique_id_1():
    """Calling unique_id() twice should return different numbers."""
    id_1 = tasks.unique_id()
    id_2 = tasks.unique_id()
    assert id_1 != id_2


def test_unique_id_2():
    """unique_id() should return an unused id."""
    ids = []
    ids.append(tasks.add(Task('one')))
    ids.append(tasks.add(Task('two')))
    ids.append(tasks.add(Task('three')))
    # grab a unique id
    uid = tasks.unique_id()
    # make sure it isn't in the list of existing ids
    assert uid not in ids
只需简单一步,在测试函数的上方添加@pytest.mark.skip()装饰器,就能够在pytest执行时跳过它。

这里在 skip() 中添加了reason参数,补充说明跳过的原因。虽然这个参数在skip中是可选的,但是注明理由将是一个非常推荐的习惯。

执行命令:

$ pytest func/test_unique_id_2.py -v
输出结果:
======================== test session starts =========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system
\envs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile:
 pytest.ini
collected 2 items                                                     

func/test_unique_id_2.py::test_unique_id_1 SKIPPED (misunde...) [ 50%]
func/test_unique_id_2.py::test_unique_id_2 PASSED               [100%]

==================== 1 passed, 1 skipped in 0.03s ====================

条件跳过 skipif

语法:@pytest.mark.skipif(condition_expr, reason='xxx')

假设,出于某些原因,我们认为第一个测试函数test_unique_id_1()本身应该是要执行的,只是目前时候未到,因为原计划是在tasks包的下个版本0.2.0中才具体实现被测功能。

在这种情况下,我们可以保留这个测试函数,并且使用skipif装饰器,当指定的条件时满足时就跳过,不满足时则照常运行。

💡 在tests/func目录创建新的测试模块test_unique_id_3.py,将之前的test_unique_id_2.py测试模块的代码全部拷贝进来,然后把跳过标记skip()修改为条件跳过标记skipif()。

# ch2/tasks_proj/tests/func/test_unique_id_3.py

import pytest
import tasks
from tasks import Task


# 用来初始化和清理db的装置
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
    """测试之前连接数据库,测试之后断开数据库"""
    # Setup : start db
    tasks.start_tasks_db(str(tmpdir), 'tiny')
    yield # this is where the testing happens
    # Teardown : stop db
    tasks.stop_tasks_db()


# 条件跳过
@pytest.mark.skipif(tasks.__version__ < '0.2.0',
                    reason='not supported until version 0.2.0')
def test_unique_id_1():
    """Calling unique_id() twice should return different numbers."""
    id_1 = tasks.unique_id()
    id_2 = tasks.unique_id()
    assert id_1 != id_2


def test_unique_id_2():
    """unique_id() should return an unused id."""
    ids = []
    ids.append(tasks.add(Task('one')))
    ids.append(tasks.add(Task('two')))
    ids.append(tasks.add(Task('three')))
    # grab a unique id
    uid = tasks.unique_id()
    # make sure it isn't in the list of existing ids
    assert uid not in ids
我们传递给skipif()的第一个参数是一个表达式,它可以是任何有效的 python 条件表达式。在这个测试函数中,我们是来判断tasks包的版本号是否小于0.2.0

当前tasks包的版本号是0.1.0,可以通过pip list命令查看当前已安装的task包的版本号。
在Windows系统中可以执行命令:

$ pip list | findstr tasks
在Windows系统中可以执行命令:
$ pip list | grep tasks

这里传递给skipif()的第一个参数是reason参数,补充说明跳过的原因。注意,这个参数在skip()中是可选的,但是在skipif()中则是必填的。建议为每个skip和skipif以后后面讲到的xfail中都注明理由。

让我们来运行test_unique_id_3.py模块中的测试函数。

$ pytest  func/test_unique_id_3.py -v
输出结果:
======================== test session starts =========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system
\envs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile:
 pytest.ini
collected 2 items                                                     

func/test_unique_id_3.py::test_unique_id_1 SKIPPED (not sup...) [ 50%]
func/test_unique_id_3.py::test_unique_id_2 PASSED               [100%]

==================== 1 passed, 1 skipped in 0.03s ====================
可以看到,因为当前安装的tasks包的版本是0.1.0,因此skipif的条件成立,会跳过该测试函数,不会被执行。这里的SKIPPED标志也证明了test_unique_id_1()被跳过了,另外一个测试函数则正常执行,不受影响。

我们还可以加上-rs选项运行,以便查看跳过的具体原因。

$ pytest func/test_unique_id_3.py -v -rs
这里的-rs选项表示在输出结果中额外展示skipped的概信息。

输出结果:

========================== test session starts ===========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\env
s\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pyt
est.ini
collected 2 items                                                         

func/test_unique_id_3.py::test_unique_id_1 SKIPPED (not support...) [ 50%]
func/test_unique_id_3.py::test_unique_id_2 PASSED                   [100%]

======================== short test summary info =========================
SKIPPED [1] func\test_unique_id_3.py:18: not supported until version 0.2.0
====================== 1 passed, 1 skipped in 0.03s ======================
可以看到,第13行展示了SKIPPED跳过标志的概要信息,并且包含了在测试函数的装饰器@pytest.mark.skipif()中所指定的reason参数的值。

标记失败

Marking Tests as Expecting to Fail

如果使用 xfail 标记,表示预期测试函数的运行结果应当是失败的,然后 pytest 会运行它们。如果实际执行确实失败了,结果标志是XFAIL;如果实际执行通过了,结果标志则是XPASS

语法:@pytest.mark.xfail(condition_expr, reason='xxx')

xfail标记的参数:

  • condition_expr是条件表达式,必填,可以是任何合法的python逻辑表达式。
  • reason是原因,必填,可以用来描述预期失败的原因。
  • strict是严格模式,选填,值为布尔值。默认值为False表示非严格模式,如果等于True则表示启用严格模式。启用状态下,XFAIL结果还是XFAIL,但XPASS结果会视为FAILED,也就说期望明明时失败但实际执行并没有失败,在严格模式下会认定这个XPASS结果最终是FAILED失败的。

普通模式

预期失败并且实际执行失败得XFAIL,预期失败但是实际执行成功得XPASS

我们通过一个例子来演示xfail标记失败的具体的用法。

💡 在tests/func目录创建新的测试模块test_unique_id_4.py,将之前的test_unique_id_3.py测试模块的代码全部拷贝进来,并且把test_unique_id_1的标记改成xfail。然后再添加两个新的带有xfail标记的测试函数。

# ch2/tasks_proj/tests/func/test_unique_id_4.py

import pytest
import tasks
from tasks import Task

# 用来初始化和清理db的装置
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
    """测试之前连接数据库,测试之后断开数据库"""
    # Setup : start db
    tasks.start_tasks_db(str(tmpdir), 'tiny')
    yield # this is where the testing happens
    # Teardown : stop db
    tasks.stop_tasks_db()


@pytest.mark.xfail(tasks.__version__ < '0.2.0',
                   reason='not supported until version 0.2.0')
def test_unique_id_1():
    """ 调用 unique_id() 两次的的返回数字应当不相同 """
    id_1 = tasks.unique_id()
    id_2 = tasks.unique_id()
    assert id_1 != id_2

def test_unique_id_2():
    """unique_id() should return an unused id."""
    ids = []
    ids.append(tasks.add(Task('one')))
    ids.append(tasks.add(Task('two')))
    ids.append(tasks.add(Task('three')))
    # grab a unique id
    uid = tasks.unique_id()
    # make sure it isn't in the list of existing ids
    assert uid not in ids

@pytest.mark.xfail()
def test_unique_id_is_a_duck():
    """演示 xfail """
    uid = tasks.unique_id()
    assert uid == 'a duck'


@pytest.mark.xfail()
def test_unique_id_not_a_duck():
    """演示 xpass """
    uid = tasks.unique_id()
    print('***********', uid)
    assert uid != 'a duck'
代码解释:第一个测试函数依旧是之前写过的test_unique_id_1()函数,只不过现在使用了xfail标记。接下来的两个测试函数也使用了 xfail 标记,并且它们的代码几乎相同,唯一区别只有断言语句中的 ==!= ,因此它们必定水火不容,有一个成功则另一个会失败。

执行命令:

$ pytest func/test_unique_id_4.py -v
输出结果:
======================== test session starts =========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system
\envs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile:
 pytest.ini
collected 4 items                                                     

func/test_unique_id_4.py::test_unique_id_1 XFAIL (not suppo...) [ 25%]
func/test_unique_id_4.py::test_unique_id_2 PASSED               [ 50%]
func/test_unique_id_4.py::test_unique_id_is_a_duck XFAIL        [ 75%]
func/test_unique_id_4.py::test_unique_id_not_a_duck XPASS       [100%]

============== 1 passed, 2 xfailed, 1 xpassed in 0.08s ===============
结果说明:第9行和第11行的XFAIL表示预期失败,并且实际执行真的失败了;第12行的 XPASS 表示预期失败,但是实际执行竟然通过了。很显然当前没有使用strict参数,所以当前是非严格模式下执行的。

严格模式

预期失败并且实际执行失败得XFAIL,预期失败但是实际执行成功得FAILED

通过添加参数实现
如果无法接受XPASS的情形(明明预期失败,结果反倒通过),那么可以在xfail标记中指定参数strict=True

💡 在tests/func目录创建新的测试模块test_unique_id_4.py模块中新增两个测试函数:

# ch2/tasks_proj/tests/func/test_unique_id_4.py
# 代码片段


# 启用严格模式:XFAIL依旧是XFAIL
@pytest.mark.xfail(strict=True)
def test_unique_id_is_a_duck_strict():
    """ 演示严格模式下的XFAIL """
    uid = tasks.unique_id()
    assert uid == 'a duck'


# 启用严格模式:XPASS将变成FAILED
@pytest.mark.xfail(strict=True)
def test_unique_id_not_a_duck_strict():
    """ 演示严格模式下的XPASS """
    uid = tasks.unique_id()
    assert uid != 'a duck'
代码说明:代码逻辑和前面两个函数一致,只是函数名称后加了_strict尾缀以示区分,并在xfail标记中都增加了strict=True参数来启用严格模式。

执行命令:

$ pytest func/test_unique_id_4.py -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 6 items                                                       

func/test_unique_id_4.py::test_unique_id_1 XFAIL (not support...) [ 16%]
func/test_unique_id_4.py::test_unique_id_2 PASSED                 [ 33%]
func/test_unique_id_4.py::test_unique_id_is_a_duck XFAIL          [ 50%]
func/test_unique_id_4.py::test_unique_id_not_a_duck XPASS         [ 66%]
func/test_unique_id_4.py::test_unique_id_is_a_duck_strict XFAIL   [ 83%]
func/test_unique_id_4.py::test_unique_id_not_a_duck_strict FAILED [100%]

=============================== FAILURES ===============================
___________________ test_unique_id_not_a_duck_strict ___________________
[XPASS(strict)]
======================= short test summary info ========================
FAILED func/test_unique_id_4.py::test_unique_id_not_a_duck_strict
========== 1 failed, 1 passed, 3 xfailed, 1 xpassed in 0.09s ===========
可以看到,在严格模式下,原本XFAIL的结果依旧是XFAIL;原本XPASS的结果则变成FAILED

通过配置文件实现
如果只是将个别测试函数的执行模式设定为严格模式,就可以在xfail标记中指定strict=True参数。如果整个项目级别的xfail标记都要使用严格模式时,则可以通过修改 pytest.ini 配置文件,将标记为xfail的但实际却执行通过的XPASS一律测试判定成FAILED。

在 pytest.ini 中的严格模式的配置方法:

[pytest]
xfail_strict=true

更多关于pytest.ini配置的知识,详见【第六章:配置】

标记参数化

Parametrized Testing

语法:@pytest.mark.parametrize(argnames, argvalues, ids, indirect)

在软件测试中,通过给测试函数一定的输入,并检查输出是否符合预期是一种常规操作。然而,每次调用都传入一组值并且做一次正确性检查,这种做法并不足以完备地测试一个目标函数。

参数化测试(parametrized testing)是一种在同一个测试函数中发送多组数据的方法。如果其中任何一组数据失败,结果都会 pytest 记录下来。

普通测试函数
💡 在 ch2/tasks_proj/tests/func 中新增test_add_variety.py测试模块:

# ch2/tasks_proj/tests/func/test_add_variety.py

import pytest
import tasks
from tasks import Task


# 装置
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
    """Connect to db before testing, disconnect after."""
    tasks.start_tasks_db(str(tmpdir), 'tiny')
    yield
    tasks.stop_tasks_db()


# 辅助函数
def equivalent(t1, t2):
    """Check two tasks for equivalence."""
    # Compare everything but the id field
    return ((t1.summary == t2.summary) and
    (t1.owner == t2.owner) and
    (t1.done == t2.done))


# 普通测试函数:未使用参数化
def test_add_1():
    """tasks.get() using id returned from add() works."""
    task = Task('breathe', 'BRIAN', True)
    task_id = tasks.add(task)
    t_from_db = tasks.get(task_id)
    # everything but the id should be the same
    assert equivalent(t_from_db, task)
代码解释:第17行,函数equivalent()用来帮忙检查除了 id 字段之外的所有字段内容;第27行,测试用例中,当一个 Task 对象实例化后,它的 id 字段被设置为 None,在它被添加到数据库,然后查询结果后,它的id字段将被赋值。

执行命令:

$ pytest func/test_add_variety.py::test_add_1 -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item                                                        

func/test_add_variety.py::test_add_1 PASSED                       [100%]

========================== 1 passed in 0.02s ===========================
这个测试虽然看起来很合理,但是,毕竟它只是测试了组数据('breathe', 'BRIAN', True)的结果。如果想在一个测试函数中测试很多个不同的task对象呢?我们可以使用parametrize标记来给测试函数传递大量的数据。

参数化语法

语法:@pytest.mark.parametrize(argnames, argvalues, ids, indirect)

  • argnames:必填,表示参数名,可以是字符串、字符串列表和字符串元组。
  • argvalues:必填,表示参数值数据集(dataset),是一个可迭代对象。
  • ids:选填,表示测试标识符,长度和数据集的相同。可以是可迭代对象或者可调用对象。
  • indirect:选填,表示是否接收装置的数据(详见第三章)。

测试标识符(test identifier)在 pytest 术语中也称为节点(node)。

单参数标记写法

  1. 参数名argnames可以填一个字符串;
  2. 参数值数据集argvalues可以填一个列表或者元组;
  3. 标识符列表ids选填。可以填一个列表,元素个数必须和数据集的相同。

示例1:数据集可以使用一维列表
parametrize("data", ["a", 666, True])

示例2:数据集可以使用一维元组
@parametrize("data", ("a", 666, True))

示例3:可以指定标识符列表
@parametrize("data", ["a", 666, True], ids=["str", "int", "bool"])

示例4:数据集中使用param()函数生成,同时绑定标识符
@parametrize("data", [pytest.param("a", id="test str"), pytest.param(666, "test int"), pytest.param(True, "test bool")])

多参数标记写法

  1. 参数名argnames填一个字符串,多个名称之间用英文逗号分隔。
  2. 参数值数据集argvalues可以填一个嵌套的列表或者元组,其中每个元素表示参数值的一组数据,参数值的个数和位置要跟参数名称一一对应;或者是调用pytest.param()函数返回的一组数据。
  3. 标识符列表ids可填可不填,如果填则填入一个一维列表,元素个数必须和数据集的相同。

示例1:参数名可以使用字符串,参数值数据集可以使用嵌套列表
@pytest.mark.parametrize("name,passwd", [["a", 111], ["b", 222]])

示例2:参数名可以使用字符串列表,参数值数据集可以直接使用嵌套列表
@pytest.mark.parametrize(["name", "passwd"], [["a", 111], ["b", 222]])

示例3:参数名可以使用字符串元组,参数值数据集可以直接使用嵌套元组
@pytest.mark.parametrize(("name", "passwd"), (("a", 111), ("b", 222)))

示例4:可以使用一个字符串列表(可迭代对象)作为标识符
@pytest.mark.parametrize(("name", "passwd"), (("a", 111), ("b", 222)), ids=["test a", "test b"])

示例5:可以使用一个函数名称(可调用对象)作为标识符
@pytest.mark.parametrize(("name", "passwd"), (("a", 111), ("b", 222)), ids=gen_ids)

示例6:参数名可以使用字符串元组,参数值数据项可以通过函数生成,同时标识符
@pytest.mark.parametrize(["name", "passwd"], [pytest.param(["a", 111], id="login a"), pytest.param(["b", 222], id="login b"])

字面数据集

parametrize可以定义多个参数,现在用元组的形式作为数据集来传递参数值,每个参数值都是python中的字面量值,来看看这种数据集的参数化具体是如何工作的。

💡 在 ch2/tasks_proj/tests/func 中的test_add_variety.py测试模块中,添加新的测试函数test_add_3()

# ch2/tasks_proj/tests/func/test_add_variety.py
# 代码片段


# def test_add_1...


@pytest.mark.parametrize('summary, owner, done',
                         [('sleep', None, False),
                          ('wake', 'brian', False),
                          ('breathe', 'BRIAN', True),
                          ('eat eggs', 'BrIaN', False),
                          ])
def test_add_2(summary, owner, done):
    """Demonstrate parametrize with multiple parameters."""
    task = Task(summary, owner, done)
    task_id = tasks.add(task)
    t_from_db = tasks.get(task_id)
    assert equivalent(t_from_db, task)
执行命令:
$ pytest func/test_add_variety.py::test_add_2 -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 4 items                                                       

func/test_add_variety.py::test_add_2[sleep-None-False] PASSED     [ 25%]
func/test_add_variety.py::test_add_2[wake-brian-False] PASSED     [ 50%]
func/test_add_variety.py::test_add_2[breathe-BRIAN-True] PASSED   [ 75%]
func/test_add_variety.py::test_add_2[eat eggs-BrIaN-False] PASSED [100%]

========================== 4 passed in 0.05s ===========================
可以看到,第9~12行结果比平常的测试结果多了方括号[]内容,表示这个测试函数的标识符,在这种情况下,它正好就是每个测试函数的参数项。因此,在数据集中,如果使用的是可以转换为字符串类型的数据项时,parametrize标记的可选参数ids,即节点(测试标识符),将默认使用参数项的字面值来展示,从而提高测试结果的可读性。这是使用字面数据集的好处。

在终端中使用标识符
我们也可以在pytest命令中,在测试函数后面使用测试标识符。

$ pytest func/test_add_variety.py::test_add_2[sleep-None-False] -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item                                                        

func/test_add_variety.py::test_add_2[sleep-None-False] PASSED     [100%]

========================== 1 passed in 0.03s ===========================

如果标识符中有空格,在命令中一定要使用双引号"包围起来。

$ pytest "func/test_add_variety.py::test_add_2[eat eggs-BrIaN-False]" -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item                                                        

func/test_add_variety.py::test_add_2[eat eggs-BrIaN-False] PASSED [100%]

========================== 1 passed in 0.03s ===========================

对象数据集

💡 在 ch2/tasks_proj/tests/func 中的test_add_variety.py测试模块中添加新的测试函数test_add_3()

# ch2/tasks_proj/tests/func/test_add_variety.py
# 代码片段


# def test_add_1...
# def test_add_2...


# 使用对象数据集
@pytest.mark.parametrize('task',
                         [Task('sleep', done=True),
                          Task('wake', 'brian'),
                          Task('breathe', 'BRIAN', True),
                          Task('exercise', 'BrIaN', False)
                          ])
def test_add_3(task):
    """Demonstrate parametrize with one parameter."""
    task_id = tasks.add(task)
    t_from_db = tasks.get(task_id)
    assert equivalent(t_from_db, task)
代码解释:第11行中函数传入的task参数,其实就是第5行中parametrize标记的第一个参数task,并且parametrize的第二个参数就是task参数的值列表。这样一来,pytest 将为值列表中的每个 task 参数值都执行一次测试,并输出对应的测试结果。

执行命令:

$ pytest func/test_add_variety.py::test_add_3 -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 4 items                                                       

func/test_add_variety.py::test_add_3[task0] PASSED                [ 25%]
func/test_add_variety.py::test_add_3[task1] PASSED                [ 50%]
func/test_add_variety.py::test_add_3[task2] PASSED                [ 75%]
func/test_add_variety.py::test_add_3[task3] PASSED                [100%]

========================== 4 passed in 0.05s ===========================
可以看到,通过parametrize标记的这种用法可以实现测试很多个不同的task变量值的需求。

数据集外移

💡 在 ch2/tasks_proj/tests/func 中的test_add_variety.py测试模块中,将参数化的数据 task 列表移动到函数外,定义成全局变量tasks_to_try,放在模块顶部位置;添加新的测试函数test_add_4()

# ch2/tasks_proj/tests/func/test_add_variety.py
# 代码片段

# 数据集外移
tasks_to_try = (Task('sleep', done=True),
                Task('wake', 'brian'),
                Task('wake', 'brian'),
                Task('breathe', 'BRIAN', True),
                Task('exercise', 'BrIaN', False)
                )


# def test_add_1...
# def test_add_2...
# def test_add_3...


@pytest.mark.parametrize('task', tasks_to_try)
def test_add_4(task):
    """Slightly different take."""
    task_id = tasks.add(task)
    t_from_db = tasks.get(task_id)
    assert equivalent(t_from_db, task)
执行命令:
$ pytest func/test_add_variety.py::test_add_4 -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 5 items                                                       

func/test_add_variety.py::test_add_4[task0] PASSED                [ 20%]
func/test_add_variety.py::test_add_4[task1] PASSED                [ 40%]
func/test_add_variety.py::test_add_4[task2] PASSED                [ 60%]
func/test_add_variety.py::test_add_4[task3] PASSED                [ 80%]
func/test_add_variety.py::test_add_4[task4] PASSED                [100%]

========================== 5 passed in 0.05s ===========================
可以看到,把参数值数据移到测试函数的外面,可以让测试函数看起来更加简练。但是,输出结果中的值列表却变得很难理解,只看到类似[task0]这种,至于task0所指代的具体值是什么是一个谜,可读性大大降低了。

定制标识符

通过ids参数定制
字面数据集可读性很好,对象数据集也很实用。为了弥补对象数据集比较鸡肋的结果可读性,可以使用 parametrize()标记的可选参数ids,为每组数据设置对应的自定义的标识符。

ids参数的值是一个与数据集元素数量相同的字符串列表。在前面的test_add_variety.py中,我们将数据集赋给了变量 tasks_to_try,我们可以用它来生成标识符列表。

💡 在 ch2/tasks_proj/tests/func 中的test_add_variety.py测试模块中,在模块顶部,tasks_to_try变量后面,添加task_ids变量的代码;添加新的测试函数test_add_5()

# ch2/tasks_proj/tests/func/test_add_variety.py
# 代码片段

# 数据集外移
tasks_to_try = (Task('sleep', done=True),
                Task('wake', 'brian'),
                Task('wake', 'brian'),
                Task('breathe', 'BRIAN', True),
                Task('exercise', 'BrIaN', False)
                )
# 节点列表
task_ids = ['Task({},{},{})'.format(t.summary, t.owner, t.done)
            for t in tasks_to_try]


# def test_add_1...
# def test_add_2...
# def test_add_3...
# def test_add_4...


@pytest.mark.parametrize('task', tasks_to_try, ids=task_ids)
def test_add_5(task):
    """ 演示 ids """
    task_id = tasks.add(task)
    t_from_db = tasks.get(task_id)
    assert equivalent(t_from_db, task)
代码解释:第22行的parametrize标记中,'task'表示用于参数化的参数变量,tasks_to_try变量是参数值的数据集,ids=task_ids是设置自定义的标识符列表。

执行命令,看看效果如何:

$ pytest func/test_add_variety.py::test_add_5 -v
输出结果:
=============================== test session starts ================================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\envs\mytasks\
scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: pytest.ini
collected 5 items                                                                   

func/test_add_variety.py::test_add_5[Task(sleep,None,True)] PASSED            [ 20%]
func/test_add_variety.py::test_add_5[Task(wake,brian,False)0] PASSED          [ 40%]
func/test_add_variety.py::test_add_5[Task(wake,brian,False)1] PASSED          [ 60%]
func/test_add_variety.py::test_add_5[Task(breathe,BRIAN,True)] PASSED         [ 80%]
func/test_add_variety.py::test_add_5[Task(exercise,BrIaN,False)] PASSED       [100%]

================================ 5 passed in 0.05s =================================
可以看到,测试结果中的可读性大大提高了,每条测试用例的输入都一目了然,万一出现问题调试起来也非常方便。

正如终端标识符中所讲述的,我们还可以尝试将其中第12行的内容func/test_add_variety.py::test_add_5[Task(exercise,BrIaN,False)],作为pytest参数放到终端命令行中单独执行。注意这里的引号必不可少,否则,标识符中的各种括号会让命令行懵圈。

$ pytest "func/test_add_variety.py::test_add_5[Task(exercise,BrIaN,False)]" -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item                                                        

func/test_add_variety.py::test_add_5[Task(exercise,BrIaN,False)] PASSED [
100%]

========================== 1 passed in 0.03s ===========================

通过param()函数定制

根据前面学过的知识,parametrize标记的argvalues数据集参数,可以支持传入字面数据集,比如一个包含多个数值的元组对象,这时候数据集只包含纯粹的数据项,对应的标识符无需额外指定,默认就是数据项本身。

数据集参数argvalues还可以支持传入对象数据集,这时候数据集也只包含纯粹的数据项,对应的标识符默认是对象名称,而且存在一个痛点就是对象字段被隐藏了,导致默认标识符的可读性很差。在这种情况下,就可以通过使用parametrize标记的ids参数来额外指定标识符列表,并且每个标识符和数据项的个数相同,从而实现一一对应。

数据集参数argvalues还支持一种写法,通过调用pytest.param()函数生成每个数据项,并且实现同时绑定数据项和标识符。具体写法是[pytest.param(argvalue, id="xxx"),],在参数值的右边,通过id绑定标识符。这种方式同样可以解决默认标识符的可读性差的痛点。

💡 在 ch2/tasks_proj/tests/func 中的test_add_variety.py测试模块中,添加新的测试函数test_add_6()

# ch2/tasks_proj/tests/func/test_add_variety.py
# 代码片段

# def test_add_1...
# def test_add_2...
# def test_add_3...
# def test_add_4...
# def test_add_5...


@pytest.mark.parametrize('task', [
    pytest.param(Task('create'), id='只传 summary'),
    pytest.param(Task('inspire', 'Michelle'), id='summary/owner'),
    pytest.param(Task('encourage', 'Michelle', True), id='summary/owner/done')])
def test_add_6(task):
    """演示 pytest.param 绑定数据项argvalue和标识符id"""
    task_id = tasks.add(task)
    t_from_db = tasks.get(task_id)
    assert equivalent(t_from_db, task)
执行后效果:
$ pytest func/test_add_variety.py::test_add_6 -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 3 items                                                       

func/test_add_variety.py::test_add_6[\u53ea\u4f20 summary] PASSED [ 33%]
func/test_add_variety.py::test_add_6[summary/owner] PASSED        [ 66%]
func/test_add_variety.py::test_add_6[summary/owner/done] PASSED   [100%]

========================== 3 passed in 0.05s ===========================
妙哇!当测试函数标识符无法从参数值中自动获取的时候,这一招非常给力。你可以将标识符定制成任何一看就懂的信息。

这里的标识符当然可以使用中文描述,只不过目前pytest的标识符的编码导致中文展示不够良好,正如刚刚的输出结果的第9行中的\u53ea\u4f20字样。

我们可以在下面的根目录/ch2/tasks_proj/的conftest.py插件文件中,编写一个钩子函数,来改变标识符的字符集从而修复这个中文字符显示成乱码的问题。

# ch2/tasks_proj/tests/conftest.py
# 代码片段

def pytest_collection_modifyitems(items):
    """
    修复中文显示问题
    """
    for item in items:
        item.name = item.name.encode("utf-8").decode("unicode_escape")
        item._nodeid = item.nodeid.encode("utf-8").decode("unicode_escape")

修改完毕后,再次执行之前的命令:

$ pytest func/test_add_variety.py::test_add_6 -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 3 items                                                       

func/test_add_variety.py::test_add_6[只传 summary] PASSED         [ 33%]
func/test_add_variety.py::test_add_6[summary/owner] PASSED        [ 66%]
func/test_add_variety.py::test_add_6[summary/owner/done] PASSED   [100%]

========================== 3 passed in 0.04s ===========================
可以看到,第9行中,原来的\u53ea\u4f20字样已经成功显示为中文“只传”字样了。

更多关于钩子函数的内容详见【第五章:插件】。

类的参数化

前面演示的都是如何给测试函数(test function)做参数化,pytest还支持将parametrize()应用到类的身上。这样一来,pytest执行时,相同的数据集将被传递给类中的所有测试方法。

💡 在 ch2/tasks_proj/tests/func 中的test_add_variety.py测试模块中,添加新的测试类TestAdd

# ch2/tasks_proj/tests/func/test_add_variety.py
# 代码片段

# def test_add_1...
# def test_add_2...
# def test_add_3...
# def test_add_4...
# def test_add_5...
# def test_add_6...


# 类的参数化
@pytest.mark.parametrize('task', tasks_to_try, ids=task_ids)
class TestAdd():
    """演示类的参数化"""

    def test_equivalent(self, task):
        """相同的测试逻辑,只不过放在类中"""
        task_id = tasks.add(task)
        t_from_db = tasks.get(task_id)
        assert equivalent(t_from_db, task)

    def test_valid_id(self, task):
        """可以沿用同一份数据"""
        task_id = tasks.add(task)
        t_from_db = tasks.get(task_id)
        assert t_from_db.id == task_id
执行命令,看看测试类的参数化效果:
$ pytest func/test_add_variety.py::TestAdd -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 10 items                                                      

func/test_add_variety.py::TestAdd::test_equivalent[Task(sleep,None,True)]
PASSED [ 10%]
func/test_add_variety.py::TestAdd::test_equivalent[Task(wake,brian,False)
0] PASSED [ 20%]
func/test_add_variety.py::TestAdd::test_equivalent[Task(wake,brian,False)
1] PASSED [ 30%]
func/test_add_variety.py::TestAdd::test_equivalent[Task(breathe,BRIAN,Tru
e)] PASSED [ 40%]
func/test_add_variety.py::TestAdd::test_equivalent[Task(exercise,BrIaN,Fa
lse)] PASSED [ 50%]
func/test_add_variety.py::TestAdd::test_valid_id[Task(sleep,None,True)] P
ASSED [ 60%]
func/test_add_variety.py::TestAdd::test_valid_id[Task(wake,brian,False)0]
PASSED [ 70%]
func/test_add_variety.py::TestAdd::test_valid_id[Task(wake,brian,False)1]
PASSED [ 80%]
func/test_add_variety.py::TestAdd::test_valid_id[Task(breathe,BRIAN,True)
] PASSED [ 90%]
func/test_add_variety.py::TestAdd::test_valid_id[Task(exercise,BrIaN,Fals
e)] PASSED [100%]

========================== 10 passed in 0.09s ==========================
可以看到,测试类经过参数化后,类中的所有测试方法都将使用数据集执行参数化测试。

标记夹具

语法:@pytest.mark.usefixtures("FIXTURENAME")

2.7 测试子集

Running a Subset of Tests

我已经了解了过如何在测试中添加标记,以及如何根据标记来执行测试子集(Subset),其实还有多种方式来实现挑选并运行测试子集,比如可以运行全部的测试函数,也可以挑选运行单个目录、模块、类,甚至是类中的某个测试函数。

到目前为止,我们演示过的例子都是使用测试模块(module)和测试函数(function),还未曾见过测试类(class),别担心,这一小节就能如愿以偿了。

备注:在本章中执行终端命令的基准目录为测试目录,即ch2/tasks_proj/tests

挑选测试目录

A Single Directory

测试目录(test directory)是指包含测试模块文件的目录。要挑选运行一个目录中的所有测试函数,将目录的相对路径作为pytest命令的参数即可。

语法:pytest 目录路径

$ pytest ./func --tb=no
备注:./func表示tests/func目录的相对路径,--tb=no表示屏蔽执行失败的报错信息。

输出结果:

========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 17 items                                                      

func\test_add.py ..                                               [ 11%]
func\test_api_exceptions.py ....                                  [ 35%]
func\test_unique_id_1.py F                                        [ 41%]
func\test_unique_id_2.py s.                                       [ 52%]
func\test_unique_id_3.py s.                                       [ 64%]
func\test_unique_id_4.py x.xXxF                                   [100%]

======================= short test summary info ========================
FAILED func/test_unique_id_1.py::test_unique_id - assert 1 != 1
FAILED func/test_unique_id_4.py::test_unique_id_not_a_duck_strict
===== 2 failed, 9 passed, 2 skipped, 3 xfailed, 1 xpassed in 0.11s =====

挑选测试模块

A Single Test File/Module

测试模块是指包含测试函数的一个python文件(以.py结尾)。如果要运行某个测试模块,将模块文件的相对路径作为pytest命令的参数即可。

语法:pytest 模块路径.py

$ pytest func/test_add.py
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 2 items                                                       

func/test_add.py::test_add_returns_valid_id PASSED                [ 50%]
func/test_add.py::test_added_task_has_id_set PASSED               [100%]

========================== 2 passed in 0.03s ===========================

挑选测试函数

A Single Test Function

测试函数(test function)是指在测试模块中,在类外定义的函数。要运行测试模块中的单个测试函数,在模块相对路径和名称后面添加 :: 和函数名称。

语法:pytest 模块路径.py::函数名称

执行命令:

$ pytest func/test_add.py::test_add_returns_valid_id
执行结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item                                                        

func\test_add.py .                                                [100%]

========================== 1 passed in 0.02s ===========================

使用-v选项,就可以看到具体运行的是哪个函数。

$ pytest func/test_add.py::test_add_returns_valid_id -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item                                                        

func/test_add.py::test_add_returns_valid_id PASSED                [100%]

========================== 1 passed in 0.02s ===========================

挑选测试类

A Single Test Class

测试类(test class)是指包含测试方法的类。测试方法其实就是放在测试类中的测试函数。测试类是组织测试函数的一种高效的方式。

语法:pytest 模块路径.py::类名

💡 在 ch2/tasks_proj/tests/func 中新增test_api_update.py测试模块,并添加新的测试类和测试方法:

import pytest
import tasks


# 测试类
class TestUpdate:
    """Test expected exceptions with tasks.update()."""

    # 测试方法
    def test_bad_id(self):
        """A non-int id should raise an excption."""
        with pytest.raises(TypeError):
            tasks.update(task_id={'dict instead': 1},
            task=tasks.Task())


    # 测试方法
    def test_bad_task(self):
        """A non-Task task should raise an excption."""
        with pytest.raises(TypeError):
            tasks.update(task_id=1, task='not a task')
由于这两个测试函数都是用来测试tasksupdate()功能,因此将它们放在一个测试类中合情合理。

要想运行整个测试类,可以参照测试函数的执行命令,在::后面接上类名。

执行命令:

$ pytest func/test_api_update.py::TestUpdate
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 2 items                                                       

func\test_api_update.py ..                                        [100%]

========================== 2 passed in 0.01s ===========================
可以看到,运行测试类,会将类中的所有测试方法全部执行。

挑选测试方法

A Single Test Method of a Test Class

语法:pytest 模块路径.py::类名::方法名

如果你不想运行整个测试类,而只是运行某个特定方法,只需要在类名后面继续添加::以及方法名称。

$ pytest func/test_api_update.py::TestUpdate::test_bad_id
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 1 item                                                        

func\test_api_update.py .                                         [100%]

========================== 1 passed in 0.01s ===========================

温馨提示:如何挑选测试目录、模块、函数、类和方法来运行测试子集的语法其实不需要死记硬背,当运行pytest时加上-v 选项后,会在输出结果中列出每个测试函数/方法的语法。

执行命令:
输出结果:

========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 19 items                                                      

func/test_add.py::test_add_returns_valid_id PASSED                [  5%]
func/test_add.py::test_added_task_has_id_set PASSED               [ 10%]
func/test_api_exceptions.py::test_add_raises PASSED               [ 15%]
func/test_api_exceptions.py::test_start_tasks_db_raises PASSED    [ 21%]
func/test_api_exceptions.py::test_list_raises PASSED              [ 26%]
func/test_api_exceptions.py::test_get_raises PASSED               [ 31%]
func/test_api_update.py::TestUpdate::test_bad_id PASSED           [ 36%]
func/test_api_update.py::TestUpdate::test_bad_task PASSED         [ 42%]
func/test_unique_id_1.py::test_unique_id FAILED                   [ 47%]
func/test_unique_id_2.py::test_unique_id_1 SKIPPED (misunders...) [ 52%]
func/test_unique_id_2.py::test_unique_id_2 PASSED                 [ 57%]
func/test_unique_id_3.py::test_unique_id_1 SKIPPED (not suppo...) [ 63%]
func/test_unique_id_3.py::test_unique_id_2 PASSED                 [ 68%]
func/test_unique_id_4.py::test_unique_id_1 XFAIL (not support...) [ 73%]
func/test_unique_id_4.py::test_unique_id_2 PASSED                 [ 78%]
func/test_unique_id_4.py::test_unique_id_is_a_duck XFAIL          [ 84%]
func/test_unique_id_4.py::test_unique_id_not_a_duck XPASS         [ 89%]
func/test_unique_id_4.py::test_unique_id_is_a_duck_strict XFAIL   [ 94%]
func/test_unique_id_4.py::test_unique_id_not_a_duck_strict FAILED [100%]

======================= short test summary info ========================
FAILED func/test_unique_id_1.py::test_unique_id - assert 1 != 1
FAILED func/test_unique_id_4.py::test_unique_id_not_a_duck_strict
==== 2 failed, 11 passed, 2 skipped, 3 xfailed, 1 xpassed in 0.11s =====
可以看到,第15和16行分别列出了挑选TestUpdate类中的test_bad_id和test_bad_task两个测试方法。其他测试函数的语法同理。

匹配关键字

A Set of Tests Based on Test Name

在第一章中介绍了-k EXPRESSION 选项的用法,支持按照关键字表达式来匹配并运行测试子集,该表达式将目标关键字作为测试类和函数名称的子字符串进行匹配,并且还可以在关键字表达式中使用and,or,not运算符来创建复杂的表达式。

例如,我们可以运行所有名称中包含 _raise 的测试类或测试函数:

$ pytest -k "_raises" -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 25 items / 21 deselected / 4 selected                         

func/test_api_exceptions.py::test_add_raises PASSED               [ 25%]
func/test_api_exceptions.py::test_start_tasks_db_raises PASSED    [ 50%]
func/test_api_exceptions.py::test_list_raises PASSED              [ 75%]
func/test_api_exceptions.py::test_get_raises PASSED               [100%]

=================== 4 passed, 21 deselected in 0.03s ===================
可以看到,已经匹配并执行了4个名称中含有_raises的测试函数。

我们还可以使用 and 和 not 来剔除当前测试子集中的 test_start_tasks_db_raises() 函数:

$ pytest -k "_raises and not start" -v
输出结果:
========================= test session starts ==========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0 -- d:\system\e
nvs\mytasks\scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch2\tasks_proj\tests, configfile: p
ytest.ini
collected 25 items / 22 deselected / 3 selected                         

func/test_api_exceptions.py::test_add_raises PASSED               [ 33%]
func/test_api_exceptions.py::test_list_raises PASSED              [ 66%]
func/test_api_exceptions.py::test_get_raises PASSED               [100%]

=================== 3 passed, 22 deselected in 0.03s ===================
可以看到,现在的测试集中只有3条测试函数,并且名称中都包含_raises且不含start字样。


在本章中,我们已经见识了pytest 的许多特性。即使只学完这里介绍的内容,也可以开始改造你的测试套件,进行升级换代了。

我们在许多测试模块中,都曾使用了一个名为initialized_tasks_db的装置(fixture),可以单独通过装置为测试函数提供测试数据。它们还可以分离公共代码,这样多个测试函数可以使用相同的装置。

在下一章中,我们将深入探索pytest装置的奇妙世界。