Skip to content

Using pytest with Other Tools

通常不会单独使用pytest,而是在测试环境中搭配其他工具一起使用。

本章着眼于经常与 pytest 结合使用的一些工具,以便执行有效和高效的测试。虽然本章不会面面俱到,并,但是这里讨论的工具,将会让你体验到将 pytest 与其他工具结合使用的力量。

7.1 调试器

pdb 模块是标准库中的Python调试器。可以使用 --pdb选项让pytest 在问题点启动一个调试会话。

让我们先来看看可以加快调试测试失败的速度的 pytest 选项:
--tb=[auto/long/short/line/native/no]:控制报错信息样式。
-v:详细展示。
-l:显示堆栈跟踪旁边的本地变量。
-lf:只运行上次失败的测试。
-x:在第一次失败时停止测试会话。
--pdb:在失败点启动一个交互式调试会话。

--pdb调试会话中,可以使用以下命令:

  • p expr: 等同于print expr,用来打印 exp 的值。
  • pp expr: 等同于pprint expr,用来美化打印expr 的值。
  • l: 等同于list,用来列出故障点和上下五行代码。
  • l begin,end: 等同于list begin,end,用来列出特定的行号。
  • a: 等同于args,用来打印当前函数的参数及其值。(在测试帮助函数中这很有用。)
  • u: 等同于up,用来在报错信息中向上移动一级。
  • d: 等同于down,用来在报错信息中向下移动一级。
  • q: 等同于quit,用来退出调试会话。

其他的导航命令,比如 stepnext 并没有那么实用,因为我们刚好就停留在assert语句附近。

💡 在ch7目录中添加新目录pdb_demo,在其中添加新的测试模块test_pdb.py

# ch7/pdb_demo/test_pdb.py

def test_failing():
    msg = "test_one"
    assert False

执行命令:

$ cd tests
$ pytest --pdb
输出内容:
F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
E   assert False
>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>

>>>>>>>>>>>>> PDB post_mortem (IO-capturing turned off) >>>>>>>>>>>>>>
> d:\coding\gitees\studypytest\ch7\tests\pdb_demo\test_one.py(3)test_on
e()
-> assert False
列出附近代码:
(Pdb) l
输出结果:
  1     def test_one():
  2         msg = "test_one"
  3  ->     assert False
[EOF]
可以使用p expr来查看函数中的变量值:
(Pdb) pp msg
输出结果:
'test_one'
可以退出调试会话,继续运行后续测试用例了。
(Pdb) q
输出结果:
====================== short test summary info =======================
FAILED test_one.py::test_one - assert False
!!!!!!!!!!!!!! _pytest.outcomes.Exit: Quitting debugger !!!!!!!!!!!!!!

更多关于使用 pdb 模块的内容详见Python官方文档。 传送门:https://docs.python.org/3/library/pdb.html

7.2 覆盖率

测试覆盖率pytest-cov

代码覆盖率是衡量被测代码已经被测试套件所覆盖的百分比。当运行Tasks项目的测试用例时,每个测试函数都会测试一些 Tasks 功能,但绝对不是全部。

代码覆盖工具非常适合用来评估系统的哪些部分代码被测试用例完全忽略了。Coverage.py则是Python中评估代码覆盖率的首选工具。

可以用它来检查Tasks项目中被pytest 测试的代码。

在使用coverage.py 之前,需要安装它。这里推荐直接安装 pytest-cov 插件即可。它可以通过 pytest 调用 coverage.py,支持一些pytest 选项。而且自带了coverage,所以安装 pytest-cov 一个就完全足够了,它会自动引入coverage.py。

安装 pytest-cov:

$ pip install pytest-cov 

💡 将源码tasks_proj_v2目录,整体复制到在ch7目录中。

让我们运行Tasks 版本2的覆盖率报告。如果当前安装了 Tasks 项目的第一个版本tasks_proj,那么卸载它,并安装v2版本。

卸载旧版本的tasks:

$ pip uninstall tasks 
安装v2版本的tasks:
$ cd ch7/tasks_proj_v2
$ pip install -e .
Windows系统检查安装情况:
$ pip list | findstr tasks
macOS系统检查安装情况:
$ pip list | findstr tasks

现在,新版的tasks已经安装完毕,可以运行来获得一份基线覆盖率报告。
执行命令:

$ cd ch7/tasks_proj_v2
$ pytest --cov=src
解释:由于当前目录是tasks_proj_v2,并且测试中的源代码都在src 中,因此添加选项 --cov=src选项将只为特定被测目录生成一个覆盖率报告。

输出结果:

