1. 项目概述:为什么单元测试是Python开发的“安全带”?

干了这么多年Python开发,我见过太多因为没写测试而“翻车”的案例了。一个看似简单的函数修改,上线后却导致核心业务逻辑瘫痪;一个依赖第三方API的模块,因为对方接口变动而整个服务不可用。这些问题,绝大多数都能被一套完善的单元测试提前拦截。单元测试不是给领导看的“面子工程”,而是开发者写给自己和团队的“后悔药”和“安全带”。它让你在修改代码时心里有底,在重构系统时手不发抖。

所谓单元测试,就是针对程序模块(在Python中通常是一个函数、一个类或者一个方法)进行正确性检验的测试工作。它的目标是隔离程序的最小可测试部分,验证其行为是否符合预期。对于Python开发者而言,掌握单元测试不是“加分项”,而是“必备技能”。无论你是刚入门的新手,还是在处理复杂业务逻辑的老手,一套好的测试习惯都能极大提升代码质量和开发效率。本文将带你从零开始,手把手搭建测试环境,深入理解 unittest pytest 两大框架,并分享我在实战中积累的测试模式、Mock技巧以及CI/CD集成经验,让你真正从“知道要写测试”进阶到“精通怎么写好测试”。

2. 环境准备与测试框架初探

2.1 搭建你的第一个测试环境

很多人觉得配置测试环境很麻烦,其实对于Python来说,非常简单。首先,我强烈建议为每个项目使用独立的虚拟环境。这能避免项目间的包版本冲突,也是专业开发的基本素养。

# 使用venv创建虚拟环境(Python 3.3+ 内置)
python -m venv venv

# 激活虚拟环境
# Windows:
venv\Scripts\activate
# Linux/Mac:
source venv/bin/activate

激活虚拟环境后,你的命令行提示符通常会发生变化,前面会显示 (venv) 。接下来,我们需要安装测试框架。Python标准库自带 unittest ,无需安装。但对于更现代、功能更强大的 pytest ,我们需要手动安装。我个人的选择是 pytest ,因为它写起来更简洁,报告更友好,插件生态也更丰富。

# 安装pytest
pip install pytest

# 可选但推荐:安装pytest-cov用于生成测试覆盖率报告
pip install pytest-cov

安装完成后,创建一个简单的项目结构。我习惯的目录结构是这样的:

my_project/
├── src/               # 源代码目录
│   └── calculator.py
├── tests/             # 测试代码目录
│   └── test_calculator.py
├── requirements.txt   # 项目依赖
└── pytest.ini         # pytest配置文件(可选)

将源代码和测试代码分离,是保持项目整洁的好习惯。 src 目录下放你的业务逻辑, tests 目录下放对应的测试文件。测试文件通常以 test_ 开头,或者以 _test.py 结尾,这样 pytest 才能自动发现它们。

2.2 unittest vs. Pytest:如何选择你的主力框架?

Python世界主要有两大测试框架:标准库的 unittest 和第三方 pytest 。新手可能会困惑该学哪个。我的建议是: 了解 unittest ,但主攻 pytest

unittest 是JUnit风格的框架,采用面向对象的方式,要求你创建继承自 unittest.TestCase 的测试类,并在其中编写以 test 开头的方法。它的优点是无需额外安装,与标准库集成好,适合有Java/JUnit背景的开发者。但它的语法相对繁琐,比如断言要用 self.assertEqual(a, b) 而不是更直观的 assert a == b

pytest 则更加Pythonic。它支持简单的函数式测试,断言直接使用Python原生的 assert 语句,失败时会给出非常详细的差异对比。它的夹具(Fixture)系统极其强大,可以优雅地管理测试资源(如数据库连接、临时文件)。插件系统让它几乎可以满足所有测试需求(如并行测试、分布式测试、生成HTML报告等)。

来看一个直观对比。假设我们要测试一个加法函数:

使用unittest:

# src/calculator.py
def add(a, b):
    return a + b

# tests/test_calculator_unittest.py
import unittest
from src.calculator import add

class TestCalculator(unittest.TestCase):
    def test_add_integers(self):
        self.assertEqual(add(1, 2), 3)

    def test_add_floats(self):
        self.assertAlmostEqual(add(0.1, 0.2), 0.3, places=7)

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

使用pytest:

