练习

已完成

在本练习中,你将结合使用 pytest 和 @pytest.mark.parametrize 来测试一个函数,包括预计会引发异常的用例。 然后,将一个基于类的测试从 xUnit 风格的 setup_method() 和 teardown_method() 方法重构为固定例程。 使用参数化和固定装置有助于编写测试,这些测试涵盖更多案例,且重复程度较低且设置更清晰。

有关设置帮助,请参阅 Python 文档中的虚拟环境部分、pip 文档pip install,以及 pytest 文档中的安装 pytest部分。 有关本练习中 pytest 功能的更多详细信息,请参阅用于 参数化测试函数的 pytest 文档、 有关预期异常、 固定装置、 xUnit 样式设置以及 临时目录和文件的断言。

在您开始之前

按照模块先决条件中所述,使用 Python 3.10 或更高版本。 如果已安装并激活了 pytest 的虚拟环境,请继续执行步骤 1。 否则,请在要在其中创建 test_advanced.py的文件夹中创建和激活虚拟环境。

在 Windows PowerShell 上:

python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install pytest
python -m pytest --version

在 macOS、Linux 或适用于 Linux 的 Windows 子系统上:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pytest
python -m pytest --version

版本命令应打印 pytest 版本,类似于以下输出:

pytest x.y.z

安装命令不会将 pytest 固定到特定版本。 Pip 会根据包元数据选择与虚拟环境中的 Python 版本兼容的稳定 pytest 版本。

在本练习中始终使用 python -m pytest,以确保 pytest 从活动环境中运行。 如果Windows PowerShell 阻止 Activate.ps1,请参阅 PowerShell 执行策略选项的 Python venv 文档,或直接针对 pip 和 pytest 命令运行虚拟环境的Python。 例如:

.\.venv\Scripts\python -m pip install --upgrade pip
.\.venv\Scripts\python -m pip install pytest
.\.venv\Scripts\python -m pytest -v test_advanced.py

步骤 1 - 为本练习添加测试文件

  1. 在运行 pytest 的同一文件夹中,创建名为 test_advanced.py的新测试文件。 不要将文件放在 .venv 文件夹中。 添加以下代码:

    import pytest
    
    
    def str_to_bool(string):
        normalized = string.lower()
        if normalized in ["yes", "y", "1"]:
            return True
        if normalized in ["no", "n", "0"]:
            return False
        raise ValueError(f"Cannot convert {string!r} to a boolean")
    

    该函数 str_to_bool() 接受字符串作为输入。 如果识别出的 true 值,它将返回 True;如果识别出的 false 值,将返回 False;对于任何其他值,将引发 ValueError。

  2. 在同一个文件中,追加对 str_to_bool() 函数的测试。 使用 @pytest.mark.parametrize 首先测试真值:

    @pytest.mark.parametrize("string", ["Y", "y", "1", "YES"])
    def test_str_to_bool_true(string):
        assert str_to_bool(string) is True
    
  3. 接下来,再添加一个使用假值的测试:

    @pytest.mark.parametrize("string", ["N", "n", "0", "NO"])
    def test_str_to_bool_false(string):
        assert str_to_bool(string) is False
    

    现在有两个测试函数,它们涵盖了 True 和 False 返回值的代表性输入。

  4. 最后,为函数不应接受的值追加参数化测试。 将 pytest.raises() 用作上下文管理器,这样只有在 str_to_bool() 引发预期异常时,测试才会通过。

    @pytest.mark.parametrize("string", ["maybe", "2"])
    def test_str_to_bool_invalid(string):
        with pytest.raises(ValueError, match="Cannot convert"):
            str_to_bool(string)
    

    该 match 参数使用正则表达式检查异常消息。 在这里,测试仅检查消息的稳定部分,因此它不依赖于确切的无效值。

注意

为简单起见,本练习将测试中的代码和测试保留在同一文件中。 在实际Python项目中,应用程序代码和测试通常分为不同的文件和目录,例如 src/ 包和 tests/ 目录。

步骤 2 - 运行测试并浏览报告

添加测试后,运行 pytest 并检查输出。 使用增加的详细程度标志 (-v),这样便可以看到每个输入值都被视为单独的测试。

在 Windows PowerShell 上:

python -m pytest -v test_advanced.py

在 macOS、Linux 或适用于 Linux 的 Windows 子系统上:

python -m pytest -v test_advanced.py

输出应类似于以下报表:

============================= test session starts ==============================
platform ... -- Python 3.x.y, pytest-x.y.z, pluggy-x.y.z -- ...
rootdir: ...
...
collected 10 items

test_advanced.py::test_str_to_bool_true[Y] PASSED                        [ 10%]
test_advanced.py::test_str_to_bool_true[y] PASSED                        [ 20%]
test_advanced.py::test_str_to_bool_true[1] PASSED                        [ 30%]
test_advanced.py::test_str_to_bool_true[YES] PASSED                      [ 40%]
test_advanced.py::test_str_to_bool_false[N] PASSED                       [ 50%]
test_advanced.py::test_str_to_bool_false[n] PASSED                       [ 60%]
test_advanced.py::test_str_to_bool_false[0] PASSED                       [ 70%]
test_advanced.py::test_str_to_bool_false[NO] PASSED                      [ 80%]
test_advanced.py::test_str_to_bool_invalid[maybe] PASSED                 [ 90%]
test_advanced.py::test_str_to_bool_invalid[2] PASSED                     [100%]

============================== 10 passed in 0.01s ==============================

操作系统、Python版本、pytest 版本、可执行路径、根目录、插件和缓存行、集合进度格式、换行和运行时可能有所不同。 虽然只编写了三个测试函数,但 pytest 收集了 10 个测试,因为每个参数化输入值都作为自己的测试用例运行。

