第 12 章 测试脚本和应用

示例 cards 应用程序是一个可安装的 Python 软件包,使用 pip install 安装。安装完成后,测试代码只需导入 cards 即可访问应用程序的功能,然后进行测试。然而,并不是所有 Python 代码都能用 pip 安装,但仍然需要进行测试。

在本章中,我们将探讨如何测试无法使用 pip 安装的脚本和应用程序。为了明确术语,以下定义适用于本章:

  • 脚本是包含 Python 代码的单个文件,旨在直接从 Python 运行,例如 python my_script.py。

  • 可导入脚本是导入时不执行任何代码的脚本。 代码只有直接运行时才会被执行。

  • 应用程序是指具有在 requirements.txt 文件中定义的外部依赖项的包或脚本。 Cards 项目也是一个应用程序,但它是通过 pip 安装的。Cards 的外部依赖项在其 pyproject.toml 文件中定义,并在 pip 安装期间引入。在本章中,我们将专门研究不能或选择不使用 pip 的应用程序。

我们将从测试脚本开始。 然后我们将修改脚本,以便可以导入它进行测试。 然后,我们将添加外部依赖项并查看测试应用程序。

在测试脚本和应用程序时,经常会出现一些问题:

  • 如何运行测试脚本?

  • 如何捕获脚本的输出?

  • 我想将我的源模块或包导入到我的测试中。 如果测试和代码位于不同的目录中,我该如何使其工作?

  • 如果没有要构建的包,我该如何使用 tox?

  • 如何让 tox 从 requirements.txt 文件中提取外部依赖项?

这些是本章将回答的问题。

不要忘记使用虚拟环境

您在本书前一部分中使用的虚拟环境可以用于本章的讨论,也可以创建一个新环境。 回顾一下如何做到这一点:

$ cd /path/to/code/ch12
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install -U pip
(venv) $ pip install pytest
(venv) $ pip install tox

测试简单的 Python 脚本

让我们从规范的编码示例开始,Hello World!:

ch12/script/hello.py
print("Hello, World!")

运行输出应该不足为奇:

$ cd /path/to/code/ch12/script
$ python hello.py
Hello, World!

与任何其他软件一样,脚本是通过运行脚本并检查输出和/或副作用来测试的。

对于 hello.py 脚本,我们的挑战是 (1) 弄清楚如何从测试中运行它,以及 (2) 如何捕获输出。 subprocess 模块是 Python 标准库的一部分,有一个 run() 方法可以很好地解决这两个问题:

ch12/script/test_hello.py
from subprocess import run


def test_hello():
    result = run(["python", "hello.py"], capture_output=True, text=True)
    output = result.stdout
    assert output == "Hello, World!\n"

该测试启动了一个子进程,捕获输出,并将其与 "Hello, World!\n" 进行比较,包括 print() 自动添加到输出中的换行。让我们试试看:

$ pytest -v test_hello.py
========================= test session starts ==========================
collected 1 item
test_hello.py::test_hello PASSED [100%]
========================== 1 passed in 0.03s ===========================

那还不错。让我们用 tox 来尝试一下。

如果我们设置一个正常的 tox.ini 文件,它不会真正起作用。 无论如何,让我们尝试一下:

ch12/script/tox_bad.ini
[tox]
envlist = py39, py310

[testenv]
deps = pytest
commands = pytest

[pytest]

运行这个说明了问题:

$ tox -e py310 -c tox_bad.ini
ERROR: No pyproject.toml or setup.py file found. The expected locations are:
/path/to/code/ch12/script/pyproject.toml or /path/to/code/ch12/script/setup.py
You can
1. Create one:
https://tox.readthedocs.io/en/latest/example/package.html
2. Configure tox to avoid running sdist:
https://tox.readthedocs.io/en/latest/example/general.html
3. Configure tox to use an isolated_build

问题在于,tox 正试图构建一些东西作为其流程的第一部分。 我们需要告诉 tox 不要尝试构建任何东西,我们可以使用 skipsdist = true 来做到这一点:

ch12/script/tox.ini
[tox]
envlist = py39, py310
skipsdist = true

[testenv]
deps = pytest
commands = pytest

[pytest]

现在它应该可以正常运行:

$ tox
...
py39 run-test: commands[0] | pytest
========================= test session starts ==========================
collected 1 item
test_hello.py . [100%]
========================== 1 passed in 0.04s ===========================
...
py310 run-test: commands[0] | pytest
========================= test session starts ==========================
collected 1 item
test_hello.py . [100%]
========================== 1 passed in 0.04s ===========================
_______________________________ summary ________________________________
py39: commands succeeded
py310: commands succeeded
congratulations :)
$