======================== test session starts =========================
platform win32 -- Python 3.8.8, pytest-7.1.1, pluggy-1.0.0
rootdir: D:\Coding\Gitees\studypytest\ch7\tasks_proj_v2, configfile: to
x.ini
plugins: cov-3.0.0, mock-3.7.0, nice-0.1.0
collected 62 items                                                    

tests\func\test_add.py ...                                      [  4%]
tests\func\test_add_variety.py ............................     [ 50%]
tests\func\test_add_variety2.py ............                    [ 69%]
tests\func\test_api_exceptions.py .........                     [ 83%]
tests\func\test_unique_id.py .                                  [ 85%]
tests\unit\test_cli.py .....                                    [ 93%]
tests\unit\test_task.py ....                                    [100%]

----------- coverage: platform win32, python 3.8.8-final-0 -----------
Name                           Stmts   Miss  Cover
--------------------------------------------------
src\tasks\__init__.py              2      0   100%
src\tasks\api.py                  79     22    72%
src\tasks\cli.py                  52     14    73%
src\tasks\config.py               18     12    33%
src\tasks\tasksdb_pymongo.py      74     74     0%
src\tasks\tasksdb_tinydb.py       32      4    88%
--------------------------------------------------
TOTAL                            257    126    51%


========================= 62 passed in 0.44s =========================
可以看到,有些模块的覆盖率很低,甚至是0%,比如tasksdb_pymongo.py的覆盖率就是0% ,因为我们已经关闭了这个版本的 MongoDB 测试。在上线之前,这个项目肯定要对所有的这些低覆盖率的模块进行测试。

覆盖率相对较高一些的模块是api.py(72%)和 tasksdb_tinydb.py(88%)。让我们看看tasksdb_tinydb.py还缺少什么。

我们还可以使用 --cov-report=html 参数,生成一个HTML 报告。

执行命令:

$ pytest --cov=src --cov-report=html
执行完毕后,它将在当前目录中新生成一个htmlcov目录,其中包含了相关的报告页面和样式文件。

然后在浏览器中打开htmlcov/index.html的效果是:
image.png

单击列表中的tasksdb_tinydb.py 将展示单个模块的覆盖率报告。如下图所示:
image.png

image.png
报告顶部显示覆盖行数的百分比,以及覆盖行数和未覆盖的行数。向下滚动,可以看到未覆盖的代码行,红色高亮部分。从中不难看出:

  • 没有测试update()delete()方法。
  • 没有充分测试unique_id()list_tasks()方法。

OK,这样一来,我们可以把这些列入测试待办事项。

虽然代码覆盖率工具非常有用,但是追求100% 的覆盖率是非常不现实的。当你看到未经测试的代码时,这可能意味着需要进行测试。但这也可能意味着系统的某些功能是不需要的,可以删除。像所有的软件开发工具一样,代码覆盖率分析并不能代替我们的人脑思考。

更多关于coverage.py 和 pytest-cov 知识详见各自的官方文档: https://coverage.readthedocs.io/ https://pytest-cov.readthedocs.io

7.3 替身

mock,Swapping Out Part of the System

mock包用于移除系统的个别部分,以便将测试中的代码与系统的其他部分隔离开来。Mock对象有时被称为测试替身或桩。从pytest的monkeypatch猴子补丁夹具到 mock,都是有用的替身功能。

这个 mock 包在 Python 3.3版本以 unittest.mock 的形式发布,成为Python 标准库的一部分。在早期版本中,它是一个可以独立安装的包。这意味着从python2.6再到最新的Python 版本的 mock ,都能获得相同的功能。

当然,对于 pytest 来说,有一个叫 pytest-mock 的插件,因其便捷性推荐作为mock系统接口的首选。

对于Tasks项目,我们将使用 mock 来帮助我们测试CLI命令行界面,毕竟我们的 cli.py 文件(英文版原书129页)根本没有被测试,现在是时候开始解决这个问题。

在之前的章节主要是对Tasks 项目的 api.py 进行了功能测试。命令行测试不是完整的功能测试所必需的。如果我们在 CLI 测试期间mock API 层,我们将有足够的信心认为系统将通过 CLI 工作。

Tasks的 CLI 的实现使用第三方Click命令行界面库。有许多实现 CLI 的替代方案,包括 python 的内置 argparse 模块。我选择 Click 的原因之一,是因为它包含了一个测试运行器来帮助我们测试 Click 应用程序。

更多关于Click的知识,详见官方文档。 http://click.pocoo.org/

💡 在当前系统用户目录下创建tasks_db目录,并在其中创建tasks_db.json空文件。

注意:如果没有执行上述操作,可能会报错FileNotFoundError: [Errno 2] No such file or directory: 'C:\\Users\\xxx/tasks_db//tasks_db.json'