# tests/test_calculator_pytest.py
from src.calculator import add

def test_add_integers():
    assert add(1, 2) == 3

def test_add_floats():
    # pytest能很好地处理浮点数比较
    assert add(0.1, 0.2) == pytest.approx(0.3)

可以看到, pytest 的代码更简洁,更符合直觉。因此,除非项目有强制要求(比如一些遗留系统),否则我都推荐使用 pytest 作为主力测试框架。

3. 编写你的第一个单元测试

3.1 测试什么?如何设计测试用例?

新手常犯的一个错误是不知道测试什么。一个基本原则是: 测试行为,而非实现 。也就是说,你关心的是函数或方法对外表现出的功能,而不是它内部具体怎么实现的。这能保证在重构内部代码时,只要外部行为不变,测试就不需要修改。

设计测试用例,我遵循“Right-BICEP”和“CORRECT”原则的简化版:

  1. Right : 结果是否正确?这是最基本的。
  2. 边界条件(Boundary) : 输入在边界值时是否正常?例如,空列表、零、最大值、最小值。
  3. 反向关联(Inverse) : 用反向操作检验结果。例如,测试了加密函数,就用解密函数验证。
  4. 交叉检查(Cross-check) : 用另一种方法验证结果。
  5. 错误条件(Error) : 是否正确处理了非法输入或异常情况?
  6. 性能(Performance) : 是否满足性能要求?(这通常在单元测试后期或集成测试中考虑)

让我们为一个简单的用户验证函数写测试。假设我们有这样一个函数:

# src/auth.py
def validate_user(username, password):
    """验证用户名和密码。
    规则:用户名非空且长度在3-20字符;密码至少8位,且包含字母和数字。
    """
    if not username or len(username) < 3 or len(username) > 20:
        return False, "用户名长度需在3-20字符之间"
    if len(password) < 8:
        return False, "密码长度至少8位"
    if not any(c.isalpha() for c in password):
        return False, "密码需包含字母"
    if not any(c.isdigit() for c in password):
        return False, "密码需包含数字"
    return True, "验证通过"

根据上述原则,我们设计测试用例:

# tests/test_auth.py
import pytest
from src.auth import validate_user

def test_valid_user():
    """Right: 正确用例"""
    is_valid, msg = validate_user("alice", "pass123word")
    assert is_valid is True
    assert msg == "验证通过"

def test_username_too_short():
    """边界条件:用户名过短"""
    is_valid, msg = validate_user("ab", "pass123word")
    assert is_valid is False
    assert "用户名长度" in msg

def test_username_too_long():
    """边界条件:用户名过长"""
    long_name = "a" * 21
    is_valid, msg = validate_user(long_name, "pass123word")
    assert is_valid is False
    assert "用户名长度" in msg

def test_password_no_letter():
    """错误条件:密码无字母"""
    is_valid, msg = validate_user("alice", "12345678")
    assert is_valid is False
    assert "密码需包含字母" in msg

def test_password_no_digit():
    """错误条件:密码无数字"""
    is_valid, msg = validate_user("alice", "abcdefgh")
    assert is_valid is False
    assert "密码需包含数字" in msg

def test_empty_username():
    """边界条件:用户名为空"""
    is_valid, msg = validate_user("", "pass123word")
    assert is_valid is False
    # 注意:这里我们测试的是函数对空值的处理,这是常见的边界情况

运行测试:在项目根目录下,直接执行 pytest tests/ pytest 会自动发现并运行所有测试,并给出一个漂亮的总结报告。

实操心得 :不要试图在一个测试函数里验证太多东西。每个测试函数应该只关注一个具体的场景或条件。这样当测试失败时,你能立刻定位到是哪个功能点出了问题,而不是在一个庞大的测试函数里费力排查。这就是“单一职责原则”在测试中的体现。

3.2 掌握断言的艺术:让失败信息一目了然

断言是测试的核心。 pytest 的断言之所以强大,是因为当断言失败时,它会智能地展示表达式的左右值,让你一眼看出哪里不对。但要想用好,还得有点技巧。

基础断言:

assert result == expected
assert value is None
assert item in collection
assert "error" not in log_message

使用 pytest.approx 处理浮点数: 浮点数比较永远不要用 == ,因为存在精度问题。