真棒。我们用 pytest 和 tox 测试了脚本,并使用 subprocess.run() 启动脚本并捕获输出。

使用 subprocess.run() 测试小型脚本效果还不错,但也有缺点。我们可能希望单独测试大型脚本的各个部分。这是不可能的,除非我们将功能拆分成多个函数。我们可能还想将测试代码和脚本分到不同的目录中。这在目前的代码中也并非易事,因为我们对 subprocess.run() 的调用假定 hello.py 在同一目录下。只要对代码稍作修改,就能解决这些问题。

测试可导入的 Python 脚本

我们可以对脚本代码稍作修改,使其可以导入,并允许测试和代码位于不同的目录中。首先,我们要确保脚本中的所有逻辑都在一个函数中。让我们将 hello.py 的工作量转移到 main() 函数中:

ch12/script_importable/hello.py
def main():
    print("Hello, World!")


if __name__ == "__main__":
    main()

我们在 if __name__ == '__main__' 块内调用 main() 。 当我们使用 python hello.py 调用脚本时,将调用 main() 代码:

$ cd /path/to/code/ch12/script_importable
$ python hello.py
Hello, World!

仅仅导入是无法调用 main() 代码的。我们必须明确调用 main():

$ python
>>> import hello
>>> hello.main()
Hello, World!

现在我们可以像测试其他函数一样测试 main() 。 在修改后的测试中,我们使用 capsys(第 51 页的使用 capsys 中对此进行了介绍):

ch12/script_importable/test_hello.py
import hello


def test_main(capsys):
    hello.main()
    output = capsys.readouterr().out
    assert output == "Hello, World!\n"

我们不仅可以测试 main(),而且随着脚本的增长,我们可以将代码分解为单独的函数。 我们现在可以单独测试这些功能。 分解 Hello, World! 有点傻,但我们还是这么做吧,只是为了好玩:

ch12/script_funcs/hello.py
def full_output():
    return "Hello, World!"


def main():
    print(full_output())


if __name__ == "__main__":
    main()

这里我们将输出内容放入 full_output() 中,并在 main() 中实际打印它。 现在我们可以分别测试它们:

ch12/script_funcs/test_hello.py
import hello


def test_full_output():
    assert hello.full_output() == "Hello, World!"


def test_main(capsys):
    hello.main()
    output = capsys.readouterr().out
    assert output == "Hello, World!\n"

太好了。即使是相当大的脚本,也可以通过这种方式进行合理测试。现在,让我们研究一下如何将测试和脚本移到不同的目录中。

将代码分为 src 和 tests 目录

假设我们有一堆脚本和这些脚本的一堆测试,我们的目录变得有点杂乱。因此,我们决定将脚本移到 src 目录,将测试移到 tests 目录,就像这样:

script_src
├── src
│    └── hello.py
├── tests
│    └── test_hello.py
└── pytest.ini

如果没有任何其他更改,pytest 将崩溃:

$ cd /path/to/code/ch12/script_src
$ pytest --tb=short -c pytest_bad.ini
========================= test session starts ==========================
collected 0 items / 1 error
================================ ERRORS ================================
_________________ ERROR collecting tests/test_hello.py _________________
ImportError while importing test module
'/path/to/code/ch12/script_src/tests/test_hello.py'.
...
tests/test_hello.py:1: in <module>
import hello
E ModuleNotFoundError: No module named 'hello'
======================= short test summary info ========================
ERROR tests/test_hello.py
!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!
=========================== 1 error in 0.08s ===========================

我们的测试和 pytest 都不知道要在 src 中查找 hello。无论是源代码还是测试代码中的所有导入语句,都使用了标准的 Python 导入过程;因此,它们会在 Python 模块搜索路径中查找目录。Python 将搜索路径列表保存在 sys.path 变量中,然后 pytest 会对列表进行一些修改,以添加要运行的测试的目录。

pytest 有一个选项 pythonpath 可以帮助我们。pytest 7 引入了这个选项。如果你需要使用 pytest 6.2,可以使用 pytest-srcpaths 插件,将该选项添加到 pytest 6.2.x。

首先我们需要修改 pytest.ini 以将 pythonpath 设置为 src:

ch12/script_src/pytest.ini
[pytest]
addopts = -ra
testpaths = tests
pythonpath = src

现在 pytest 运行得很好:

$ pytest tests/test_hello.py
========================= test session starts ==========================
collected 2 items
tests/test_hello.py .. [100%]
========================== 2 passed in 0.01s ===========================

