当前位置:首页 > 随笔 > 正文内容

Pytest测试框架入门指南

访客 随笔 2026年6月20日 1

Python测试入门可能令人生畏。虽然标准库提供了一些测试工具,但存在使用不便的局限。

作为Python最流行的测试框架之一,Pytest既能处理高度复杂的测试场景,又不会强制开发者使用其所有功能。你完全可以编写简单的测试,同时享受快速完善的测试运行器和报告功能。

Pytest的核心优势在于降低测试编写门槛。你可以创建零依赖、零配置的测试函数并立即执行。

本文涵盖Pytest入门所需的基础知识,助你提升测试套件质量。

学习目标

完成本文后,你将能够使用Pytest编写测试、理解其强大的错误报告机制,并利用其完整的测试运行器功能。你将自如运用测试函数和类,并能合理选择适用场景。

你将具备以下能力:

  • 使用Pytest编写新测试或扩展现有测试套件
  • 通过Pytest的错误报告精确定位故障
  • 将Pytest作为库和命令行工具使用

核心目标

掌握Pytest测试编写方法,编写更多更优质的测试,成为更高效的开发者。

01 Pytest基础知识

Pytest高度可配置,能处理复杂测试套件。但入门测试不需要复杂配置——测试框架越简单越好。

本节结束后,你将掌握编写首个测试并使用Pytest运行所需的所有知识。

命名规范

Python没有强制规定测试文件和目录的布局规则。但了解以下规范可实现自动化测试发现和执行,无需额外配置。

测试目录与测试文件

主测试目录通常命名为tests。该目录可置于项目根目录,也可与代码模块放在一起。

注意:本文默认采用项目根目录下的tests目录结构。

查看名为jformat的小型Python项目结构:

.
├── README.md
├── jformat
│   ├── __init__.py
│   └── main.py
├── setup.py
└── tests
    └── test_main.py

tests目录位于项目根目录,包含一个测试文件test_main.py。此示例展示两个关键规范:

  • 使用tests目录存放测试文件和嵌套测试目录
  • 测试文件以test前缀命名,标识其包含测试代码

建议:避免使用单数形式test作为目录名,因为它与Python内置模块冲突。始终使用复数形式tests

测试函数

Pytest的一大优势是支持普通函数作为测试。测试函数必须带有test_前缀,以确保Pytest能收集并执行它们。

简单的测试函数示例:

def test_main():
    assert "a string value" == "a string value"

提示:如果你熟悉unittest,在测试函数中使用assert可能会令你惊喜。通过普通assert语句,Pytest能提供详细的失败报告。

测试类与测试方法

测试类和测试方法遵循以下命名规则:

  • 测试类以Test开头
  • 测试方法以test_开头

与Python的unittest不同,Pytest无需继承任何基类。

以下示例展示检查用户名的测试类:

class TestUser:
    def test_username(self):
        assert default() == "default username"

运行测试

Pytest既是测试框架也是测试运行器。作为命令行工具,它主要负责:

  • 自动发现并收集所有测试文件、测试类和测试函数
  • 执行全部测试并跟踪结果
  • 提供详尽的测试报告

假设我们有test_main.py文件:

# test_main.py 内容
def test_main():
    assert True

在文件所在目录运行pytest命令:

$ pytest
=========================== test session starts ============================
platform -- Python 3.10.1, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private/tmp/project
collected 1 item

test_main.py . [100%]

============================ 1 passed in 0.00s =============================

Pytest无需任何配置即可自动收集并执行测试。

强大的断言机制

示例测试均使用普通的assert语句。在普通Python中,assert的失败报告不够详细。但Pytest在后台对其进行了增强,无需额外代码或配置即可提供丰富的比较信息。

使用普通assert意味着你可以使用所有Python运算符:><!=>=<=等。这是Pytest最核心的特性之一——无需学习新的断言语法。

比较长字符串时的失败报告:

================================ FAILURES =================================
____________________________ test_long_strings _____________________________

def test_long_strings():
    left = "this is a very long strings to be compared with another long string"
    right = "This is a very long string to be compared with another long string"
    > assert left == right
    E AssertionError: assert 'this is a ve...r long string' == 'This is a ve...r long string'
    E - This is a very long string to be compared with another long string
    E ? ^
    E + this is a very long strings to be compared with another long string
    E ? ^ +

test_main.py:4: AssertionError

Pytest精准指出字符串开头大小写不一致以及多余字符的问题。

列表比较失败报告:

________________________________ test_lists ________________________________