def test_calculation():
    result = 0.1 + 0.2
    # 错误做法:assert result == 0.3 (很可能失败)
    # 正确做法:
    assert result == pytest.approx(0.3)
    # 你也可以指定相对或绝对精度
    assert result == pytest.approx(0.3, rel=1e-5)  # 相对误差
    assert result == pytest.approx(0.3, abs=1e-12) # 绝对误差

检查异常抛出: 使用 pytest.raises 作为上下文管理器来验证代码是否按预期抛出了异常。

import pytest

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError) as exc_info:
        value = 1 / 0
    # 你还可以进一步检查异常信息
    assert str(exc_info.value) == "division by zero"

自定义失败信息: 虽然 pytest 的默认报告已经很详细,但有时你需要更清晰的说明。

def test_complex_logic():
    output = complex_function(input)
    assert output == expected, f"当输入为{input}时,期望得到{expected},但实际得到{output}。请检查处理逻辑。"

注意事项 :避免在断言中调用有副作用(如修改数据库、发送网络请求)的函数。断言表达式应该只是简单的值比较或属性检查。复杂的逻辑应该放在测试的“准备(Arrange)”阶段。

4. 高级测试技巧:Fixture、参数化与Mock

4.1 使用Fixture管理测试资源

测试中经常需要一些公共的设置和清理工作,比如创建数据库连接、初始化一个复杂的对象、创建临时文件等。如果每个测试函数都自己写一遍,代码会非常冗余。 pytest 的Fixture系统就是用来解决这个问题的。

Fixture是一个函数,用 @pytest.fixture 装饰器标记。它可以在测试函数、类、模块甚至整个会话(session)级别被调用,用于提供固定的、可重用的测试上下文。

一个简单的Fixture例子:

# tests/conftest.py
# conftest.py是pytest的本地插件文件,其中定义的fixture可以被该目录及其子目录下的所有测试文件使用。
import pytest
import tempfile
import os

@pytest.fixture
def temporary_file():
    """创建一个临时文件,并在测试结束后自动清理。"""
    # 设置阶段 (Setup)
    temp = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.txt')
    temp.write("Initial content\n")
    temp.close()
    file_path = temp.name
    
    yield file_path  # 将资源提供给测试函数
    
    # 清理阶段 (Teardown)
    if os.path.exists(file_path):
        os.unlink(file_path)

# 在测试文件中使用这个fixture
def test_read_from_file(temporary_file):
    with open(temporary_file, 'r') as f:
        content = f.read()
    assert "Initial content" in content

test_read_from_file 执行时, pytest 会先调用 temporary_file fixture。执行到 yield file_path 时,暂停并将 file_path 传给测试函数。测试函数执行完毕后,再回来执行 yield 后面的清理代码。这保证了无论测试成功还是失败,临时文件都会被删除。

Fixture的作用域: 你可以通过 scope 参数控制Fixture的创建和销毁频率。

  • scope="function" (默认): 每个测试函数运行一次。
  • scope="class" : 每个测试类运行一次。
  • scope="module" : 每个测试模块(文件)运行一次。
  • scope="session" : 一次测试运行(即一次 pytest 命令)只运行一次。

对于像数据库连接这种昂贵资源,使用 scope="session" 可以显著提升测试速度。

@pytest.fixture(scope="session")
def database_connection():
    conn = create_db_connection("test_db")
    yield conn
    conn.close()

4.2 参数化测试:用一份代码测试多组数据

如果你有一个函数,需要对多种不同的输入组合进行测试,写一堆几乎相同的测试函数非常枯燥。参数化测试(Parametrize)可以让你用一组数据驱动一个测试函数。

假设我们有一个函数,用于判断年份是否为闰年:

# src/date_utils.py
def is_leap_year(year):
    if year % 400 == 0:
        return True
    if year % 100 == 0:
        return False
    if year % 4 == 0:
        return True
    return False

我们可以用 @pytest.mark.parametrize 来测试多组数据:

# tests/test_date_utils.py
import pytest
from src.date_utils import is_leap_year

@pytest.mark.parametrize("year, expected", [
    (2000, True),   # 能被400整除,是闰年
    (1900, False),  # 能被100整除但不能被400整除,不是闰年
    (2024, True),   # 能被4整除但不能被100整除,是闰年
    (2023, False),  # 不能被4整除,不是闰年
    (1600, True),   # 更早的能被400整除的年份
    (1700, False),  # 更早的能被100整除但不能被400整除的年份
])
def test_is_leap_year(year, expected):
    assert is_leap_year(year) == expected