步骤 3 - 将 setup 和 teardown 重构为固定例程

  1. 将基于类的测试追加到 test_advanced.py 文件中。 此测试使用 xUnit 样式 setup_method() 和 teardown_method() 方法创建和删除每个测试方法周围的文件:

    from pathlib import Path
    
    
    class TestFile:
    
        def setup_method(self):
            self.path = Path("done")
            self.path.write_text("1", encoding="utf-8")
    
        def teardown_method(self):
            self.path.unlink(missing_ok=True)
    
        def test_done_file(self):
            contents = self.path.read_text(encoding="utf-8")
            assert contents == "1"
    

    此测试可以通过,但它在当前工作目录中写入固定文件名,并且需要单独的清理代码。 如果测试运行被中断或测试同时进行,例如与 pytest-xdist 或本地运行重叠,这种方法可能会留下过时的文件、覆盖一个名为done的现有文件,或导致测试相互影响。

  2. 添加一个模块级固定例程,它使用内置的 tmp_path 固定例程来创建一个临时文件。 在下一步中,你将更新类以使用此固定例程,而不是 setup 和 teardown 方法。 若要提高可读性,请将此模块级固定装置放置在参数化 str_to_bool 测试之后和类 TestFile 之前。 Pytest 会自动发现模块级固定例程,无论它们出现在模块中的什么位置,因此该位置仅为方便人类阅读,对 pytest 无实际作用:

    @pytest.fixture
    def tmp_file(tmp_path):
        def write():
            path = tmp_path / "done"
            path.write_text("1", encoding="utf-8")
            return path
        return write
    

    tmp_file() 固定例程使用 pytest 的 tmp_path 固定例程来为每次测试函数调用(包括每个参数化用例)提供一个唯一的 pathlib.Path 临时目录。 Pytest 管理临时目录,因此测试不依赖于硬编码的文件路径。

  3. 删除之前的 TestFile 类(包括其 setup_method() 和 teardown_method() 方法),并将其替换为使用固定例程而不是 setup 和 teardown 方法的此最终版本:

    class TestFile:
    
        def test_done_file(self, tmp_file):
            path = tmp_file()
            contents = path.read_text(encoding="utf-8")
            assert contents == "1"
    

    还可以删除在上一步中添加的 from pathlib import Path 导入,因为测试不再直接创建 Path 对象。

检查你的工作

现在,你应该有一个名为 test_advanced.py 的Python文件,其中包含:

  • 一个 str_to_bool() 函数,它接受字符串并返回用于识别 true 和 false 值的布尔值。
  • 针对函数 str_to_bool() 的三个参数化测试:一个用于测试 True 值,另一个用于测试 False 值,还有一个用于检查无效值是否会引发 ValueError。
  • 一个自定义 pytest 固定例程,它使用内置 tmp_path 固定例程来创建包含某些内容的临时 done 文件。
  • 单个 TestFile 类,它包含一个使用自定义 tmp_file 固定例程来创建和读取文件的方法。
  • 在最终版本中没有 from pathlib import Path 导入和没有 setup_method()/teardown_method() 方法。

最终文件应如下所示:

import pytest


def str_to_bool(string):
    normalized = string.lower()
    if normalized in ["yes", "y", "1"]:
        return True
    if normalized in ["no", "n", "0"]:
        return False
    raise ValueError(f"Cannot convert {string!r} to a boolean")


@pytest.mark.parametrize("string", ["Y", "y", "1", "YES"])
def test_str_to_bool_true(string):
    assert str_to_bool(string) is True


@pytest.mark.parametrize("string", ["N", "n", "0", "NO"])
def test_str_to_bool_false(string):
    assert str_to_bool(string) is False


@pytest.mark.parametrize("string", ["maybe", "2"])
def test_str_to_bool_invalid(string):
    with pytest.raises(ValueError, match="Cannot convert"):
        str_to_bool(string)


@pytest.fixture
def tmp_file(tmp_path):
    def write():
        path = tmp_path / "done"
        path.write_text("1", encoding="utf-8")
        return path
    return write


class TestFile:

    def test_done_file(self, tmp_file):
        path = tmp_file()
        contents = path.read_text(encoding="utf-8")
        assert contents == "1"

再次运行测试文件。

在 Windows PowerShell 上:

python -m pytest -v test_advanced.py

在 macOS、Linux 或适用于 Linux 的 Windows 子系统上:

python -m pytest -v test_advanced.py

最终报告应显示 11 个收集的测试:参数化测试中的 10 个事例和一个基于类的测试。

============================= test session starts ==============================
platform ... -- Python 3.x.y, pytest-x.y.z, pluggy-x.y.z -- ...
rootdir: ...
...
collected 11 items

test_advanced.py::test_str_to_bool_true[Y] PASSED                        [  9%]
test_advanced.py::test_str_to_bool_true[y] PASSED                        [ 18%]
test_advanced.py::test_str_to_bool_true[1] PASSED                        [ 27%]
test_advanced.py::test_str_to_bool_true[YES] PASSED                      [ 36%]
test_advanced.py::test_str_to_bool_false[N] PASSED                       [ 45%]
test_advanced.py::test_str_to_bool_false[n] PASSED                       [ 54%]
test_advanced.py::test_str_to_bool_false[0] PASSED                       [ 63%]
test_advanced.py::test_str_to_bool_false[NO] PASSED                      [ 72%]
test_advanced.py::test_str_to_bool_invalid[maybe] PASSED                 [ 81%]
test_advanced.py::test_str_to_bool_invalid[2] PASSED                     [ 90%]
test_advanced.py::TestFile::test_done_file PASSED                        [100%]

============================== 11 passed in 0.01s ==============================

所有测试都应通过,且不会出错。