安装第2版的tasks包:

$ cd ch7
$ pip install -e tasks_proj_v2

列出tasks清单,执行的命令:

$ tasks list
输出结果:
  ID      owner  done summary
  --      -----  ---- -------
解释:因为当前刚初始化,数据库中是空的,所以没有task对象。

添加task,依次执行命令:

$ tasks add "do something great"
$ tasks add "repeat" -o Brian
$ tasks add "again and again" --owner Okken

然后列出tasks清单,执行命令:

$ tasks list
输出结果:
  ID      owner  done summary
  --      -----  ---- -------
   1            False do something great
   2      Brian False repeat
   3      Okken False again and again
tasks list命令列出所有的 tasks记录。即使列表是空的,它也会打印表头信息。

执行命令:

$ tasks list -o Brian
输出结果:
  ID      owner  done summary
  --      -----  ---- -------
   2      Brian False repeat

执行命令:

$ tasks list --owner Brian
输出结果:
  ID      owner  done summary
  --      -----  ---- -------
   2      Brian False repeat
如果使用了-o--owner选项,它只打印一个owner的记录内容。

那我们怎么测试它?有很多可能的方法,但是这里使用 mock替身。

使用模拟的测试必然是白盒测试,我们必须查看代码来决定模拟什么,以及在哪里进行。

主要的入口在这里:

# ch7/tasks_proj_v2/src/tasks/cli.py
# 代码片段

# ...

if __name__ == '__main__':
    tasks_cli()

这是对tasks_cli()的调用。

# ch7/tasks_proj_v2/src/tasks/cli.py
# 代码片段

# The main entry point for tasks.
@click.group(context_settings={'help_option_names': ['-h', '--help']})
@click.version_option(version='0.1.1')
def tasks_cli():
    """Run the tasks application."""
    pass

下面可以找到有一个 list 命令:

# ch7/tasks_proj_v2/src/tasks/cli.py
# 代码片段

@tasks_cli.command(name="list", help="list tasks")
@click.option('-o', '--owner', default=None,
              help='list tasks with this owner')
def list_tasks(owner):
    """
    List tasks in db.

    If owner given, only list tasks with that owner.
    """
    formatstr = "{: >4} {: >10} {: >5} {}"
    print(formatstr.format('ID', 'owner', 'done', 'summary'))
    print(formatstr.format('--', '-----', '----', '-------'))
    with _tasks_db():
        for t in tasks.list_tasks(owner):
            done = 'True' if t.done else 'False'
            owner = '' if t.owner is None else t.owner
            print(formatstr.format(
                  t.id, owner, done, t.summary))

一旦你习惯了编写Click代码,它看起来就容易读懂了。这里不做过多的代码解释,因为开发命令行代码并不是本章的重点。然而,尽管非常确定这些代码是正确的,但是仍然有很多人为错误的可能性。这就是为什么要有一套良好的自动化测试来确保它的正确,这个工作是非常重要的。

可以看到,这里的list_tasks(owner)函数依赖于另外两个函数: 一个是_tasks_db() ,上下文管理器;另一个是tasks.list_tasks (owner) ,这是API 函数。

接下来,我们将使用 mock 为_tasks_db()tasks.list_tasks ()设置替身函数。然后可以通过命令行界面调用list_tasks()方法,确保它正确地调用 tasks.list_tasks()函数并正确地处理返回值。

为了模拟_tasks_db() ,让我们看看实际的实现代码:

# ch7/tasks_proj_v2/src/tasks/cli.py
# 代码片段

@contextmanager
def _tasks_db():
    config = tasks.config.get_config()
    tasks.start_tasks_db(config.db_path, config.db_type)
    yield
    tasks.stop_tasks_db()
代码解释:_tasks_db()函数是一个上下文管理器,它通过tasks.config.get_config()从另一个外部依赖项查询配置,并使用配置连接数据库。yield将控制释放到list_tasks()的with代码块(cli.py的第48行) ,当所有事情都完成之后,数据库连接就断开。

为了测试CLI 行为直到调用 API 函数,我们不需要与实际数据库的连接。因此,我们可以用一个简单的替身来mock上下文管理器:

# ch7/tasks_proj_v2/tests/unit/test_cli.py
# 代码片段

from click.testing import CliRunner
from contextlib import contextmanager
import pytest
from tasks.api import Task
import tasks.cli
import tasks.config


@contextmanager
def stub_tasks_db():
    yield
先看一下所有import 语句。替身所需的唯一导入项是from contextlib import contextmanager

我们将使用mock 技术制造替身,来替换真正的上下文管理器。实际上,我们将使用 pytest-mock 插件提供的mock装置。