运行这个测试, pytest 会生成6个独立的测试用例,每个用例对应一组数据。如果某一组数据失败了,报告会明确指出是哪一组 (year, expected) 导致的失败,排查起来非常方便。

参数化也支持更复杂的场景,比如多个参数组合:

@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert my_multiply(x, y) == x * y

这会生成4种组合的测试: (1,10) , (1,20) , (2,10) , (2,20)

实操心得 :参数化测试虽然强大,但要避免过度使用。如果每组测试数据的“准备(Arrange)”和“断言(Assert)”逻辑差异很大,强行参数化反而会让测试函数变得难以理解。此时,拆分成多个独立的测试函数是更好的选择。

4.3 Mock与Stub:隔离测试对象

单元测试的核心是“隔离”。我们要测试的通常是一个独立的单元(如一个函数),但它可能依赖其他模块、类、函数、网络服务或数据库。这些依赖可能不稳定、速度慢、或者有副作用(比如向数据库写入数据)。为了隔离我们的测试对象,我们需要用“替身”来代替这些依赖。这就是Mock(模拟)和Stub(桩)的作用。

  • Stub :提供预定义的、固定的返回值,用于模拟依赖对象的行为。
  • Mock :除了是Stub,还能记录自身被如何调用的信息(如调用次数、参数),并允许你在测试中对此进行断言。

Python中常用的Mock库是标准库的 unittest.mock (Python 3.3+), pytest 也通过插件 pytest-mock 提供了很好的集成。

场景:测试一个发送邮件的函数,但不想真的发邮件。

# src/notifier.py
import smtplib
from email.mime.text import MIMEText

def send_welcome_email(user_email, username):
    msg = MIMEText(f"Welcome {username}!")
    msg['Subject'] = 'Welcome to Our Service'
    msg['From'] = 'noreply@example.com'
    msg['To'] = user_email
    
    # 依赖:smtplib.SMTP,会真的尝试连接邮件服务器
    with smtplib.SMTP('localhost', 1025) as server:
        server.send_message(msg)

测试这个函数时,我们绝对不想真的启动一个邮件服务器。我们可以Mock掉 smtplib.SMTP 类。

使用pytest-mock:

# tests/test_notifier.py
def test_send_welcome_email(mocker):  # mocker是pytest-mock提供的fixture
    # 1. Mock掉smtplib.SMTP类
    mock_smtp_class = mocker.patch('src.notifier.smtplib.SMTP')
    # 创建一个Mock实例来代表SMTP()返回的对象
    mock_smtp_instance = mock_smtp_class.return_value
    # 模拟上下文管理器行为:__enter__返回实例本身,__exit__什么都不做
    mock_smtp_instance.__enter__.return_value = mock_smtp_instance
    mock_smtp_instance.__exit__.return_value = None
    
    # 2. 调用被测试函数
    from src.notifier import send_welcome_email
    send_welcome_email('user@test.com', 'Alice')
    
    # 3. 断言:SMTP类是否被以正确的参数调用了一次?
    mock_smtp_class.assert_called_once_with('localhost', 1025)
    # 断言:send_message方法是否被调用了一次?并且检查调用时的参数
    mock_smtp_instance.send_message.assert_called_once()
    call_args = mock_smtp_instance.send_message.call_args
    actual_msg = call_args[0][0]  # 第一个位置参数
    assert actual_msg['To'] == 'user@test.com'
    assert 'Welcome Alice' in actual_msg.get_payload()

通过Mock,我们将测试完全隔离在了 send_welcome_email 函数内部逻辑上,不依赖外部邮件服务。我们验证了函数是否正确构造了邮件消息,以及是否正确调用了SMTP发送流程。

Mock的常用方法:

  • mocker.patch('module.ClassName') : Mock一个类。
  • mocker.patch.object(obj, 'attribute_name') : Mock一个对象的某个属性或方法。
  • mock_obj.return_value = value : 设置Mock对象被调用时的返回值。
  • mock_obj.side_effect = [value1, value2, Exception()] : 设置Mock对象被多次调用时的行为序列,可以模拟返回值,也可以模拟抛出异常。
  • mock_obj.assert_called_once_with(...) : 断言Mock对象被以特定参数调用了一次。
  • mock_obj.call_count : 检查被调用的次数。
  • mocker.spy(obj, 'method_name') : 不改变原方法的行为,但允许你检查它被调用的情况(参数、次数)。