def test_lists():
    left = ["sugar", "wheat", "coffee", "salt", "water", "milk"]
    right = ["sugar", "coffee", "wheat", "salt", "water", "milk"]
    > assert left == right
    E AssertionError: assert ['sugar', 'wh...ater', 'milk'] == ['sugar', 'co...ater', 'milk']
    E At index 1 diff: 'wheat' != 'coffee'
    E Full diff:
    E - ['sugar', 'coffee', 'wheat', 'salt', 'water', 'milk']
    E ? ---------
    E + ['sugar', 'wheat', 'coffee', 'salt', 'water', 'milk']
    E ? +++++++++

test_main.py:9: AssertionError

报告不仅指出索引1的值不同,还提供完整的差异对比。

字典比较失败时:

____________________________ test_dictionaries _____________________________

def test_dictionaries():
    left = {"street": "Ferry Ln.", "number": 39, "state": "Nevada", "zipcode": 30877, "county": "Frett"}
    right = {"street": "Ferry Lane", "number": 38, "state": "Nevada", "zipcode": 30877, "county": "Frett"}
    > assert left == right
    E AssertionError: assert {'county': 'F...rry Ln.', ...} == {'county': 'F...ry Lane', ...}
    E Omitting 3 identical items, use -vv to show
    E Differing items:
    E {'street': 'Ferry Ln.'} != {'street': 'Ferry Lane'}
    E {'number': 39} != {'number': 38}
    E Full diff:
    E {
    E 'county': 'Frett',...
    E
    E ...Full output truncated (12 lines hidden), use '-vv' to show

使用-vv标志可以查看更详细的差异:

$ pytest -vv
____________________________ test_dictionaries _____________________________

def test_dictionaries():
    left = {"street": "Ferry Ln.", "number": 39, "state": "Nevada", "zipcode": 30877, "county": "Frett"}
    right = {"street": "Ferry Lane", "number": 38, "state": "Nevada", "zipcode": 30877, "county": "Frett"}
    > assert left == right
    E AssertionError: assert {'county': 'Frett',\n 'number': 39,\n 'state': 'Nevada',\n 'street': 'Ferry Ln.',\n 'zipcode': 30877} == {'county': 'Frett',\n 'number': 38,\n 'state': 'Nevada',\n 'street': 'Ferry Lane',\n 'zipcode': 30877}
    E Common items:
    E {'county': 'Frett', 'state': 'Nevada', 'zipcode': 30877}
    E Differing items:
    E {'number': 39} != {'number': 38}
    E {'street': 'Ferry Ln.'} != {'street': 'Ferry Lane'}
    E Full diff:
    E {
    E 'county': 'Frett',
    E - 'number': 38,
    E ? ^
    E + 'number': 39,
    E ? ^
    E 'state': 'Nevada',
    E - 'street': 'Ferry Lane',
    E ? - ^
    E + 'street': 'Ferry Ln.',
    E ? ^
    E 'zipcode': 30877,
    E }

Pytest不仅找出所有差异,还提供精确的错误定位,方便快速修复。

02 测试类与方法

除了测试函数,Pytest也支持测试类。无需继承,只需遵循简单规则。使用测试类能提升代码灵活性和可复用性。

创建测试类

假设我们有一个检查文件内容是否包含"yes"的函数:

import os

def is_done(path):
    if not os.path.exists(path):
        return False
    with open(path) as _f:
        contents = _f.read()
    if "yes" in contents.lower():
        return True
    elif "no" in contents.lower():
        return False

test_files.py中创建测试类:

class TestIsDone:
    def test_yes(self):
        with open("/tmp/test_file", "w") as _f:
            _f.write("yes")
        assert is_done("/tmp/test_file") is True

    def test_no(self):
        with open("/tmp/test_file", "w") as _f:
            _f.write("no")
        assert is_done("/tmp/test_file") is False

注意:生产环境中建议使用tempfile库安全地创建临时文件。

运行测试:

$ pytest -v test_files.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private/
collected 2 items

test_files.py::TestIsDone::test_yes PASSED [ 50%]
test_files.py::TestIsDone::test_no PASSED [100%]

============================== 2 passed in 0.00s ===============================

辅助方法

测试类提供多种设置和清理方法:

  • setup():每个测试前执行
  • teardown():每个测试后执行
  • setup_class():类中所有测试前执行一次
  • teardown_class():类中所有测试后执行一次

使用teardown()清理临时文件:

class TestIsDone:
    def teardown(self):
        if os.path.exists("/tmp/test_file"):
            os.remove("/tmp/test_file")

    def test_yes(self):
        with open("/tmp/test_file", "w") as _f:
            _f.write("yes")
        assert is_done("/tmp/test_file") is True

    def test_no(self):
        with open("/tmp/test_file", "w") as _f:
            _f.write("no")
        assert is_done("/tmp/test_file") is False

使用setup()统一文件路径:

class TestIsDone:
    def setup(self):
        self.tmp_file = "/tmp/test_file"

    def teardown(self):
        if os.path.exists(self.tmp_file):
            os.remove(self.tmp_file)

    def test_yes(self):
        with open(self.tmp_file, "w") as _f:
            _f.write("yes")
        assert is_done(self.tmp_file) is True

    def test_no(self):
        with open(self.tmp_file, "w") as _f:
            _f.write("no")
        assert is_done(self.tmp_file) is False

自定义辅助方法:

def write_tmp_file(self, content):
    with open(self.tmp_file, "w") as _f:
        _f.write(content)

整合后的完整测试类:

class TestIsDone:
    def setup(self):
        self.tmp_file = "/tmp/test_file"

    def teardown(self):
        if os.path.exists(self.tmp_file):
            os.remove(self.tmp_file)

    def write_tmp_file(self, content):
        with open(self.tmp_file, "w") as _f:
            _f.write(content)

    def test_yes(self):
        self.write_tmp_file("yes")
        assert is_done(self.tmp_file) is True

    def test_no(self):
        self.write_tmp_file("no")
        assert is_done(self.tmp_file) is False

何时使用类而非函数

没有严格规定,但可以参考以下问题:

  • 测试是否需要共享设置或清理逻辑?
  • 将相关测试分组是否逻辑更清晰?
  • 测试套件是否包含一定数量的测试?
  • 测试是否能从公共辅助方法中受益?

03 实践练习

练习目标:使用Pytest测试并修复一个包含bug的函数,体验Pytest强大的错误报告功能。

待测试的函数admin_command()能根据参数决定是否使用sudo前缀系统命令:

def admin_command(command, sudo=True):
    """
    Prefix a command with `sudo` unless explicitly not needed.
    Expects `command` to be a list.
    """
    if sudo:
        ["sudo"] + command
    return command

步骤1 - 创建测试文件

创建test_exercise.py并添加测试:

class TestAdminCommand:
    def command(self):
        return ["ps", "aux"]

    def test_no_sudo(self):
        result = admin_command(self.command(), sudo=False)
        assert result == self.command()

    def test_sudo(self):
        result = admin_command(self.command(), sudo=True)
        expected = ["sudo"] + self.command()
        assert result == expected

步骤2 - 运行测试并定位问题

$ pytest test_exercise.py
=================================== FAILURES ===================================
__________________________ TestAdminCommand.test_sudo __________________________

def test_sudo(self):
    result = admin_command(self.command(), sudo=True)
    expected = ["sudo"] + self.command()
    > assert result == expected
    E AssertionError: assert ['ps', 'aux'] == ['sudo', 'ps', 'aux']
    E At index 0 diff: 'ps' != 'sudo'
    E Right contains one more item: 'aux'

test_exercise.py:24: AssertionError
=========================== short test summary info ============================
FAILED test_exercise.py::TestAdminCommand::test_sudo
========================= 1 failed, 1 passed in 0.04s ==========================

结果显示test_sudo失败——result中没有"sudo"。原因是admin_command中列表拼接的结果未被返回。

步骤3 - 修复bug

修改admin_command函数:

def admin_command(command, sudo=True):
    if sudo:
        return ["sudo"] + command
    return command

重新运行测试:

$ pytest -v test_exercise.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private
collected 2 items

test_exercise.py::TestAdminCommand::test_no_sudo PASSED [ 50%]
test_exercise.py::TestAdminCommand::test_sudo PASSED [100%]

============================== 2 passed in 0.00s ===============================

步骤4 - 扩展功能并添加测试

首先添加检查非列表输入的测试:

import pytest

def test_non_list_commands(self):
    with pytest.raises(TypeError):
        admin_command("some command", sudo=True)

更新函数,添加类型检查和错误信息:

def admin_command(command, sudo=True):
    if not isinstance(command, list):
        raise TypeError(f"was expecting command to be a list, but got a {type(command)}")
    if sudo:
        return ["sudo"] + command
    return command

更新测试以验证错误消息:

def test_non_list_commands(self):
    with pytest.raises(TypeError) as error:
        admin_command("some command", sudo=True)
    assert error.value.args[0] == "was expecting command to be a list, but got a <class 'str'>"

最终运行全部测试:

$ pytest -v test_exercise.py
============================= test session starts ==============================
collected 3 items

test_exercise.py::TestAdminCommand::test_no_sudo PASSED [ 33%]
test_exercise.py::TestAdminCommand::test_sudo PASSED [ 66%]
test_exercise.py::TestAdminCommand::test_non_list_commands PASSED [100%]

============================== 3 passed in 0.00s ===============================

最终验证

完整test_exercise.py包含:

  • admin_command()函数,包含参数验证和错误处理
  • TestAdminCommand测试类,包含三个测试方法
  • 全部测试通过,无错误

04 知识检测

  1. Pytest中使用普通assert语句的优势是?
    A. 加快测试执行速度
    B. 支持使用所有Python运算符进行比较
    C. 支持并行执行测试
  2. 什么时候应该将测试分组到类中?
    A. 测试需要共享设置或清理逻辑
    B. 测试需要使用assert语句
    C. 测试需要随机化以提高覆盖率
  3. Pytest能否作为Python库使用?
    A. 不能,Pytest没有库模块
    B. 不能,只能通过插件扩展
    C. 可以,Pytest提供了可导入的模块和辅助程序

总结

本文介绍了Pytest的基础用法——Python最受欢迎的测试框架之一。我们从编写测试的基础规范开始,演示了命令行工具的使用方法,然后探讨了测试类和方法带来的灵活性提升。

通过实际练习,你体验了如何为有问题的函数编写测试,利用测试定位bug并进行修复。

现在,你应该能够轻松阅读和使用Pytest编写的测试,无论是使用函数还是类的形式。你可以为项目编写新测试或扩展现有测试套件。

标签: pytest

相关文章

可以按小时收费的VPS

很多 VPS 提供商都支持 按小时计费(hourly billing),想短期试用 / 临时搭建节点、测试网络、短期项目等场景非常合适。下面是当前最主流且靠谱的按小时 VPS 选项,分别按不同需求场景整理: 1. Vultr(全球节点,包括日本) 按小时计费 可选机房:东京 / 大阪 / 洛杉矶 / 法兰克福 / 伦敦 … 支持 PayPal(部分情况),但更常用信用卡/PayPal+卡价格参考$...

在 iPhone 上下载国外App

地区/国家限制App Store 会根据 Apple ID 的国家或地区限制应用下载。如果你的 Apple ID 绑定的是中国大陆,就可能无法下载 OpenAI 官方的 ChatGPT 应用,因为它在大陆 App Store 不上架。解决办法:换成美国、加拿大、香港等地区的 Apple ID。或者在现有 Apple ID 上更改地区。注册一个国外 Apple ID(推荐)比如注册 美国区 Appl...

Node.js 中的异步编程:回调与 Promise

Node.js 是一个基于 JavaScript 构建的单线程、非阻塞运行环境,它通过异步编程机制来高效处理多个操作。在执行如文件读取、API 请求或数据库查询等任务时,Node.js 不会等待这些操作完成,而是使用回调函数和 Promise 来避免阻塞主线程。 回调方式实现异步 那么当异步操作完成后,Node.js 如何知道接下来要做什么呢?这就要用到 回调函数(callback)。 回调本质上...

Selenium自动化测试入门指南

Selenium自动化测试入门指南

什么是自动化测试? 自动化测试是指利用软件工具自动执行测试用例,模拟用户操作,如打开网页、点击链接、输入文本等,并验证结果是否符合预期。 其主要优点包括: 大幅减少人工成本 测试速度快 可以在非工作时间运行 支持持续集成和交付 然而,它也存在一些局限性,例如开发成本较高、不适合快速变化的项目、依赖稳定的UI界面等。 自动化测试的应用条件 适合引入自动化测试的情况包括: 手动测试耗时且需要大量...

MariaDB Galera集群故障快速恢复指南

OpenStack控制节点采用三节点MariaDB Galera集群架构。当数据库集群因故障重启时,有时会出现Galera集群无法正常启动的问题。虽然有多种方法可以恢复数据库服务,但如何实现快速启动同时确保数据完整性呢? 通过分析日志发现,MariaDB Galera集群节点宕机时会在日志中输出以下信息: [Note] WSREP: 新集群视图:全局状态: 874d8e7e-5980-11e8-8...

Android 中 EventBus 的通信机制与实现原理深度解析

EventBus 核心设计思想 EventBus 是一个基于观察者模式的事件总线框架,广泛应用于 Android 平台以实现组件解耦。它通过中心化的消息分发机制,使不同层级、不同线程的对象能够以"发布-订阅"方式通信,避免了传统接口回调或广播带来的强依赖问题。 核心角色说明 事件(Event):任意 Java 对象,作为数据载体,如网络状态变更通知、用户登录信息等。 发布者(Publi...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。