下面是一个调用tasks list的测试:

# ch7/tasks_proj_v2/tests/unit/test_cli.py
# 代码片段

def test_list_no_args(mocker):
    mocker.patch.object(tasks.cli, '_tasks_db', new=stub_tasks_db)
    mocker.patch.object(tasks.cli.tasks, 'list_tasks', return_value=[])
    runner = CliRunner()
    runner.invoke(tasks.cli.tasks_cli, ['list'])
    tasks.cli.tasks.list_tasks.assert_called_once_with(None)
代码解释:这里的 mocker 夹具是由pytest-mock 提供的,是一个unittest.mock便捷的接口。
mocker.patch.object(tasks.cli, 'tasks_db', new=stub_tasks_db)_tasks_db()上下
文管理器替换为不做任何事情的替身。mocker.patch.object(tasks.cli.tasks, 'list_tasks', return_value=[]),用一个空列表返回值替换从tasks.cli 中对默认 MagicMock 对象的 tasks.list_tasks()的任何调用。我们可以在以后使用这个对象来检查它是否被正确调用。

MagicMock 类是unittest.Mock的一个灵活的子类,它具有默认行为和指定返回值的能力,这就是我们在这个例子中使用的。Mock 和 MagicMock 类(以及其他类)用于模拟其他代码的接口,内置了默认方法。

第7~8行使用了Click的CliRunner(),执行与在命令行上调用 tasks list相同的操作。

第就行使用mock 对象来确保正确调用了 API 。assert_called_once_with()方法是unittest.mock.Mock 对象的一部分。

这些对象的更多内容详见Python文档。 传送门:https://docs.python.org/dev/library/unittest.mock.html

让我们来看一个几乎相同的检查输出的测试函数:

# ch7/tasks_proj_v2/tests/unit/test_cli.py
# 代码片段

@pytest.fixture()
def no_db(mocker):
    mocker.patch.object(tasks.cli, '_tasks_db', new=stub_tasks_db)


def test_list_print_empty(no_db, mocker):
    mocker.patch.object(tasks.cli.tasks, 'list_tasks', return_value=[])
    runner = CliRunner()
    result = runner.invoke(tasks.cli.tasks_cli, ['list'])
    expected_output = ("  ID      owner  done summary\n"
                       "  --      -----  ---- -------\n")
    assert result.output == expected_output

这一次,我们将 _tasks_db的mock替身,放到一个no_db()的mocker夹具中,这样我们可以在以后的测试中更容易地复用它。对tasks.list_tasks()的mock和之前一样。然而,这一次,我们也通过result.output 检查命令行操作的输出,并断言与 expected_output 相等。

这个assert 断言可以放在第一个测试test_list_no_args 中,我们可以不在需要写两个测试了。然而,与其他代码相比,我对自己获得CLI 代码正确性的能力没有那么大的信心,所以将“API 被正确调用了吗?”和“动作打印正确吗?”分成两个测试用例似乎是更为恰当的。

tasks list功能的其余测试没有添加任何新的概念,但是看看其中的几个,可能会让代码更容易理解。

# ch7/tasks_proj_v2/tests/unit/test_cli.py
# 代码片段


def test_list_print_many_items(no_db, mocker):
    many_tasks = (
        Task('write chapter', 'Brian', True, 1),
        Task('edit chapter', 'Katie', False, 2),
        Task('modify chapter', 'Brian', False, 3),
        Task('finalize chapter', 'Katie', False, 4),
    )
    mocker.patch.object(tasks.cli.tasks, 'list_tasks',
                        return_value=many_tasks)
    runner = CliRunner()
    result = runner.invoke(tasks.cli.tasks_cli, ['list'])
    expected_output = ("  ID      owner  done summary\n"
                       "  --      -----  ---- -------\n"
                       "   1      Brian  True write chapter\n"
                       "   2      Katie False edit chapter\n"
                       "   3      Brian False modify chapter\n"
                       "   4      Katie False finalize chapter\n")
    assert result.output == expected_output


    def test_list_dash_o(no_db, mocker):
        mocker.patch.object(tasks.cli.tasks, 'list_tasks')
        runner = CliRunner()
        runner.invoke(tasks.cli.tasks_cli, ['list', '-o', 'brian'])
        tasks.cli.tasks.list_tasks.assert_called_once_with('brian')


        def test_list_dash_dash_owner(no_db, mocker):
            mocker.patch.object(tasks.cli.tasks, 'list_tasks')
            runner = CliRunner()
            runner.invoke(tasks.cli.tasks_cli, ['list', '--owner', 'okken'])
            tasks.cli.tasks.list_tasks.assert_called_once_with('okken')

