[编程基础] Python测试基础教程

Python测试体系看似庞大、细节繁多,但入门门槛并不高。对于已经完成基础功能开发、尚未编写任何测试的应用,本教程将从最基础的实践入手,逐步过渡到更高级的测试技巧,重点介绍如何利用Python自带工具构建自动化测试体系。

内容将涵盖:基础测试的编写与执行方法、相关工具的使用方式、应用性能的检查手段,以及常见安全问题的排查思路。本文主要参考资料包括:

目录

  • [1 基础概念](#1 基础概念)
    • [1.1 测试定义](#1.1 测试定义)
    • [1.2 集成测试与单元测试](#1.2 集成测试与单元测试)
    • [1.3 Python中的测试](#1.3 Python中的测试)
    • [1.4 Python测试运行器](#1.4 Python测试运行器)
      • [1.4.1 主流工具对比](#1.4.1 主流工具对比)
      • [1.4.2 使用unittest运行测试](#1.4.2 使用unittest运行测试)
      • [1.4.3 使用pytest运行测试](#1.4.3 使用pytest运行测试)
  • [1.5 测试体系与流程](#1.5 测试体系与流程)
  • [2 测试进阶与实践](#2 测试进阶与实践)
    • [2.1 测试用例](#2.1 测试用例)
      • [2.1.1 用例函数](#2.1.1 用例函数)
      • [2.1.2 测试代码编写](#2.1.2 测试代码编写)
      • [2.1.3 测试执行](#2.1.3 测试执行)
    • [2.2 单元测试进阶](#2.2 单元测试进阶)
      • [2.2.1 测试夹具](#2.2.1 测试夹具)
      • [2.2.2 参数化测试](#2.2.2 参数化测试)
    • [2.3 负面测试](#2.3 负面测试)
    • [2.4 集成测试](#2.4 集成测试)
      • [2.4.1 集成测试理解](#2.4.1 集成测试理解)
      • [2.4.2 外部数据驱动测试](#2.4.2 外部数据驱动测试)
    • [2.5 多环境兼容测试](#2.5 多环境兼容测试)
  • [3 参考](#3 参考)

1 基础概念

1.1 测试定义

测试是通过运行程序并验证其实际行为是否符合预期结果的过程。许多开发者可能已在无意中实践过测试,例如在首次运行应用程序时检查各项功能。这种不依赖预设计划、自由探索的方式属于手动测试。若对手动测试进行系统化组织,可以形成如下流程:

  • 列出所有功能、输入类型及预期结果;
  • 在每次代码变更后,逐一核对测试清单。

那么,为什么要从手动测试转向自动化测试?手动测试存在两个主要局限:

  • 随着功能增多,重复执行测试清单变得极其繁琐且低效;
  • 人工比对结果容易出错,难以满足快速迭代的需求。

自动化测试正是为解决这类问题而生。它以脚本替代人工执行测试流程,自动运行功能并比对实际与预期结果,输出校验结论。其核心价值在于一次编写、重复复用,可在代码迭代中快速反馈,并在构建后持续扩展与完善。

1.2 集成测试与单元测试

在软件测试领域,术语繁多,概念容易混淆。在理解了自动化测试与手动测试的区别之后,有必要进一步认识两个构成软件质量基石的核心概念:集成测试与单元测试。之所以优先讨论这两者,是因为它们分别定义了整体协作与局部细节的检验逻辑。其他类型的测试,如端到端测试、性能测试等,大多建立在对这两者的理解之上。关于两者的详细对比,可参考:What Is a Unit Test? Unit vs Integration Testing Guide

为了直观说明两者的区别,可以以做一道番茄炒蛋为例。将准备好的食材下锅翻炒,属于测试步骤;在菜出锅前尝一口咸淡是否合适,属于核对结果。

一道成功的番茄炒蛋,需要多个环节的紧密配合:鸡蛋要打散均匀,番茄要切块适中,火候要控制得当,盐和糖的比例要准确,出锅时间要恰到好处。像这样验证所有环节能否协同完成一道菜的检验过程,就是集成测试。这些环节就好比软件中的各个函数或模块,必须环环相扣,才能最终端出一盘可口的菜。

集成测试面临的典型困境在于问题定位。假如最后炒出的菜味道不对,原因可能分散在多个环节:

  • 鸡蛋打散时没有加盐,导致整体偏淡;
  • 番茄切得太大块,汁水未能充分炒出;
  • 糖放得过多,盖过了番茄本身的酸味。

如果无法将问题缩小到具体的步骤,整道菜从备料到出锅就可能需要全部推倒重来。此时,单元测试的价值便凸显出来。想象在正式做菜之前,我们可以单独测试每一个环节:单独炒一份鸡蛋以检查是否嫩滑,单独尝一口切好的番茄以确认是否够熟够味,单独用少量油试一下火候是否足够猛烈。这种抛开整道菜、只针对某一环节独立检验的方式,正是单元测试的精髓。它的范围极小,目的单一,却能提前发现潜在的问题。

通过这一对比,两种测试的边界便一目了然:集成测试负责回答所有步骤配合起来之后整道菜是否好吃,而单元测试负责回答每一个步骤本身是否做对。

1.3 Python中的测试

在Python中,可以同时编写集成测试和单元测试。以针对字符串方法upper()编写单元测试为例,需要将其输出与已知的正确结果进行比对。例如,下面演示了如何验证"world".upper()的结果是否等于"WORLD"

python 复制代码
>>> assert "world".upper() == "WORLD", "结果应为 WORLD"

由于结果正确,这条断言语句在Python交互式环境中不会产生任何输出。但如果upper()的结果有误,断言就会失败,并抛出AssertionError异常。我们可以故意传入一个错误的值来触发这个异常:

python 复制代码
>>> assert "world".upper() == "WORLd", "结果应为 WORLD"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: 结果应为 WORLD

然而,实际开发中不应依赖交互式环境进行手动测试,而是将测试代码放入一个Python文件,例如test_upper.py中,然后执行该文件:

python 复制代码
def test_upper():
    assert "hello".upper() == "HELLO", "结果应为 HELLO"

def test_upper_mixed():
    assert "Hello World".upper() == "HELLO WORLd", "结果应为 HELLO WORLD"

if __name__ == "__main__":
    test_upper()
    test_upper_mixed()
    print("全部测试通过")

执行后会得到类似这样的错误输出:

bash 复制代码
$ python test_upper.py
Traceback (most recent call last):
  File "test_upper.py", line 9, in <module>
    test_upper_mixed()
  File "test_upper.py", line 5, in test_upper_mixed
    assert "Hello World".upper() == "HELLO WORLd", "结果应为 HELLO WORLD"
AssertionError: 结果应为 HELLO WORLD

从输出中可以清晰地看到代码中的错误:控制台会给出错误所在位置以及预期结果与实际结果之间的差异。如果希望在编写文档的同时测试代码,并确保代码与文档始终保持一致,可以使用Python内置的doctest模块。

对于简单的检查,上述使用assert的方式已经足够。但当有多个测试可能失败时,就需要引入测试运行器(test runner)。测试运行器是专门用于运行测试、检查输出、帮助调试和诊断测试用例及其所在应用程序的工具。

1.4 Python测试运行器

1.4.1 主流工具对比

Python生态中有多种测试运行器可供选择。当前最为流行、使用最广泛的三款测试运行器如下:

  • unittest

    作为Python标准库的一部分,unittest无需额外安装,具有良好的兼容性。其主要优点是与标准库无缝集成,适合在依赖受限或环境封闭的项目中使用。缺点在于编写测试时需要较多的样板代码,且插件生态相对薄弱。尽管如此,unittest仍然被广泛用于大型项目以及Python标准库自身的测试中,具有较高的受欢迎程度。

  • pytest
    pytest是目前功能最强大、社区最活跃的测试运行器之一。其优点包括强大的参数化测试功能、丰富的插件生态,以及简洁的代码风格和清晰明了的断言失败信息。缺点在于部分高级特性的学习曲线相对陡峭。pytest在当前Python测试框架中受欢迎程度最高,被大量开源项目和企业广泛采用。

  • nose2

    作为对 unittest 的扩展,nose2支持自动发现测试用例,并提供比原生unittest更丰富的插件系统。其优点在于减少了手动组织和配置测试套件的工作量,断言方式也更为简洁。缺点是项目维护不活跃,社区支持有限,遇到问题难以获得及时更新或帮助。

尽管pytest更为流行,本教程仍选择以unittest作为教学工具,主要基于两点考虑。首先,unittest是Python标准库的一部分,无需额外安装即可使用,从而降低了环境配置成本。其次,它的结构清晰、行为显式,有助于初学者建立对测试基本概念与生命周期的理解。

在掌握unittest之后,再迁移到pytestnose2会更加自然和轻松。因此,以unittest作为起点,是一条稳健且具有通用性的学习路径。

1.4.2 使用unittest运行测试

unittest是Python标准库内置的测试框架,自Python 2.1起便随发行版一同提供。它集测试用例组织与测试运行于一体,在商业应用和开源项目中均有广泛使用。

unittest对测试编写有以下要求:

  • 测试用例需放在继承自unittest.TestCase的类中,每个测试方法须以test_开头。
  • 断言时需使用TestCase类提供的方法(如assertEqual),而非Python内置的assert语句。

编写一个unittest测试用例的基本步骤如下:

  1. 导入unittest模块。
  2. 创建继承unittest.TestCase的测试类。
  3. 在类中定义以test_开头的方法,第一个参数为self
  4. 在方法内使用self.assertEqual()等断言方法验证结果。
  5. if __name__ == '__main__':块中调用unittest.main(),以便直接运行脚本时启动测试。

假设我们有一个现成的功能类,保存在math_tools.py文件中:

python 复制代码
# math_tools.py
class MathTools:
    """一个简单的数学工具类"""
    def double(self, n):
        """返回一个数字的两倍"""
        return n * 2

针对该类编写配套单元测试代码。新建文件test_math_tools.py并写入如下内容:

python 复制代码
# test_math_tools.py
import unittest
from math_tools import MathTools

# 新建测试类,必须继承unittest.TestCase
# 该类统一管理MathTools对应的全部测试用例
class TestMathTools(unittest.TestCase):
    # 编写测试方法,方法名必须以test_作为前缀
    # unittest框架会自动识别并运行所有符合命名规则的方法
    def test_double(self):
        """校验double方法计算逻辑"""
        # 实例化待测试类
        tool = MathTools()
        # 调用目标方法,获取运行产生的实际结果
        result = tool.double(3)
        # 借助断言比对实际结果与预期结果
        # 可传入自定义提示文本,便于测试异常时快速定位问题
        self.assertEqual(result, 6, "double(3)运算结果不符合预期")

    # 编写异常演示用例,模拟代码校验失败场景
    def test_double_fail_demo(self):
        """模拟测试失败,展示框架报错效果"""
        tool = MathTools()
        result = tool.double(4)
        # 设置错误的预期值,触发断言失败
        self.assertEqual(result, 10, "double(4)预期结果配置错误,用于失败演示")

if __name__ == "__main__":
    unittest.main()

由于测试文件中同时包含一个正常用例和一个故意写错的演示用例,终端将输出如下混合结果:

复制代码
.F
======================================================================
FAIL: test_double_fail_demo (__main__.TestMathTools)
模拟测试失败,展示框架报错效果
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_tools.py", line 34, in test_double_fail_demo
    self.assertEqual(result, 10, "double(4)预期结果配置错误,用于失败演示")
AssertionError: 8 != 10 : double(4)预期结果配置错误,用于失败演示
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)

对照上述输出,各部分含义如下:开头的.F表示共执行两项测试,.代表第一个用例通过,F代表第二个用例失败。FAIL: test_double_fail_demo指明了失败所在的测试方法。AssertionError: 8 != 10展示了实际结果与预期值的具体差异。末尾的Ran 2 tests统计了总用例数,FAILED (failures=1)汇总了失败数量。

⚠️⚠️⚠️注意unittest框架默认的执行顺序遵循字符串的升序(ASCII码)规则,即首先按类名排序,然后在每个测试类内部再按方法名排序。在本例中,test_double因字母d排在f之前,会先于test_double_fail_demo执行,输出结果开头的.F也正是这一顺序的体现。理解这一点有助于正确解读测试报告中的执行顺序信息。

理解输出格式后,可以进一步提炼单元测试的核心逻辑,可概括为五个步骤:首先导入待测试的目标类,其次实例化目标类,然后调用被测方法获取实际结果,接着通过断言将实际结果与预期值进行比对,最后根据比对结果判定用例状态。若数据一致则测试通过,若不一致则测试失败,并抛出包含自定义提示信息的异常。更多unittest用法可参考其官方文档:unittest官方文档

1.4.3 使用pytest运行测试

pytest不仅能兼容并执行unittest编写的测试用例,还支持以test_开头的函数或类两种方式组织测试,实现更灵活的管理。pytest具备以下突出特性:

  • 原生断言支持:直接使用Python内置的assert语句,无需借助self.assert*等专用断言方法,失败时自动展示丰富的上下文信息。
  • 灵活的筛选机制:支持按名称、标记或关键字表达式等方式灵活筛选要运行的测试用例。
  • 失败重跑能力:支持从上次失败的测试用例处重新运行,避免重复执行已通过的用例,显著提升调试效率。
  • 丰富的插件生态:可大幅扩展其功能,例如生成测试报告、并行执行、代码覆盖率分析等。

pytest风格相较于unittest更加简洁,使用pytest后不再需要继承TestCase类、也无需强制定义类结构,也不必编写命令行入口。但pytest是第三方库,需要提前安装:

bash 复制代码
pip install pytest

将前面unittest风格的测试用例改用pytest原生风格重写后,同样的测试逻辑可以表达得更加精简:

python 复制代码
# test_math_tools.py
from math_tools import MathTools

# 无需继承任何类,直接定义以 test_ 开头的函数即可
def test_double():
    """校验double方法计算逻辑"""
    # 实例化待测试类
    tool = MathTools()
    # 调用目标方法,接收实际结果
    result = tool.double(3)
    # 直接使用Python内置assert语句进行断言
    # 将预期结果与自定义错误信息写在断言表达式中
    assert result == 6, "double(3)运算结果不符合预期"

def test_double_fail_demo():
    """模拟测试失败,展示框架报错效果"""
    tool = MathTools()
    result = tool.double(4)
    # 配置错误的预期值,故意触发断言失败
    assert result == 10, "double(4)预期结果配置错误,用于失败演示"

# 添加执行入口,使该文件既可通过pytest命令运行,也可直接作为Python脚本执行
if __name__ == "__main__":
    import pytest
    pytest.main([__file__, "-v"])

若代码中未包含上述执行入口,可在终端中执行以下命令来运行测试:

bash 复制代码
pytest test_math_tools.py -v

若已添加执行入口,则可以直接作为普通Python脚本运行:

bash 复制代码
python test_math_tools.py

两种方式均会执行文件中的所有测试用例,且输出结果一致。前者为 pytest 的标准命令行用法,后者则更适合在 IDE 中通过点击运行按钮进行调试。更详细的使用说明可参考 pytest 官方文档:pytest官方文档

综合对比unittestpytest两种编写风格,可以清晰地看到pytest在代码简洁性和可读性方面的显著优势:

对比项 unittest pytest
框架依赖 需导入unittest模块 通常无需显式导入,直接编写测试函数即可
代码结构 必须继承unittest.TestCase 支持普通函数,也可使用类组织测试(无需继承特定基类)
断言方式 使用self.assertEqual()self.assertTrue()等专用断言方法 直接使用Python原生assert语句,失败信息更直观
入口代码 需包含if__name__=="__main__": unittest.main()才能直接运行脚本 通常无需入口,通过pytest命令运行;若需直接运行脚本,调用pytest.main()即可
测试发现 默认收集test*.py文件中以test开头的方法(类需继承TestCase 自动收集test_*.py*_test.py文件中的test_前缀函数,以及Test类中的test_方法
插件与扩展 需借助第三方工具集成 拥有丰富的插件生态,可轻松集成覆盖率、并行测试、报告生成等功能

1.5 测试体系与流程

测试步骤

学习一门新技术,最怕只见树木,不见森林。前面逐一认识了测试的基本概念,区分了单元测试与集成测试的职责,也动手体验了Python中的断言语句和测试运行器。但在实际工程中,仍然可能继续追问:测试用例为什么重要?测试的主要工作到底落在哪里?工具能不能自动生成测试用例?又该怎样评价一个测试写得好不好?

无论使用何种测试框架,也无论被测功能多么复杂,一个结构良好的测试都可以归结为三个核心步骤(Arrange-Act-Assert)。一个测试用例,正是这三步的显式封装,可以理解为一个可重复执行的小实验:

  1. 给定输入:向程序提供需要验证的条件,即精心设计的输入数据或前置状态。
  2. 执行并捕获:运行被测逻辑,获取真实输出,观察程序的实际行为。
  3. 验证:将真实输出与预期输出进行比对,凭借断言判定结果是否正确。

由此可以看出,测试的主要工作其实集中在第一步和第三步:设计具有代表性的输入,以及明确什么是正确的预期输出。这正是测试用例的核心价值,它将原本较为模糊的验证过程,固化为可复用、可追踪的契约,从而成为代码质量的一道安全保障。现代IDE和各类工具虽能根据函数签名自动生成测试用例的基础结构,但无法替开发者判断结果是否符合业务预期。正因为这一点,测试用例的价值不仅在于能不能跑,更在于是否定义了正确的行为边界。

这一模式可以类比为实验:将一杯水放入冰箱,随后检查水是否结冰。输入对应水的初始状态,执行对应冷冻过程,验证对应结冰与否的检查。一个好测试,就像这个实验一样,条件清晰、判定明确、每次运行都能稳定复现。如果不仅测了常温水的结冰,还覆盖了0℃附近结冰的边界条件,那这个测试用例就更接近优秀。

测试类型

明确了测试方法后,关于测试内容的思考便会自然衔接,这涉及测试的首个分类维度,即检验范围。在此维度下,主流测试分为三个层次,它们共同构成了由小而快到大而慢的测试金字塔。

  • 单元测试
    专注验证单个函数或方法。其范围最小、执行最快,通常在毫秒级完成,且在总量中占比最高。
  • 集成测试
    侧重多个模块间的协作。其核心在于检验接口与交互,而非关注具体的内部实现细节。
  • 端到端测试
    采取用户视角模拟真实操作流程,旨在覆盖完整的系统路径。

测试层次越低,执行速度越快、稳定性越高,因此应覆盖更多用例;层次越高,执行成本更高、也更容易受环境影响,因此数量应相对减少。日常开发中,应将主要精力投入到单元测试的构建中,同时配合适量集成测试,并辅以少量端到端测试,以形成稳定且高效的测试结构。更系统的说明可参考 The Practical Test Pyramid

在理解了单元测试和集成测试各自的侧重点之后,一个自然的问题便是:两种测试应该各写多少?测试金字塔为这个问题提供了直观的指导。

复制代码
        /\
       /  \       ← 少量端到端测试(E2E)
      /    \
     /------\
    /        \    ← 适量集成测试
   /          \
  /------------\
 /              \ ← 大量单元测试
/________________\

金字塔出自Mike Cohn的《Succeeding with Agile》,其核心思想是:

  • 底层(单元测试):数量最多,执行最快,反馈最及时。每个测试只覆盖一个函数或方法,几乎不依赖外部系统。
  • 中层(集成测试):数量适中,负责验证模块之间的协作,可能涉及数据库、网络请求或文件读写。
  • 顶层(端到端测试):数量较少但覆盖面广,通过模拟真实用户操作来验证整个系统流程。

遵循金字塔意味着,当你发现一个bug时,优先尝试补充一个单元测试;只有当bug确实由模块交互引发时,才添加集成测试。这种分配能让你在快速反馈和全面覆盖之间取得平衡。

除了按范围划分,测试还可以根据执行目的与时机进行分类,这些术语与测试金字塔并不冲突,而是回答在何时以及出于何种目的选择哪些测试执行:

  • 冒烟测试
    该术语源于硬件领域,意指设备通电若冒烟则存在严重问题。在软件语境下,它是一组覆盖核心流程的精简用例,用于快速判断当前版本是否具备继续测试的价值。
  • 回归测试
    用于验证缺陷修复或功能变更后既有功能是否依然正常。其本质是重复执行已有用例,涵盖各个层次的测试,而非一种独立的测试技术。
  • 验收测试
    关注系统是否达成业务需求与交付标准。其核心在于评估需求满足程度,在实践中往往通过端到端测试的形式体现。
  • 健全性测试
    属于冒烟测试的聚焦版本。它仅针对特定的修复或小改动进行快速检查,以此判断版本状态是否足以进入详细测试阶段。
  • 探索性测试
    强调测试设计与执行同步开展。这种方式不依赖预设用例,而是依靠测试人员的经验与创造力去发现脚本式测试难以覆盖的缺陷。

上述分类主要用于说明不同执行策略,更完整的类型说明可参考:Software testing types explained simply

这两组分类并不冲突,而是从两个正交维度刻画测试体系:测试金字塔回答测试应该如何分布,而冒烟、回归等概念回答在什么阶段执行什么测试。两者结合,才能构建既高效又全面的测试策略。

测试目的

除了范围与时机,测试还可以从"目的"维度进行划分,从而指导用例设计方式:

  • 正面测试

    验证正常输入是否产生预期结果。

  • 负面测试

    验证异常输入是否被正确处理。例如输入字符串时应抛出 TypeError,而不是导致程序崩溃。

在实践中,仅关注正面测试是一种常见误区,因为真实系统中异常输入往往不可避免。

2 测试进阶与实践

2.1 测试用例

2.1.1 用例函数

前面内容介绍了测试的基本概念,接下来将所有知识点串联起来。与之前测试内置函数不同,本节将自行实现一个计算乘积的函数,并为其编写完整的测试用例。

首先,创建一个新的项目文件夹,并在其中新建一个名为 my_math 的子文件夹。在my_math内创建一个空的__init__.py文件,该文件的存在标志着my_math是一个Python包,可以被父级目录中的其他模块导入。此时的项目结构如下所示:

复制代码
project/
│
└── my_math/
    └── __init__.py

打开my_math/__init__.py,定义一个product()函数。该函数接收一个可迭代对象(如列表、元组或集合),遍历其中的所有数值并计算它们的乘积:

python 复制代码
def product(arg):
    total = 1
    for val in arg:
        total *= val
    return total

有了待测代码之后,需要确定测试文件的存放位置。最简单的做法是在项目根目录下创建一个test.py文件,其中包含第一个测试用例。由于该文件需要导入被测试的包,因此通常放置在包文件夹的上一层,目录结构如下:

复制代码
project/
│
├── my_math/
│   └── __init__.py
└── test.py

当然,随着测试用例逐渐增多,单个文件会变得杂乱且难以维护。此时可以创建一个tests/文件夹,将测试拆分到多个文件中。按照业界惯例,每个测试文件的名称都以test_开头,以便测试运行器能够自动识别哪些文件包含待执行的测试。一些大型项目还会按功能模块,将测试用例进一步拆分到不同子目录中。

对于单脚本场景,也就是项目只有一个.py文件而没有包结构的情况,导入被测代码的方式略有不同。此时可以使用内置的__import__()函数导入脚本中的类、函数或变量。例如,假设待测文件名为my_math.py,其中定义了product()函数,则导入方式如下:

python 复制代码
target = __import__("my_math")
product = target.product

使用__import__()的优势在于:无需将项目文件夹转换为包,且可以直接通过模块名(不含.py后缀)导入。当脚本文件名与标准库模块重名时(例如自定义的math.py与Python标准库的math模块冲突),这种方式可避免误导入标准库模块而非自定义模块的错误。

2.1.2 测试代码编写

测试用例是验证代码行为、覆盖边界异常、防止回归的核心保障。针对product()函数,需验证以下行为:

  • 能否正确计算整数列表的乘积?
  • 能否处理元组或集合等不同类型的可迭代对象?
  • 能否正确处理浮点数列表?
  • 传入空列表时应返回什么?传入只包含单个元素的列表呢?
  • 遇到负数时能否返回正确的乘积结果?
  • 传入不合法的值(例如包含字符串的列表或非可迭代对象)时会发生什么?(应抛出异常)

最基础的测试用例是验证对整数列表求乘积。创建test.py文件,写入以下代码:

python 复制代码
import unittest
from my_math import product

class TestProduct(unittest.TestCase):
    def test_list_int(self):
        """测试整数列表的乘积"""
        data = [2, 3, 4]
        result = product(data)
        self.assertEqual(result, 24)   # 2*3*4 = 24
        
    def test_single_element(self):
        """测试只有一个元素的列表"""
        data = [7]
        result = product(data)
        self.assertEqual(result, 7)    # 单个元素的乘积即元素本身

    def test_empty_list(self):
        """测试空列表的乘积------应按数学约定返回1"""
        data = []
        result = product(data)
        self.assertEqual(result, 1)    # 空乘积定义为1

    def test_list_float(self):
        """测试浮点数列表的乘积"""
        data = [1.5, 2.0, 3.0]
        result = product(data)
        self.assertAlmostEqual(result, 9.0)   # 使用assertAlmostEqual处理浮点数精度

    def test_tuple(self):
        """测试元组的乘积"""
        data = (2, 4, 5)
        result = product(data)
        self.assertEqual(result, 40)   # 2*4*5 = 40

    def test_set(self):
        """测试集合的乘积(注意:集合会自动去重)"""
        data = {2, 3, 2}   # 集合会去重,实际元素为{2, 3}
        result = product(data)
        self.assertEqual(result, 6)    # 2*3 = 6

    def test_negative_numbers(self):
        """测试包含负数的列表"""
        data = [-2, 3, -4]
        result = product(data)
        self.assertEqual(result, 24)   # (-2)*3*(-4)=24

if __name__ == "__main__":
    unittest.main()

以上代码依次完成了模块导入、测试用例类定义、测试方法的编写,以及命令行入口的设置。

测试的核心步骤是通过断言将实际输出与预期的正确结果进行比对。编写断言时应遵循以下最佳实践:

  • 确保测试可重复执行,且每次运行均得到相同结果(避免依赖随机值或外部状态);
  • 断言结果应与输入数据密切相关,例如验证乘积结果是否确实是输入值的累乘值;
  • 每个测试方法应聚焦验证一个核心行为,这样当测试失败时能迅速定位问题。

unittest模块提供了丰富的断言方法,用于比较值、检查类型以及验证变量是否存在。以下是测试中最常用的断言方法:

方法 等价于
.assertEqual(a, b) a == b
.assertTrue(x) bool(x) is True
.assertFalse(x) bool(x) is False
.assertIs(a, b) a is b
.assertIsNone(x) x is None
.assertIn(a, b) a in b
.assertIsInstance(a, b) isinstance(a, b)
.assertAlmostEqual(a, b) round(a-b, 7) == 0(适用于浮点数比较)

其中以下断言方法均有对应的否定形式:

  • .assertIs().assertIsNot()
  • .assertIsNone().assertIsNotNone()
  • .assertIn().assertNotIn()
  • .assertIsInstance().assertNotIsInstance()

测试时还需留意副作用,即代码除了返回值之外,对类属性、文件系统、数据库或全局状态等外部环境所造成的改变。在编写断言前,应先明确是否需要验证这些副作用。如果一段代码的副作用过多,通常意味着职责耦合,违反了单一职责原则,此时更适合进行重构。遵循该原则能让单元测试更简单、更可重复,从而构建出更可靠的应用。

针对应用中副作用较多的部分,可以从以下三个方向进行处理:首先,通过重构代码,使其遵循单一职责原则,将副作用拆分并隔离到独立的函数或类中;其次,采用集成测试,在真实或接近真实的环境中验证相关行为;最后,引入模拟技术(Mock),通过模拟方法或函数调用来消除实际副作用的影响,具体可参考:Mock与单元测试的深入应用

2.1.3 测试执行

完成测试代码的编写后,下一步是执行测试。在运行更复杂的测试套件之前,应首先确认基本的测试能够成功执行。

执行测试代码、运行断言并将结果输出到控制台的Python程序称为测试运行器,例如test.py文件底部的代码段:

python 复制代码
if __name__ == "__main__":
    unittest.main()

构成了一个命令行入口。这意味着直接在终端中执行python test.py时,程序会调用unittest.main(),该方法会自动发现文件中所有继承自unittest.TestCase的类,并执行其中的测试方法。

对于单一的测试文件test.py,运行python test.py是最便捷的入门方式。此外也可以直接使用unittest模块的命令行接口:

bash 复制代码
$ python -m unittest test

该命令通过命令行执行指定的测试模块(即test,无需加.py后缀)。还可以添加-v选项来开启详细模式(verbose mode),该模式会先列出所有被执行测试的名称以及每个测试的结果(如OKFAIL)。

除了显式指定测试模块,还可以使用测试自动发现功能:

bash 复制代码
$ python -m unittest discover

该命令会搜索当前目录及其子目录下所有符合test*.py命名模式的文件,并尝试运行其中的测试。当项目中的测试文件数量增多且均遵循此命名规范时,这种方式尤为高效。可以使用-s标志直接指定测试目录:

bash 复制代码
$ python -m unittest discover -s tests

上述命令会扫描tests目录下的所有测试文件,统一收集并执行其中的测试用例。

如果源代码不在项目根目录,而是位于子目录中(例如src/文件夹),可用-t标志指定顶层目录,以确保模块导入路径正确:

bash 复制代码
$ python -m unittest discover -s tests -t src

此命令会先切换至src作为顶层目录,然后扫描tests目录中所有test*.py文件并执行。这种配置在大型项目中非常常见,有助于保持源代码与测试代码的清晰分离。

2.2 单元测试进阶

2.2.1 测试夹具

当测试对象不再只是一个简单的数字,而是一个复杂的环境或多个模块的协作时,就需要引入两个核心工具:

  • 测试夹具(test fixture)是测试运行前预先准备好的环境或数据,用以确保测试的可重复性。例如测试用户登录功能时,重复新建测试用户十分繁琐。可提前预置模拟账号、临时测试数据库,后续每次测试直接调用即可。
  • 参数化是用同一套测试逻辑,换不同的输入值来验证。比如写了一个加法函数,想测试它是否正确。可以同时测试多组输入,比如1加1等于2、2加3等于5,而不用把同样的测试代码复制粘贴好几遍。

举例说明:测试一个加法函数。用夹具可以提前设定好固定的数字3和5作为测试材料;用参数化可以一次性跑多组不同的运算。在实际测试工作中,百分之九十的情况都会用到夹具或参数化。它们不是可有可无的高阶技巧,而是让测试代码更简洁、更专业的核心工具。

回顾之前编写的测试用例,每个测试方法内部都重复编写了实例化代码。例如 TestProduct 类中,每个方法都写了 data = [...]result = product(data)。当测试用例数量增多时,这类重复代码会显著增加维护成本。此时,测试夹具便能派上用场。

unittest 中,夹具主要通过以下三个方法实现:

方法 调用时机 适用场景
setUp() 每个测试方法执行前调用一次 为每个测试准备干净、独立的初始状态
tearDown() 每个测试方法执行后调用一次 清理测试产生的副作用,如关闭文件、断开数据库连接
setUpClass() / tearDownClass() 在当前测试类的所有方法执行前后各调用一次 准备开销较大的资源,如建立数据库连接池、启动测试服务器

以下示例将前文的TestProduct测试类使用setUp进行重构,避免在每个测试方法中重复实例化:

python 复制代码
import unittest
from my_math import product

class TestProduct(unittest.TestCase):

    def setUp(self):
        """在每个测试方法运行前执行,准备基础测试数据"""
        # 创建一份通用的测试数据,供多个测试方法复用
        self.base_data = [2, 3, 4]
        self.base_expected = 24

    def test_list_int(self):
        """测试整数列表的乘积------直接使用setUp准备好的数据"""
        result = product(self.base_data)
        self.assertEqual(result, self.base_expected)

    def test_single_element(self):
        """测试只有单个元素的列表"""
        result = product([7])
        self.assertEqual(result, 7)

    def tearDown(self):
        """在每个测试方法运行后执行,清理测试痕迹(本例中可省略)"""
        pass

if __name__ == "__main__":
    unittest.main()

要点说明:

  • setUp每个测试方法执行前 都会运行一次。这意味着每个测试方法拿到的 self.base_data都是全新初始化的,一个测试对属性的修改不会污染另一个测试。
  • tearDown每个测试方法执行后运行。对于涉及文件读写、数据库操作的测试,可在此处统一执行清理逻辑,确保测试之间完全隔离。
  • 如果初始化的成本较高(例如从文件加载大量数据),可以使用setUpClasstearDownClass类方法,它们在整个测试类中仅运行一次。

使用夹具后,测试方法的职责更加单一:只需关注具体的测试逻辑,前置准备和事后清理均由框架统一管理。

2.2.2 参数化测试

观察TestProduct类中的多个测试方法,它们本质上都是在验证product()函数能否对不同输入返回正确的乘积值。如果每次新增一种输入类型都要编写一个完整的新方法,代码会迅速膨胀。更理想的做法是:只编写一份测试逻辑,让它自动遍历多组输入数据。

虽然unittest本身不提供专用的参数化装饰器,但其内置的subTest上下文管理器可以优雅地实现同一目的。以下示例展示了如何将多个独立的测试方法合并为一个参数化测试:

python 复制代码
import unittest
from my_math import product

class TestProduct(unittest.TestCase):

    def test_product_various_inputs(self):
        """使用subTest一次性覆盖多种输入场景"""
        # 定义测试用例列表:每个元素是一个元组(输入数据, 预期结果, 用例描述)
        test_cases = [
            ([2, 3, 4], 24, "整数列表求积"),
            ([7], 7, "单元素列表"),
            ([], 1, "空列表"),
            ([-2, 3, -4], 24, "包含负数的列表"),
            ((2, 4, 5), 40, "元组求积"),
            ({2, 3, 2}, 6, "集合求积(注意去重)"),
        ]

        for data, expected, description in test_cases:
            # subTest会在循环中区分每一次迭代
            # 即使某个子用例失败,其他子用例仍会继续执行
            with self.subTest(description=description, data=data):
                result = product(data)
                self.assertEqual(result, expected)

    def test_product_float(self):
        """浮点数乘积单独测试------使用assertAlmostEqual处理精度问题"""
        result = product([1.5, 2.0, 3.0])
        self.assertAlmostEqual(result, 9.0)

if __name__ == "__main__":
    unittest.main()

subTest 的关键优势在于:

  • 循环中某一组数据导致断言失败时,框架会将该子用例标记为失败,但不会中断循环,其余子用例仍会继续执行并独立报告结果。
  • 测试报告会明确指出失败的子用例及其对应的数据,便于快速定位问题。

2.3 负面测试

在常规测试中,我们通常验证函数是否返回正确结果。然而,在某些场景下,期望的行为并非正常返回,而是函数主动抛出异常。例如,当传入非法参数时,函数应当抛出TypeErrorValueError来提示调用者。

unittest框架默认的行为是:只要测试执行过程中抛出任何未被捕获的异常,都将被视为测试失败。这意味着:测试期望抛出异常 → 实际也抛出了异常 → 测试却显示失败。这显然不符合测试异常场景的需求。

为解决此问题,unittest提供了assertRaises方法,推荐以上下文管理器的形式使用。它能够捕获代码块中抛出的指定异常,并将其视为测试通过的条件。

以下示例展示了assertRaises的正确与错误用法:

python 复制代码
import unittest
from my_math import product

class TestProduct(unittest.TestCase):
    """测试 my_math.product 函数的单元测试用例"""

    def test_1_product_of_integers(self):
        """正常情况:计算整数列表的乘积"""
        data = [1, 2, 3, 4]
        result = product(data)
        self.assertEqual(result, 24)   # 测试通过 ✅

    def test_2_product_with_single_element(self):
        """错误示范:预期单元素列表应返回该元素本身,此处却误用 assertRaises"""
        # product([42]) 实际上会正常返回 42,不会抛出任何异常,
        # 但 assertRaises 期望抛出 TypeError,因此该测试失败。
        # 运行结果:失败 ❌ (AssertionError: TypeError not raised)
        with self.assertRaises(TypeError):
            product([42])

    def test_3_non_iterable_input(self):
        """正确用法:传入非可迭代对象(整数)应抛出 TypeError"""
        # product(42) 会因为传入整数(不可迭代)而抛出 TypeError,
        # assertRaises 成功捕获该异常,因此测试通过。
        # 运行结果:通过 ✅
        with self.assertRaises(TypeError):
            product(42)

    def test_4_catch_any_exception(self):
        """不推荐:捕获过于宽泛的 Exception 基类"""
        # 使用 Exception 可以捕获几乎任何异常类型,
        # 但这种写法会掩盖具体的异常类型,降低测试的精确性。
        # 本例中 product([42]) 未抛出异常,因此测试失败。
        # 运行结果:失败 ❌ (AssertionError: Exception not raised)
        with self.assertRaises(Exception):
            product([42])

if __name__ == "__main__":
    unittest.main()

with self.assertRaises(TypeError): 的语义是:执行该代码块必须 抛出TypeError类型的异常,否则测试判定为失败。只有当预期的异常被抛出且类型匹配时,测试才会通过。

assertRaises的第一个参数可以灵活指定:

  • 单个异常类型,如ValueErrorKeyError或自定义异常类;
  • 异常元组,如(TypeError, ValueError),表示捕获其中任意一种异常即算通过;
  • 使用Exception可捕获几乎全部异常,但因其过于宽泛,会掩盖代码中潜在的异常类型错误,一般仅在调试或临时验证时使用,正式测试中强烈推荐指定明确的异常类型

2.4 集成测试

2.4.1 集成测试理解

到目前为止,主要学习的是单元测试。单元测试专注于验证一个独立的小功能,比如一个加法函数。单元测试能帮助构建出可预测、稳定的代码。

但程序在真正启动运行时也需要不出差错。只测试单个函数是不够的,因为应用程序是由许多模块共同协作的,一个函数能正常工作,不代表它和其他模块合在一起也能正常工作。

集成测试是对应用程序的多个组件进行测试,以验证它们能否协同工作。集成测试通常需要模拟真实用户或外部系统的行为,例如调用消息队列、读写文件系统、调用数据库存储过程、发送电子邮件或短信。

这些集成测试可以采用与单元测试相同的方式编写,即遵循输入、执行、断言的模式。集成测试与单元测试最大的区别在于:

  • 检查范围更广:一次性检查多个组件
  • 副作用更多:因为会真实操作数据库或文件,所以在系统中留下痕迹
  • 需要更复杂的夹具:常常需要准备数据库、网络连接或配置文件等重型夹具

下面通过一个具体案例,直观感受两者的区别。

被测试的代码如下:

python 复制代码
# calculator.py
class Calculator:
    """计算器 - 包含计算与文件存储功能"""
    def add(self, a, b):
        return a + b

    def save_result(self, result):
        """保存计算结果到本地文件"""
        with open('result.txt', 'w', encoding='utf-8') as f:
            f.write(str(result))

    def calculate_and_save(self, a, b):
        """完整业务流程:计算结果并自动保存"""
        result = self.add(a, b)
        self.save_result(result)
        return result

单元测试只关注核心计算逻辑,不依赖真实文件系统。在下面的例子中,每个测试方法都会获得一个新的Calculator实例,其save_result方法被替换为模拟对象,不会产生任何文件副作用。

python 复制代码
# tests/unit/test_calculator_unit.py
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent.parent))

import unittest
from unittest.mock import Mock
from calculator import Calculator

class TestCalculatorUnit(unittest.TestCase):
    """单元测试:使用Mock隔离文件操作,只校验纯逻辑"""
    def setUp(self):
        self.calc = Calculator()
        # 模拟替换保存方法,避免真实生成文件
        self.calc.save_result = Mock()

    def test_add(self):
        """单独测试加法逻辑"""
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)

    def test_calculate_and_save(self):
        """测试整体流程调用逻辑,不操作真实文件"""
        result = self.calc.calculate_and_save(2, 3)
        self.assertEqual(result, 5)
        self.calc.save_result.assert_called_once_with(5)

if __name__ == '__main__':
    unittest.main()

集成测试则使用真实文件系统,测试文件操作并验证组件间的协作,确保完整的计算与保存流程无误:

python 复制代码
# tests/integration/test_calculator_integration.py
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent.parent))

import unittest
import os
from calculator import Calculator

class TestCalculatorIntegration(unittest.TestCase):
    """集成测试:使用真实文件系统,校验完整业务流程"""
    def setUp(self):
        self.file_name = "result.txt"
        # 前置清理残留文件
        if os.path.exists(self.file_name):
            os.remove(self.file_name)
        self.calc = Calculator()

    def tearDown(self):
        # 测试结束后自动清理,避免环境污染
        if os.path.exists(self.file_name):
            os.remove(self.file_name)

    def test_full_workflow(self):
        """完整流程:计算 + 保存文件 全链路校验"""
        result = self.calc.calculate_and_save(4, 5)
        self.assertEqual(result, 9)
        self.assertTrue(os.path.exists(self.file_name))

        with open(self.file_name, 'r', encoding='utf-8') as f:
            content = f.read()
        self.assertEqual(content, "9")

    def test_multiple_calculations(self):
        """测试多次计算,文件内容自动覆盖"""
        self.calc.calculate_and_save(1, 2)
        self.calc.calculate_and_save(10, 20)

        with open(self.file_name, 'r', encoding='utf-8') as f:
            content = f.read()
        self.assertEqual(content, "30")

if __name__ == '__main__':
    unittest.main()

推荐的文件结构如下:

复制代码
project/
│
├── calculator.py          # 要测试的代码
│
└── tests/
    ├── unit/              # 单元测试(快,用 Mock)
    │   └── test_calculator_unit.py
    │
    └── integration/       # 集成测试(慢,真实操作)
        └── test_calculator_integration.py

运行方式如下:

bash 复制代码
# 每次改完代码快速跑单元测试
python -m unittest discover -s tests/unit

# 提交代码前跑集成测试
python -m unittest discover -s tests/integration

2.4.2 外部数据驱动测试

在2.4.1的集成测试中,测试数据是直接硬编码在测试代码里的。但在实际项目中,很多业务逻辑依赖于预置的测试数据,比如数据库中必须存在特定用户、订单或配置,测试才能运行。

这类集成测试的核心特点是:测试逻辑由外部数据文件驱动,数据一变,测试结果随之改变。为此,推荐在集成测试目录下创建一个fixtures文件夹,用于存放测试数据文件,然后在测试代码中加载这些数据。

下面是一个优惠券管理系统的示例,展示如何用JSON文件驱动集成测试。

核心代码(coupon.py)如下:

python 复制代码
import json

class CouponManager:
    """从 JSON 文件加载优惠券并提供基础操作"""
    
    def __init__(self, data_path):
        """
        初始化时直接加载 JSON 文件中的优惠券数据
        :param data_path: fixtures 目录下的 JSON 文件路径
        """
        with open(data_path, encoding='utf-8') as f:
            data = json.load(f)
        # 假设 JSON 格式为 {"coupons": [{"code":..., "discount_percent":..., ...}]}
        self.coupons = data['coupons']   # 每一项都是 dict,方便直接使用

    def active_count(self):
        """返回 is_active 为 True 的优惠券数量"""
        # 使用生成器表达式统计
        return sum(1 for c in self.coupons if c['is_active'])

    def apply(self, code, amount=0):
        """
        应用优惠码,计算折扣
        :param code: 优惠券码
        :param amount: 订单金额
        :return: dict 包含 success 状态、msg 或 discount 金额
        """
        # 遍历所有优惠券(数据量小,直接线性查找)
        for c in self.coupons:
            if c['code'] == code:
                # 检查是否失效
                if not c['is_active']:
                    return {'success': False, 'msg': '已失效'}
                # 检查是否达到最低消费门槛(JSON中可缺省min_order,默认0)
                min_order = c.get('min_order', 0)
                if amount < min_order:
                    return {'success': False, 'msg': f"需满{min_order}元"}
                # 计算折扣金额,四舍五入保留两位小数
                discount = amount * c['discount_percent'] / 100
                return {'success': True, 'discount': round(discount, 2)}
        # 未找到对应优惠码
        return {'success': False, 'msg': '优惠码不存在'}

测试夹具(fixtures/coupons.json)如下:

json 复制代码
{
  "coupons": [
    {
      "code": "SAVE10",
      "discount_percent": 10,
      "message": "10% off applied",
      "is_active": true,
      "min_order_amount": 0,
      "category": "general"
    },
    {
      "code": "SAVE20",
      "discount_percent": 20,
      "message": "20% off applied",
      "is_active": true,
      "min_order_amount": 200,
      "category": "general"
    },
    {
      "code": "EXPIRED50",
      "discount_percent": 50,
      "message": "50% off applied",
      "is_active": false,
      "min_order_amount": 0,
      "category": "general"
    },
    {
      "code": "VIP100",
      "discount_percent": 30,
      "message": "VIP 会员专享 7 折优惠",
      "is_active": true,
      "min_order_amount": 500,
      "category": "vip"
    }
  ]
}

集成测试(test_coupon.py)代码如下:

python 复制代码
import unittest
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))  # 将 project/ 加入路径
from coupon import CouponManager

class TestCoupon(unittest.TestCase):
    """集成测试:使用真实的 JSON 夹具验证优惠券逻辑"""

    def setUp(self):
        """每个测试方法执行前都会运行:构造 CouponManager 并加载夹具"""
        # 构造 fixtures 目录下 coupons.json 的绝对路径
        # __file__ 是当前文件(test_coupon.py)的路径
        fixture_dir = os.path.join(os.path.dirname(__file__), 'fixtures')
        fixture_path = os.path.join(fixture_dir, 'coupons.json')
        self.mgr = CouponManager(fixture_path)

    def test_active_count(self):
        """验证活跃优惠券数量统计正确"""
        # 根据 fixtures/coupons.json 中的数据,有两个活跃优惠券
        self.assertEqual(self.mgr.active_count(), 2)

    def test_apply_valid(self):
        """验证有效优惠券能正确计算折扣"""
        res = self.mgr.apply('SAVE10', 100)
        self.assertTrue(res['success'])
        # 10% 折扣,100 元 -> 10.0
        self.assertEqual(res['discount'], 10.0)

    def test_apply_below_min(self):
        """验证未达到最低消费门槛时返回失败"""
        res = self.mgr.apply('SAVE20', 100)   # SAVE20 需要满 200 元
        self.assertFalse(res['success'])
        self.assertIn('200元', res['msg'])

    def test_apply_expired(self):
        """验证已失效优惠券返回失败"""
        res = self.mgr.apply('EXPIRED', 100)
        self.assertFalse(res['success'])
        self.assertEqual(res['msg'], '已失效')

if __name__ == '__main__':
    # 运行所有测试
    unittest.main()

目录结构如下:

复制代码
project/
├── coupon.py
└── tests/
    └── integration/
        ├── fixtures/
        │   └── coupons.json
        └── test_coupon.py

在项目根目录下执行代码:

bash 复制代码
python tests/integration/test_coupon.py

2.5 多环境兼容测试

Tox工具的使用

考虑一个常见场景,开发者在本地环境的Python 3.11上编写了一个小工具,运行一切正常。然而,同事仍在使用Python 3.9,一运行就报错。另一个潜在问题是,所依赖的某个第三方包升级后,新版本与原有代码不兼容。这些情况都会导致所谓在我电脑上明明可以运行的尴尬局面。

如何在发布代码之前就主动发现这类兼容性问题呢?Tox就是解决这个问题的利器。它能够自动在多个Python版本下运行你的测试套件,只需一条命令即可完成跨版本兼容性检查。安装命令如下:

bash 复制代码
$ pip install tox

假设我们有一个工具文件 str_utils.py,里面实现了一些字符串处理和日期计算的函数。再编写一个对应的测试文件,来验证这些函数的正确性。项目结构如下:

复制代码
str_utils/
├── str_utils.py          ← 你的业务代码
├── test_str_utils.py     ← 你的测试代码
└── tox.ini               ← Tox 的配置文件

业务代码str_utils.py中定义了三个函数:

python 复制代码
from datetime import datetime

def to_title_case(text):
    """将字符串转换为首字母大写的标题格式"""
    return text.title()

def remove_punctuation(text):
    """移除字符串中的所有标点符号"""
    import string
    return text.translate(str.maketrans("", "", string.punctuation))

def days_until(target_date_str):
    """计算当前日期距离目标日期还有多少天(负数表示已过去)"""
    target_date = datetime.strptime(target_date_str, "%Y-%m-%d")
    today = datetime.now()
    delta = target_date - today
    return delta.days

测试代码 test_str_utils.py 用于验证上述函数的正确性:

python 复制代码
import unittest
from unittest.mock import patch
from datetime import datetime
from str_utils import to_title_case, remove_punctuation, days_until

class TestStrUtils(unittest.TestCase):

    def test_1_to_title_case(self):
        self.assertEqual(to_title_case("hello world"), "Hello World")

    def test_2_remove_punctuation(self):
        self.assertEqual(remove_punctuation("Hello, World!"), "Hello World")
        self.assertEqual(remove_punctuation("a.b,c?d!e;f:"), "abcdef")

    @patch("str_utils.datetime")  # 模拟 str_utils 模块中的 datetime
    def test_3_days_until(self, mock_datetime):
        # 创建一个固定的日期对象作为模拟的"当前时间"
        fixed_date = datetime(2026, 1, 1)
        # 设置模拟对象的 now 方法返回该固定日期
        mock_datetime.now.return_value = fixed_date
        # 保留 strptime 的真实功能,用于正常解析输入的日期字符串
        mock_datetime.strptime = lambda x, y: datetime.strptime(x, y)
        # 调用被测试函数:计算从 2026-01-01 到 2026-01-10 还有多少天
        result = days_until("2026-01-10")
        self.assertEqual(result, 9)

if __name__ == "__main__":
    unittest.main()

在配置Tox之前,建议先手动运行一次测试,确认代码在当前环境下一切正常:

bash 复制代码
$ python -m unittest discover

接下来,若要支持多Python环境测试,需要配置Tox。在项目根目录下新建一个名为tox.ini的配置文件,并写入以下内容:

ini 复制代码
[tox]
# 定义要运行的 Python 版本环境列表
envlist = py38, py39, py310, py311

# 跳过打包步骤(适用于没有 setup.py 的项目)
# 当项目无需安装即可直接测试时使用
skipsdist = True

[testenv]
# 在每个测试环境中执行的命令
commands =
    # 使用 unittest 框架自动发现并运行所有测试用例
    python -m unittest discover

配置完成后,在终端中执行:

bash 复制代码
$ tox

正常情况下,会看到类似下面的输出:

复制代码
py38: commands[0]> python -m unittest discover
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s
OK

py39: commands[0]> python -m unittest discover
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s
OK

py310: commands[0]> python -m unittest discover
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s
OK

py311: commands[0]> python -m unittest discover
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s
OK
___________________________________ summary ___________________________________
  py38: commands succeeded
  py39: commands succeeded
  py310: commands succeeded
  py311: commands succeeded
  congratulations :)

Tox通常会为每个指定的Python版本创建独立虚拟环境,这些环境默认存放在项目目录下的.tox/文件夹中,并在各环境中分别安装依赖(如有)后运行测试。首次运行时会相对较慢,因为需要创建虚拟环境并安装基础包,后续运行则会明显加快。Tox的具体使用方法可参考官方文档:tox

当所有指定版本的测试都通过后,终端会显示congratulations :)。如果某个环境的测试失败,Tox会立即停止并报告错误详情。造成失败的常见原因之一是缺少外部依赖。例如,代码中包含了import django,那么Django就是该测试所必需的外部依赖。此时需要在tox.ini配置文件中明确告知Tox先安装这些依赖,再运行测试。配置示例如下:

ini 复制代码
[tox]
envlist = py38, py39, py310
skipsdist = True

[testenv]
deps =
    django>=3.2,<4.0
    requests
    pytest
commands =
    python -m unittest discover

此外,Tox还提供了多种命令行参数,以适应不同的开发和调试场景。常用命令及其含义如下:

命令 含义 适用场景
tox 在所有指定环境中运行测试 常规使用,全面检查兼容性
tox -e py310 仅针对Python 3.10环境运行测试 需要快速测试单个版本时
tox -r 删除并重建所有虚拟环境 依赖发生变化或环境出现异常时
tox -q 减少输出信息,仅显示最终结果 只关心测试是否通过
tox -v 增加输出信息,显示详细执行过程 出现错误,需要深入排查问题时

Tox与自动化测试集成

到目前为止,测试主要依赖手动执行命令完成,但在实际开发流程中,更常见的做法是当代码提交到Git等版本控制系统后,由自动化工具自动触发测试流程。这类工具可以在每次提交时自动运行测试,从而减少人工操作并提高反馈效率。

在Python生态中,TravisCI是常用的持续集成服务之一。通过将Tox集成到.travis.yml配置文件中,可以在每次代码提交时自动执行多版本测试,从而提前发现不同Python版本之间的兼容性问题并提升代码质量。TravisCI的使用方法可参考官方文档:Travis CI

此外,可以结合Python库tox-travis,让TravisCI自动根据当前运行的Python版本选择对应的Tox环境,从而实现本地开发环境与测试环境配置的一致性。

覆盖率测试

写了测试之后,一个关键问题是是否覆盖到了所有代码路径,coverage.py是Python生态中常用的覆盖率测量工具,用于分析测试执行过程中哪些代码被触达、哪些没有被执行。

安装方式如下:

bash 复制代码
pip install coverage

使用coverage run执行测试并收集覆盖率数据:

bash 复制代码
coverage run -m unittest discover

生成覆盖率报告的方式如下:

  • 命令行摘要报告:coverage report,用于快速查看整体覆盖率情况
  • HTML可视化报告:coverage html,生成htmlcov/index.html,可在浏览器中逐行查看代码覆盖情况

报告会标注哪些代码被执行过,哪些从未触及,从而帮助定位未覆盖的代码路径,尤其是异常处理和边界条件等容易遗漏的逻辑。

初次提升覆盖率时不必追求100%,更重要的是优先补齐关键逻辑的缺失部分。随着测试逐步完善,可以持续提高覆盖率,并在自动化流程中设置最低阈值,例如80%,避免后续修改导致覆盖率下降。更多使用方法可参考:coverage

3 参考