第 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 文件中提取外部依赖项?
这些是本章将回答的问题。
测试简单的 Python 脚本
让我们从规范的编码示例开始,Hello World!:
print("Hello, World!")
运行输出应该不足为奇:
$ cd /path/to/code/ch12/script
$ python hello.py
Hello, World!
与任何其他软件一样,脚本是通过运行脚本并检查输出和/或副作用来测试的。
对于 hello.py 脚本,我们的挑战是 (1) 弄清楚如何从测试中运行它,以及 (2) 如何捕获输出。 subprocess 模块是 Python 标准库的一部分,有一个 run() 方法可以很好地解决这两个问题:
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 文件,它不会真正起作用。 无论如何,让我们尝试一下:
[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 来做到这一点:
[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() 函数中:
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 中对此进行了介绍):
import hello
def test_main(capsys):
hello.main()
output = capsys.readouterr().out
assert output == "Hello, World!\n"
我们不仅可以测试 main(),而且随着脚本的增长,我们可以将代码分解为单独的函数。 我们现在可以单独测试这些功能。 分解 Hello, World! 有点傻,但我们还是这么做吧,只是为了好玩:
def full_output():
return "Hello, World!"
def main():
print(full_output())
if __name__ == "__main__":
main()
这里我们将输出内容放入 full_output() 中,并在 main() 中实际打印它。 现在我们可以分别测试它们:
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:
[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 在测试运行期间是什么样子:
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 文件中的依赖关系列表可能只是一个松散的依赖关系列表,例如:
typer
requests
然而,应用程序通过定义已知可用的特定版本来 “固定” 依赖关系更为常见:
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 文件中:
typer==0.3.2
请注意,我还将版本固定为 Typer 0.3.2。 现在我们可以使用以下任一方法安装新的依赖项:
$ pip install typer==0.3.2
或者
$ pip install -r requirements.txt
代码更改也按顺序进行:
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 在有名称和无名称的情况下都能运行:
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:
[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:
Unresolved include directive in modules/ROOT/pages/section02/ch12/ch12.adoc - include::example$exercises/ch12/sums.py[]
这是一个示例数据文件:
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 中的数字有效,我们需要测试此脚本。
-
使用 subprocess.run() 编写一个测试来测试 sums.py 和 data.txt。
-
修改 sums.py 以便测试模块可以导入它。
-
编写一个导入 sum 的新测试并使用 capsys 对其进行测试。
-
设置 tox 以在至少一个版本的 Python 上运行测试。
-
(奖励)将测试和源移至测试和 src 中。 进行必要的更改以使测试通过。
-
(奖励)修改脚本以传入文件名。
-
将代码运行为 python sums.py data.txt。
-
您应该能够在多个文件上使用它。
-
您将添加哪些不同的测试用例?
-