执行测试:

$ cd ch7/tasks_proj_v2/tests
$ pytest unit/test_cli.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\ch7\tasks_proj_v2, configfile: to
x.ini
plugins: cov-3.0.0, mock-3.7.0, nice-0.1.0
collected 5 items                                                     

unit\test_cli.py::test_list_no_args PASSED                      [ 20%]
unit\test_cli.py::test_list_print_empty PASSED                  [ 40%]
unit\test_cli.py::test_list_print_many_items PASSED             [ 60%]
unit\test_cli.py::test_list_dash_o PASSED                       [ 80%]
unit\test_cli.py::test_list_dash_dash_owner PASSED              [100%]

========================= 5 passed in 0.04s ==========================
耶! 所有的测试用例都运行通过了。

更多关于mock的知识,详见标准库文档中的unittest.mock,以及插件pytest-mock。 传送门:https://docs.python.org/dev/library/unittest.mock.html 传送门:https://pypi.python.org/pypi/pytest-mock

7.4 多种配置

测试多种配置tox

tox 是一个命令行工具,允许我们在多种环境中运行完整的测试套件。我们将用它在多个Python 版本中测试 Tasks 项目。当然,tox 并不仅仅局限于 Python 版本,还可以用它来测试不同的依赖的配置,和不同的操作系统配置。

总的来说,这里是关于tox 如何工作的思维模型:
tox 使用测试包的 setup.py 文件来创建包的可安装的源码包,在 tox.ini 中查找环境列表,然后查找每个环境。

  1. tox 会在.tox目录中创建一个虚拟环境。
  2. tox 执行pip安装一些依赖项。
  3. tox 使用pipsdist 安装包,如步骤1中所述。
  4. tox 运行测试用例。

当所有的环境都经过测试之后,tox 在测试报告中总结测试结果。

我们需要对Tasks项目做的第一件事,是添加一个 tox.ini 配置文件,与 setup.py 放在同级目录,把pytest.ini 中的所有内容都移到 tox.ini 中。

让我们看看如何修改Tasks项目,以便使用 tox 来测试python2.73.6两个版本。如果你已经安装的是其他不同的python版本,那么修改 envlist 这行,从而匹配你所安装的,或者愿意安装的python版本。

[tox]
envlist = py27,py37

下面是简化的项目结构:

tasks_proj_v2/
├── ...
├── setup.py
├── tox.ini
├── src
│       └── tasks
│                   ├── __init__.py
│                   ├── api.py
│                   └── ...
└── tests
      ├── conftest.py
      ├── func
      │         ├── __init__.py
      │         ├── test_add.py
      │         └── ...
      └── unit
            ├── __init__.py
            ├── test_task.py
            └── ...

现在的 tox.ini 文件内容是这样的:

# tox.ini , put in same dir as setup.py

[tox]
envlist = py27,py36

[testenv]
deps=pytest
commands=pytest

[pytest]
addopts = -rsxX -l --tb=short --strict
markers =
smoke: Run the smoke test test functions
    get: Run the test functions that test tasks.get()
[ tox ]下面的envlist = py27,py36这句。它是一个简写,告诉tox使用 python2.7和 python3.6版本运行测试。
[ testenv ]下的deps=pytest,告诉tox 确保 pytest 已安装。如果你有多个测试依赖项,你可以把它们写在不同的行上。也可以指定使用具体的版本。commands=pytest告诉tox 在每个环境中运行 pytest。
[ pytest ]下,可以是通常想要写在 pytest.ini 的内容来配置 pytest。

python2.7下载地址:https://www.python.org/downloads/release/python-270/ python3.6下载地址:https://www.python.org/downloads/release/python-360/

在运行tox之前,必须确保已经安装了它。当前演示的tox版本是tox-3.25.0

$ pip install tox

然后运行tox,只需要输入tox:

$ cd ch7/tasks_proj_v2
$ tox
输出结果:
GLOB sdist-make: D:\Coding\Gitees\studypytest\ch7\tasks_proj_v2\setup.p
y
py27 create: D:\Coding\Gitees\studypytest\ch7\tasks_proj_v2\.tox\py27
ERROR: InterpreterNotFound: python2.7