它能起作用真是太好了。 但当您第一次遇到 sys.path 时,它可能看起来很神秘。 让我们仔细看看。

定义 Python 搜索路径

Python 搜索路径只是 Python 存储在 sys.path 变量中的目录列表。 在任何导入语句期间,Python 都会在列表中查找与请求的导入相匹配的模块或包。 我们可以使用一个小测试来看看 sys.path 在测试运行期间是什么样子:

ch12/script_src/tests/test_sys_path.py
import sys


def test_sys_path():
    print("sys.path: ")
    for p in sys.path:
        print(p)

当我们运行它时,请注意搜索路径:

$ pytest -s tests/test_sys_path.py
========================= test session starts ==========================
collected 1 item
tests/test_sys_path.py sys.path:
/path/to/code/ch12/script_src/tests
/path/to/code/ch12/script_src/src
...
/path/to/code/ch12/venv/lib/python3.10/site-packages
.
========================== 1 passed in 0.00s ===========================

最后一个路径 "site-packages" 是有意义的。这是通过 pip 安装的软件包所在的路径。script_src/tests 路径是我们的测试所在。pytest 会添加 tests 目录,以便 pytest 导入我们的测试模块。我们可以将任何测试辅助模块与使用该模块的测试放在同一目录下,从而利用这一新增目录。script_src/src 路径是由 pythonpath=src 设置添加的路径。该路径与包含 pytest.ini 文件的目录相对。

测试基于 requirements.txt 的应用程序

脚本或应用程序可能有依赖项,即在脚本或应用程序运行前需要安装的其他项目。像 Cards 这样的打包项目会在 pyproject.toml、setup.py 或 setup.cfg 文件中定义依赖项列表。Cards 使用 pyproject.toml。不过,许多项目并不使用打包,而是在 requirements.txt 文件中定义依赖关系。

requirements.txt 文件中的依赖关系列表可能只是一个松散的依赖关系列表,例如:

ch12/sample_requirements.txt
typer
requests

然而,应用程序通过定义已知可用的特定版本来 “固定” 依赖关系更为常见:

ch12/sample_pinned_requirements.txt
typer==0.3.2
requests==2.26.0

requirements.txt 文件用于通过 pip install -r 重新创建运行环境。 -r 告诉 pip 读取并安装 requests.txt 文件中的所有内容。

一个合理的流程是:

  • 以某种方式获取代码。 例如,git clone <项目存储库>。

  • 使用 python3 -m venv venv 创建虚拟环境。

  • 激活虚拟环境。

  • 使用 pip install -r requirements.txt 安装依赖项。

  • 运行应用程序。

对于很多项目来说,包装更有意义。 然而,这个过程对于 Django 这样的 Web 框架和使用更高级别打包的项目(例如 Docker)来说很常见。 在这些情况和其他情况下,requirements.txt 文件很常见并且工作正常。

让我们向 hello.py 添加一个依赖项来查看这种情况的实际情况。 我们将使用 Typer 来帮助我们添加一个命令行参数来向某个名字打招呼。 首先,我们将 typer 添加到 requests.txt 文件中:

ch12/app/requirements.txt
typer==0.3.2

请注意,我还将版本固定为 Typer 0.3.2。 现在我们可以使用以下任一方法安装新的依赖项:

$ pip install typer==0.3.2

或者

$ pip install -r requirements.txt

代码更改也按顺序进行:

ch12/app/src/hello.py
import typer
from typing import Optional


def full_output(name: str):
    return f"Hello, {name}!"


app = typer.Typer()


@app.command()
def main(name: Optional[str] = typer.Argument("World")):
    print(full_output(name))


if __name__ == "__main__":
    app()

Typer 使用类型提示来指定传递给 CLI 应用程序的选项和参数的类型,包括可选参数。在前面的代码中,我们告诉 Python 和 Typer,我们的应用程序将 name 作为参数,将其视为字符串,它是可选的,如果没有传入 name,就使用 "World"。

为了理智起见,让我们试试看:

$ cd /path/to/code/ch12/app/src
$ python hello.py
Hello, World!
$ python hello.py Brian
Hello, Brian!

酷毙了。现在我们需要修改测试,以确保 hello.py 在有名称和无名称的情况下都能运行:

ch12/app/tests/test_hello.py
import hello
from typer.testing import CliRunner


def test_full_output():
    assert hello.full_output("Foo") == "Hello, Foo!"


runner = CliRunner()


def test_hello_app_no_name():
    result = runner.invoke(hello.app)
    assert result.stdout == "Hello, World!\n"