注意事项 :Mock虽然强大,但不要滥用。过度Mock会导致测试与实现细节耦合过紧(测试了“怎么做的”而不是“做了什么”),一旦内部实现改变(比如换了一个发送邮件的库),即使外部行为没变,测试也会失败。Mock应该主要用于隔离外部依赖(如网络、数据库、文件系统),而不是内部实现。

5. 测试覆盖率与持续集成

5.1 衡量你的测试:覆盖率报告

写了测试,怎么知道测得到底够不够?测试覆盖率是一个重要的量化指标。它表示你的测试代码执行了源代码的哪些部分,通常以百分比表示,包括行覆盖率、分支覆盖率、函数覆盖率等。

使用 pytest-cov 插件可以很方便地生成覆盖率报告。安装后,运行测试时加上 --cov 参数。

# 测试并计算src目录下代码的覆盖率
pytest --cov=src tests/

# 生成详细的HTML报告,方便查看哪些行没被覆盖
pytest --cov=src --cov-report=html tests/

运行后,命令行会输出一个摘要。HTML报告则会生成在 htmlcov 目录下,用浏览器打开 index.html ,你可以清晰地看到每个文件的覆盖率,并点击文件查看具体哪一行代码没有被执行到。

如何解读覆盖率?

  • 85%-90%以上 :通常是一个比较健康的目标,意味着大部分关键逻辑都被覆盖了。
  • 100% :理想很丰满,但现实往往不必要甚至有害。追求100%可能导致为了覆盖而覆盖,写出大量无意义的测试,比如去测试简单的getter/setter方法或纯数据类。
  • 低于70% :通常意味着测试不足,存在较大风险。

覆盖率的局限性: 覆盖率只能告诉你代码被执行了,但不能告诉你代码被“正确地”测试了。一个断言都没有的测试,即使覆盖了100%的代码,也毫无价值。因此,覆盖率是一个有用的辅助工具,但不是终极目标。我们的目标是写出有意义的、能发现bug的测试。

5.2 将测试融入工作流:持续集成(CI)

个人开发时跑测试是一回事,如何保证团队里每个人提交的代码都不破坏现有功能?答案就是持续集成(Continuous Integration, CI)。CI的核心是:每当有代码提交到共享仓库(如Git)时,自动触发一个构建流程,这个流程通常包括安装依赖、运行测试、检查代码风格等。如果任何一步失败,立即通知开发者。

主流的CI平台如GitHub Actions、GitLab CI、Jenkins等都原生支持Python项目。这里以GitHub Actions为例,展示一个最简单的CI配置。

在项目根目录创建 .github/workflows/test.yml

name: Python Tests

on: [push, pull_request] # 在推送代码或创建拉取请求时触发

jobs:
  test:
    runs-on: ubuntu-latest # 在最新的Ubuntu系统上运行
    strategy:
      matrix:
        python-version: ["3.8", "3.9", "3.10", "3.11"] # 多版本Python测试矩阵

    steps:
    - uses: actions/checkout@v2 # 检出代码
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v2
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install pytest pytest-cov
    - name: Run tests with pytest
      run: |
        pytest --cov=src --cov-report=xml tests/
    - name: Upload coverage to Codecov (可选)
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

这个配置文件做了以下几件事:

  1. 在代码推送或拉取请求时触发。
  2. 准备一个Ubuntu环境。
  3. 针对多个Python版本(3.8, 3.9, 3.10, 3.11)并行运行测试,确保代码兼容性。
  4. 安装项目依赖和测试依赖。
  5. 运行 pytest 并生成XML格式的覆盖率报告。
  6. (可选)将覆盖率报告上传到Codecov等在线服务进行跟踪。

配置好后,每次你推送代码,GitHub都会自动运行测试。你可以在仓库的“Actions”标签页查看运行结果。如果测试失败,你会收到通知,从而能立即修复问题,避免有问题的代码被合并到主分支。

实操心得 :把CI配置当作项目文档的一部分。新成员加入时,通过CI配置文件能快速了解项目的测试命令、支持的Python版本和依赖关系。确保CI能在5-10分钟内完成,如果太慢,考虑拆分测试任务或使用并行测试。一个缓慢的CI会拖慢整个团队的开发节奏。