py38 inst-nodeps: D:\Coding\Gitees\studypytest\ch7\tasks_proj_v2\.tox\.
tmp\package\1\tasks-0.1.1.zip
py38 installed: atomicwrites==1.4.0,attrs==21.4.0,click==7.1.2,colorama
==0.4.4,iniconfig==1.1.1,packaging==21.3,pluggy==1.0.0,py==1.11.0,pypar
sing==3.0.8,pytest==7.1.2,pytest-mock==3.7.0,six==1.16.0,tasks @ file:/
//D:/Coding/Gitees/studypytest/ch7/tasks_proj_v2/.tox/.tmp/package/1/ta
sks-0.1.1.zip,tinydb==3.15.1,tomli==2.0.1
py38 run-test-pre: PYTHONHASHSEED='977'
py38 run-test: commands[0] | pytest
======================== test session starts =========================
platform win32 -- Python 3.8.8, pytest-7.1.2, pluggy-1.0.0
cachedir: .tox\py38\.pytest_cache
rootdir: D:\Coding\Gitees\studypytest\ch7\tasks_proj_v2, configfile: to
x.ini
plugins: mock-3.7.0
collected 62 items                                                    

tests\func\test_add.py ...                                      [  4%]
tests\func\test_add_variety.py ............................     [ 50%]
tests\func\test_add_variety2.py ............                    [ 69%]
tests\func\test_api_exceptions.py .........                     [ 83%]
tests\func\test_unique_id.py .                                  [ 85%]
tests\unit\test_cli.py .....                                    [ 93%]
tests\unit\test_task.py ....                                    [100%]

========================= 62 passed in 0.31s =========================
______________________________ summary _______________________________
注意:本楼主的python2.7环境一直有点问题导致pytest安装不上。。。。

Tox 远比我在这里展示的功能强大得多,如果你使用pytest 来测试打算在多种环境中运行的软件包,那么值得学习。

更多关于tox的知识,详见官方文档。 传送门:https://tox.readthedocs.io

7.5 持续集成

Jenkins CI:自动化你的自动化测试

持续集成(CI)系统,例如Jenkins,通常用来在每次代码提交之后运行测试套件。Pytest 包含了一些选项,可以用来生成 Jenkins 和其他 CI 系统所需的 junit.xml 格式的文件来展示测试结果。

Jenkins 是一个经常用于持续集成的免费开源的自动化服务器。尽管 Python 不需要编译,但是我们会经常使用 Jenkins 或其他 CI 系统,来自动化 Python 项目的运行和收集测试报告。在本节中,我们将看到如何在Jenkins 中设置 Tasks 项目。

更多关于 Jenkins 的安装和使用方法,参见Jenkins网站的相关文档。 传送门:https://jenkins.io

在使用Jenkins 运行 pytest 套件时,有一些 Jenkins 插件可能会起很大作用,比如:

build-name-setter插件:
支持定制每次构建的显示名称,而不再是默认的#1、#2、#这种。

Test Results Analyzer plugin插件:
支持以表格或图形格式,显示测试执行结果的历史记录。

可以通过访问Jenkins Web页面来安装插件,地址是 ip/Manage,然后依次单击菜单 Manage Jenkins-> Manage Plugins-> Available。使用过滤器框搜索你想要的插件。勾选你想要的插件。通常选择“ Install without Restart”,然后在 Installing Plugins/Upgrades 页面上,选择“ Restart Jenkins when installation is complete and no jobs are running”框。

在Jenkins中创建一个名为tasks的自由风格的项目,如下图所示。
image.png

我们需要对项目进行参数化,告诉每个测试会话,在哪里安装Tasks项目,以及在哪里寻找测试函数。我们将使用一些字符串参数,来指定这些目录,如下图所示。
image.png

向下滚动到Build Environment,选择 Delete workspace before Build starts,并set Build Name,将构建名称设置为 ${start_tests_dir} #${BUILD_NUMBER},如下图所示。
image.png

设置构建Build。在 Mac 或类 unix 系统中,选择 Add build step-> Execute shell。在 Windows 上,选择 Add build step-> Execute Windows batch 命令。
当前演示使用的是 Mac,如下图所示:
image.png
文本框的内容如下:

# your paths will be different
code_path=/Users/okken/projects/book/bopytest/Book/code
run_tests=${code_path}/ch7/jenkins/run_tests.bash
bash -e ${run_tests} ${tasks_proj_dir} ${start_tests_dir} ${WORKSPACE}

推荐使用一个脚本,而不是将所有这些代码全部放在Jenkins 的执行块中,这样就可以通过版本控制跟踪修改记录。

💡 在ch7目录中添加新目录jenkins_demo,添加新的配置文件run_tests.bash

#!/bin/bash
# your paths will be different
top_path=/Users/okken/projects/book/bopytest/Book
code_path=${top_path}/code
venv_path=${top_path}/venv
tasks_proj_dir=${code_path}/$1
start_tests_dir=${code_path}/$2
results_dir=$3
# click and Python 3,
# from http://click.pocoo.org/5/python3/
export LC_ALL=en_US.utf-8
export LANG=en_US.utf-8
# virtual environment
source ${venv_path}/bin/activate
# install project
pip install -e ${tasks_proj_dir}
# run tests
cd ${start_tests_dir}
pytest --junit-xml=${results_dir}/results.xml