def test_hello_app_with_name():
    result = runner.invoke(hello.app, ["Brian"])
    assert result.stdout == "Hello, Brian!\n"

我们没有直接调用 main(),而是使用 Typer 内置的 CliRunner() 来测试应用程序。

让我们首先使用 pytest 运行它,然后使用 tox 运行它:

$ cd /path/to/code/ch12/app
$ pytest -v
========================= test session starts ==========================
collected 3 items
tests/test_hello.py::test_full_output PASSED [ 33%]
tests/test_hello.py::test_hello_app_no_name PASSED [ 66%]
tests/test_hello.py::test_hello_app_with_name PASSED [100%]
========================== 3 passed in 0.02s ===========================

太棒了 与 pytest 一起工作。现在开始使用 tox。因为我们有依赖项,所以需要确保它们安装在 tox 环境中。为此,我们需要在 deps 设置中添加 -r requirements.txt:

ch12/app/tox.ini
[tox]
envlist = py39, py310
skipsdist = true

[testenv]
deps = pytest
       pytest-srcpaths
       -rrequirements.txt
commands = pytest

[pytest]
addopts = -ra
testpaths = tests
pythonpath = src

那很简单。 让我们尝试一下:

$ tox
py39 installed: ..., pytest==x.y,typer==x.y.z
...
========================= test session starts ==========================
...
collected 3 items
tests/test_hello.py ... [100%]
========================== 3 passed in 0.03s ===========================
py310 ..., pytest==x.y,typer==x.y.z
...
========================= test session starts ==========================
...
collected 3 items
tests/test_hello.py ... [100%]
========================== 3 passed in 0.02s ===========================
_______________________________ summary ________________________________
py39: commands succeeded
py310: commands succeeded
congratulations :)

是的!我们有一个应用程序,其外部依赖关系列在 requirements.txt 文件中。我们使用 pythonpath 指定源代码位置。我们在 tox.ini 中添加了 -rrequirements.txt,以便在 tox 环境中安装这些依赖项。我们的测试可以通过 pytest 和 tox 运行。哇哦!

回顾

在本章中,我们了解了如何使用 pytest 和 tox 来测试脚本和应用程序。 在本章的上下文中,脚本指的是直接运行的 Python 文件,如 python my_script.py 中,应用程序指的是需要使用 requests.txt 安装依赖项的 Python 脚本或更大的应用程序。

此外,您还学习了多种测试脚本和应用程序的技术:

  • 使用 subprocess.run() 和管道运行脚本并读取输出

  • 将脚本代码重构为函数,包括 main()

  • if __name__ == "__main__" 块调用 main()

  • 使用 capsys 捕获输出

  • 使用 pythonpath 将测试移至 tests 并将源代码移至 src

  • 在 tox.ini 中为具有依赖性的应用程序指定 requirements.txt

练习

测试脚本可能非常有趣。 在第二个脚本上运行该过程将帮助您记住本章中的技术。

练习从示例脚本 sums.py 开始,该脚本将单独文件 data.txt 中的数字相加。

这是 sums.py:

exercises/ch12/sums.py
Unresolved include directive in modules/ROOT/pages/section02/ch12/ch12.adoc - include::example$exercises/ch12/sums.py[]

这是一个示例数据文件:

exercises/ch12/data.txt
Unresolved include directive in modules/ROOT/pages/section02/ch12/ch12.adoc - include::example$exercises/ch12/data.txt[]

如果我们运行它,我们应该得到 200.00:

$ cd /path/to/code/exercises/ch12
$ python sums.py data.txt
200.00

假设 data.txt 中的数字有效,我们需要测试此脚本。

  1. 使用 subprocess.run() 编写一个测试来测试 sums.py 和 data.txt。

  2. 修改 sums.py 以便测试模块可以导入它。

  3. 编写一个导入 sum 的新测试并使用 capsys 对其进行测试。

  4. 设置 tox 以在至少一个版本的 Python 上运行测试。

  5. (奖励)将测试和源移至测试和 src 中。 进行必要的更改以使测试通过。

  6. (奖励)修改脚本以传入文件名。

    • 将代码运行为 python sums.py data.txt。

    • 您应该能够在多个文件上使用它。

    • 您将添加哪些不同的测试用例?

下一步

编写和运行测试的一个重要部分,我们在本书中还没有讨论太多,那就是测试失败时该怎么办。当一个或多个测试失败时,我们需要找出原因。要么是测试出了问题,要么是我们测试的代码出了问题。无论哪种情况,找出原因的过程都叫做调试。在下一章中,我们将了解 pytest 帮助你调试的许多标志和特性。