6. 实战中的测试策略与模式

6.1 测试金字塔与测试策略

在实际项目中,测试不是越多越好,而是要讲究策略。经典的“测试金字塔”模型为我们提供了指导:

  • 单元测试(底层,最多) :测试独立的函数、类。运行快、隔离好、定位问题准。应该是你投入最多精力、数量最多的测试。
  • 集成测试(中层,中等) :测试多个模块如何协同工作。例如,测试API端点与数据库的交互。
  • 端到端测试(顶层,最少) :测试整个应用从用户界面到后端的工作流。运行慢、脆弱、维护成本高,但能发现集成测试和单元测试发现不了的问题。

对于Python后端开发,我的经验是:

  • 70-80%的精力放在单元测试 上,保证每个核心函数、类都经过充分测试。
  • 20-25%的精力放在集成测试 上,例如用 pytest 配合 requests 测试FastAPI/Django的API端点,或者测试数据库操作层。
  • 5%或更少的精力放在端到端测试 上,只覆盖最关键的用户流程。

6.2 针对不同代码结构的测试模式

1. 测试纯函数: 这是最简单的。给定输入,断言输出。大量使用参数化测试。

def process_data(input_list, threshold):
    return [x * 2 for x in input_list if x > threshold]

@pytest.mark.parametrize("input_list, threshold, expected", [
    ([1, 2, 3, 4], 2, [6, 8]),
    ([], 5, []),
    ([10], 0, [20]),
])
def test_process_data(input_list, threshold, expected):
    assert process_data(input_list, threshold) == expected

2. 测试类和方法: 重点测试公共接口。对于私有方法( _private ),通常不直接测试,而是通过测试调用它的公共方法来间接覆盖。

# src/stack.py
class Stack:
    def __init__(self):
        self._items = []
    def push(self, item):
        self._items.append(item)
    def pop(self):
        if self.is_empty():
            raise IndexError("pop from empty stack")
        return self._items.pop()
    def is_empty(self):
        return len(self._items) == 0

# tests/test_stack.py
def test_stack_push_pop():
    s = Stack()
    s.push(1)
    s.push(2)
    assert s.pop() == 2
    assert s.pop() == 1
    assert s.is_empty()

def test_pop_empty_stack():
    s = Stack()
    with pytest.raises(IndexError, match="pop from empty stack"):
        s.pop()

3. 测试依赖外部资源的代码(使用Mock): 如前所述,使用 unittest.mock pytest-mock 来隔离数据库、网络、文件系统等。

def test_user_service(mocker):
    mock_db = mocker.Mock()
    mock_db.fetch_user.return_value = {'id': 1, 'name': 'Alice'}
    service = UserService(mock_db)
    user = service.get_user(1)
    assert user.name == 'Alice'
    mock_db.fetch_user.assert_called_once_with(1)

4. 测试异步代码: 现代Python异步编程很常见。 pytest 通过 pytest-asyncio 插件支持得很好。

pip install pytest-asyncio
import pytest
import asyncio

async def async_fetch_data():
    await asyncio.sleep(0.1)
    return {"data": 42}

@pytest.mark.asyncio
async def test_async_fetch_data():
    result = await async_fetch_data()
    assert result["data"] == 42

6.3 测试数据库与API

测试数据库操作: 对于涉及数据库的测试,核心思想是使用测试数据库,并且每个测试用例都应该是独立的,不依赖于其他测试用例留下的数据。通常使用Fixture来管理数据库会话和事务回滚。

# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from myapp.models import Base, User

@pytest.fixture(scope="session")
def engine():
    # 连接到一个专门用于测试的数据库(如内存SQLite)
    return create_engine("sqlite:///:memory:")

@pytest.fixture
def tables(engine):
    # 创建所有表
    Base.metadata.create_all(engine)
    yield
    # 测试结束后删除所有表(对于内存数据库,断开连接即可)
    Base.metadata.drop_all(engine)

@pytest.fixture
def db_session(engine, tables):
    # 为每个测试函数创建一个新的数据库会话,并在测试后回滚
    connection = engine.connect()
    transaction = connection.begin()
    Session = sessionmaker(bind=connection)
    session = Session()
    yield session
    session.close()
    transaction.rollback()
    connection.close()