注意最后面的一行 pytest --junit-xml=${results_dir}/results.xml,这个--junit-xml选项是生成 Jenkins 需要的 junit.xml 格式的结果文件所唯一必需的东西。

关于junit方面的,还有其他的pytest选项。

windows系统执行命令:

$ pytest --help | findstr junit
macOS系统执行命令:
$ pytest --help | grep junit
输出结果:
  --junit-xml=path      create junit-xml style report file at given path.
  --junit-prefix=str    prepend prefix to classnames in junit-xml output
  junit_suite_name (string):
  junit_logging (string):
  junit_log_passing_tests (bool):
  junit_duration_report (string):
  junit_family (string):
--junit-prefix 前缀可以用于每个测试。这在使用tox时很有用,并且可以区分不同的环境的测试结果。 junit_suite_name 是一个配置文件选项,可以在pytest.ini 或 tox.ini 的[pytest]部分中设置。稍后我们会看到结果将展示from (pytest)。如果想要将 pytest 替换为其他名称,可以指定junit_suite_name

接下来,我们将添加一个构建后操作:Add post-build action->Publish Junit test result report

Test report XMLs中填写 results.xml ,如下图所示。
image.png
点击保存。

现在我们可以通过 Jenkins 运行测试了。以下是具体步骤:

  1. 回到到顶部项目。
  2. 单击Build with Parameters
  3. 选择你的目录,然后点击Build
  4. 完成后,单击构建名称并选择 Console Output
  5. 查看输出,试着找出出错的地方。

也许可以跳过第五步和第六步,如果能确保脚本一次性通过。在脚本中通常会出现目录权限问题、路径问题或者拼写错误等等,所以检查输出日志是个不错的习惯。

我们再运行一个版本,让它变得更有意思一点。再次点击 Build with Parameters。这一次,保持同样的项目目录,但是设置 ch2 作为 start_tests_dir的值,然后单击Build构建。刷新项目顶部视图之后,你应该会看到下图所示:
image.png
单击图表内部或Latest Test Result链接,查看测试会话的概述,并展开+图标展开测试失败信息。

点击任何一个失败的测试名称,都会显示单独的测试失败信息,如下图所示。在这里可以看到(from pytest)作为测试名称的一部分。这是由配置文件中的 junit_suite_name选项所控制的。
image.png

回到Jenkins > tasks,你可以单击Test Results Analyzer 查看一个视图,该视图列出了哪些测试没有在不同的会话运行,以及通过/失败的状态,参见下图:
image.png

我们已经了解了如何使用Jenkins 提供的虚拟环境运行 pytest 套件,但是还有很多其他主题需要一起使用 pytest 和 Jenkins 来探讨。可以通过为每个环境设置独立的 Jenkins 任务,或者让 Jenkins 直接调用 tox 来测试多个环境。

还有一个很好的插件叫做 Cobertura,它可以显示coverage.py中的覆盖率数据。

更多关于Cobertura插件文档详见Jenkins文档。 传送门:https://wiki.jenkins-ci.org/display/JENKINS/Cobertura+Plugin

7.6 遗留测试

使用 pytest 运行遗留测试

unittest 是构建在 Python 标准库中的测试框架。它的目的是测试 Python 本身,但它也经常用于项目测试。pytest 作为一个 unittest 运行程序,可以在同一个会话中运行 pytest 和 unittest 测试。

让我们假设当 Tasks 项目启动时,它使用了 unittest,而不是 pytest 来进行测试。也许有很多测试用例已经写好了。幸运的是,你可以使用pytest 来运行基于unittest的测试。如果将测试工作从 unittest 迁移到 pytest,这可能是一个合理的选择。

可以把所有旧的unittest测试用例继续保留,并在 pytest 中编写新的测试。也可以在有时间的情况下,或者需要更改的时候,再逐渐迁移旧的测试。然而,在迁移过程中可能会遇到一些问题,我将在这里解决其中的一些问题。

首先,让我们看一个用 unittest 编写的测试用例。

💡 在ch7目录中添加新目录unittest_demo,添加新的测试模块test_delete_unittest.py

import unittest
import shutil
import tempfile
import tasks
from tasks import Task


def setUpModule():
    """Make temp dir, initialize DB."""
    global temp_dir
    temp_dir = tempfile.mkdtemp()
    tasks.start_tasks_db(str(temp_dir), 'tiny')


def tearDownModule():
    """Clean up DB, remove temp dir."""
    tasks.stop_tasks_db()
    shutil.rmtree(temp_dir)


class TestNonEmpty(unittest.TestCase):

    def setUp(self):
        tasks.delete_all() # start empty
        # add a few items, saving ids
        self.ids = []
        self.ids.append(tasks.add(Task('One', 'Brian', True)))
        self.ids.append(tasks.add(Task('Two', 'Still Brian', False)))
        self.ids.append(tasks.add(Task('Three', 'Not Brian', False)))

    def test_delete_decreases_count(self):
        # GIVEN 3 items
        self.assertEqual(tasks.count(), 3)
        # WHEN we delete one
        tasks.delete(self.ids[0])
        # THEN count decreases by 1
        self.assertEqual(tasks.count(), 2)
代码解释:真正的测试函数在底部,是test_delete_decreases_count() ,其余代码是前置和后置处理。

这个测试用例在unittest中运行良好,例如执行命令:

$ cd ch7/unittest_demo
$ python -m unittest test_delete_unittest.py -v
输出结果:
test_delete_decreases_count (test_delete_unittest.TestNonEmpty) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.018s

OK

(mytasks) D:\Coding\Gitees\studypytest\ch7\unittest_demo>

如果使用pytest运行,照样也OK:

$ cd ch7/unittest_demo
$ pytest test_delete_unittest.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\ch7\unittest_demo
plugins: cov-3.0.0, mock-3.7.0, nice-0.1.0
collected 1 item                                                      

test_delete_unittest.py::TestNonEmpty::test_delete_decreases_count PASS
ED [100%]

========================= 1 passed in 0.04s ==========================

如果你只想使用pytest 作为 unittest 的测试运行程序,那么这样做非常好。然而,我们的前提是 Tasks 项目正在迁移到 pytest。假设我们希望一次迁移一个测试用例,并同时运行 unittest 和 pytest 版本,直到我们对 pytest 版本有信心为止。

让我们重写这个测试模块,然后尝试同时运行它们。

💡 在ch7/unittest_demo目录中添加新的本地插件文件conftest.py

# ch7/unittest_demo/conftest.py

import pytest
import tasks
from tasks import Task


@pytest.fixture()
def tasks_db(tmpdir):
    """Connect to db before tests, disconnect after."""
    # 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.fixture()
def tasks_just_a_few():
    """All summaries and owners are unique."""
    return (
    Task('Write some code', 'Brian', True),
    Task("Code review Brian's code", 'Katie', False),
    Task('Fix what Brian did', 'Michelle', False))

@pytest.fixture()
def db_with_3_tasks(tasks_db, tasks_just_a_few):
    """Connected db with 3 tasks, all unique."""
    for t in tasks_just_a_few:
        tasks.add(t)

💡 在ch7/unittest_demo目录中添加新的测试模块test_delete_pytest.py

# ch7/unittest_demo/test_delete_pytest.py

import tasks


def test_delete_decreases_count(db_with_3_tasks):
    ids = [t.id for t in tasks.list_tasks()]
    # GIVEN 3 items
    assert tasks.count() == 3
    # WHEN we delete one
    tasks.delete(ids[0])
    # THEN count decreases by 1
    assert tasks.count() == 2
代码解释:这里的db_with_3_tasks夹具(详见第四章)会在测试之前建立数据库。

这两项测试分别通过:

$ pytest -q test_delete_pytest.py
输出结果:
.                                                               [100%]
1 passed in 0.03s

执行命令:

$ pytest -q test_delete_unittest.py
输出结果:
.                                                               [100%]
1 passed in 0.02s

我们甚至可以把它们放在一起运行,当且仅当确保unittest版本先得到执行。
执行命令:

$ pytest -v  test_delete_unittest.py test_delete_pytest.py
输出结果:
======================== 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\ch7\unittest_demo
plugins: cov-3.0.0, mock-3.7.0, nice-0.1.0
collected 2 items                                                     

test_delete_unittest.py::TestNonEmpty::test_delete_decreases_count PASS
ED [ 50%]
test_delete_pytest.py::test_delete_decreases_count PASSED       [100%]

========================= 2 passed in 0.05s ==========================

使用pytest 运行 unittest 的限制是,pytest测试子集将在第一次失败时停止,而unittest 将运行每个子测试,不管是否失败。当所有的子集测试通过时,pytest 会运行所有的子测试。由于这个限制,我们不会看到任何假象结果,我认为这是一个微小的差异。


相信你肯定已经准备用你自己的项目去尝试pytest了,祝你好运!

英文版原书网站:https://pragprog.com/titles/bopytest 原书论坛:https://forums.pragprog.com/forums/438