# tests/test_user_model.py
def test_create_user(db_session):
    new_user = User(name="Bob", email="bob@example.com")
    db_session.add(new_user)
    db_session.commit()
    # 重新查询,验证数据已持久化
    user_in_db = db_session.query(User).filter_by(name="Bob").first()
    assert user_in_db is not None
    assert user_in_db.email == "bob@example.com"

测试Web API(以FastAPI为例): 使用 TestClient 可以模拟HTTP请求,而不需要启动真正的服务器。

pip install httpx pytest
# tests/test_api.py
from fastapi.testclient import TestClient
from myapp.main import app  # 你的FastAPI应用实例

client = TestClient(app)

def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello World"}

def test_create_item():
    item_data = {"title": "Foo", "description": "Something"}
    response = client.post("/items/", json=item_data)
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "Foo"
    assert "id" in data

7. 常见陷阱与最佳实践

7.1 单元测试的“坑”与避坑指南

  1. 测试过于脆弱(与实现细节耦合过紧)

    • 现象 :稍微重构一下内部代码(比如重命名一个私有变量、拆分一个内部函数),大量测试就失败了,即使外部行为没变。
    • 解法 :牢记“测试行为,而非实现”。只测试公共接口。避免测试私有方法(除非有极其复杂的逻辑)。使用Mock时,只Mock真正的“外部依赖”,而不是内部模块。
  2. 测试依赖顺序或全局状态

    • 现象 :测试单独跑能过,但按顺序一起跑就失败。因为测试A修改了某个全局变量或数据库状态,影响了测试B。
    • 解法 :每个测试都应该是独立的、可重复的。使用Fixture在测试开始前设置好已知状态,测试结束后清理干净。对于数据库,使用事务并在测试后回滚。
  3. 过度测试(Testing the Framework)

    • 现象 :测试Python标准库或第三方库的功能。比如,测试 json.dumps() 是否能正确序列化字典。
    • 解法 :相信成熟的框架和库。你的测试应该专注于你写的业务逻辑。除非你在包装或扩展它们,否则不要测试库本身。
  4. 断言过于模糊或缺失

    • 现象 :测试只调用了函数,但没有对结果进行有意义的断言(比如只调用了函数,没有 assert )。
    • 解法 :每个测试都必须有断言。断言应该尽可能具体,检查返回值的具体内容,而不仅仅是“不是None”。
  5. 测试速度过慢

    • 现象 :跑一遍测试要几分钟甚至几十分钟,导致开发者不愿意频繁运行测试。
    • 解法
      • 区分快慢测试:用 @pytest.mark.slow 标记慢测试,平时用 pytest -m "not slow" 只跑快测试。
      • 使用Mock替换慢速依赖(网络、数据库)。
      • 利用 pytest-xdist 插件进行并行测试。

7.2 让测试成为习惯:融入开发流程

  1. 测试驱动开发(TDD) :在写实现代码之前先写测试。这能强迫你从调用者角度思考接口设计,并且保证代码从一开始就是可测试的。流程是:红(写一个失败测试)-> 绿(写最少代码让测试通过)-> 重构(优化代码,测试保持绿色)。

  2. 提交前本地运行 :养成在 git commit 前运行一遍相关测试的习惯。可以配置Git钩子(pre-commit hook)自动执行。

  3. 代码评审看测试 :在代码评审时,把测试代码作为重要审查部分。好的测试用例是理解代码功能的最佳文档。

  4. 测试命名要清晰 :测试函数名应该清晰地描述它在测试什么。好的模式是: test_<函数名>_<场景>_<预期结果> ,例如 test_login_with_invalid_password_returns_error

我个人在项目中的习惯是,为每个功能模块( src/ 下的一个文件)建立一个对应的测试文件( tests/test_<模块名>.py )。在实现一个复杂函数时,我会先在测试文件中勾勒出它的使用场景和期望行为,然后再去写实现。这就像先画好靶子再射箭,能极大地提高代码的准确性和健壮性。刚开始可能会觉得写测试拖慢了速度,但当你需要修改一个几个月前写的、已经忘记细节的模块时,那套完整的测试用例就是你的“救命稻草”。它给你重构的勇气,让你能确信自己的修改没有破坏任何现有功能。

更多推荐