Python单元测试实战指南:从unittest到pytest,掌握Mock与CI/CD集成
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”原则的简化版:
- Right : 结果是否正确?这是最基本的。
- 边界条件(Boundary) : 输入在边界值时是否正常?例如,空列表、零、最大值、最小值。
- 反向关联(Inverse) : 用反向操作检验结果。例如,测试了加密函数,就用解密函数验证。
- 交叉检查(Cross-check) : 用另一种方法验证结果。
- 错误条件(Error) : 是否正确处理了非法输入或异常情况?
- 性能(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
这个配置文件做了以下几件事:
- 在代码推送或拉取请求时触发。
- 准备一个Ubuntu环境。
- 针对多个Python版本(3.8, 3.9, 3.10, 3.11)并行运行测试,确保代码兼容性。
- 安装项目依赖和测试依赖。
- 运行
pytest并生成XML格式的覆盖率报告。 - (可选)将覆盖率报告上传到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 单元测试的“坑”与避坑指南
-
测试过于脆弱(与实现细节耦合过紧) :
- 现象 :稍微重构一下内部代码(比如重命名一个私有变量、拆分一个内部函数),大量测试就失败了,即使外部行为没变。
- 解法 :牢记“测试行为,而非实现”。只测试公共接口。避免测试私有方法(除非有极其复杂的逻辑)。使用Mock时,只Mock真正的“外部依赖”,而不是内部模块。
-
测试依赖顺序或全局状态 :
- 现象 :测试单独跑能过,但按顺序一起跑就失败。因为测试A修改了某个全局变量或数据库状态,影响了测试B。
- 解法 :每个测试都应该是独立的、可重复的。使用Fixture在测试开始前设置好已知状态,测试结束后清理干净。对于数据库,使用事务并在测试后回滚。
-
过度测试(Testing the Framework) :
- 现象 :测试Python标准库或第三方库的功能。比如,测试
json.dumps()是否能正确序列化字典。 - 解法 :相信成熟的框架和库。你的测试应该专注于你写的业务逻辑。除非你在包装或扩展它们,否则不要测试库本身。
- 现象 :测试Python标准库或第三方库的功能。比如,测试
-
断言过于模糊或缺失 :
- 现象 :测试只调用了函数,但没有对结果进行有意义的断言(比如只调用了函数,没有
assert)。 - 解法 :每个测试都必须有断言。断言应该尽可能具体,检查返回值的具体内容,而不仅仅是“不是None”。
- 现象 :测试只调用了函数,但没有对结果进行有意义的断言(比如只调用了函数,没有
-
测试速度过慢 :
- 现象 :跑一遍测试要几分钟甚至几十分钟,导致开发者不愿意频繁运行测试。
- 解法 :
- 区分快慢测试:用
@pytest.mark.slow标记慢测试,平时用pytest -m "not slow"只跑快测试。 - 使用Mock替换慢速依赖(网络、数据库)。
- 利用
pytest-xdist插件进行并行测试。
- 区分快慢测试:用
7.2 让测试成为习惯:融入开发流程
-
测试驱动开发(TDD) :在写实现代码之前先写测试。这能强迫你从调用者角度思考接口设计,并且保证代码从一开始就是可测试的。流程是:红(写一个失败测试)-> 绿(写最少代码让测试通过)-> 重构(优化代码,测试保持绿色)。
-
提交前本地运行 :养成在
git commit前运行一遍相关测试的习惯。可以配置Git钩子(pre-commit hook)自动执行。 -
代码评审看测试 :在代码评审时,把测试代码作为重要审查部分。好的测试用例是理解代码功能的最佳文档。
-
测试命名要清晰 :测试函数名应该清晰地描述它在测试什么。好的模式是:
test_<函数名>_<场景>_<预期结果>,例如test_login_with_invalid_password_returns_error。
我个人在项目中的习惯是,为每个功能模块( src/ 下的一个文件)建立一个对应的测试文件( tests/test_<模块名>.py )。在实现一个复杂函数时,我会先在测试文件中勾勒出它的使用场景和期望行为,然后再去写实现。这就像先画好靶子再射箭,能极大地提高代码的准确性和健壮性。刚开始可能会觉得写测试拖慢了速度,但当你需要修改一个几个月前写的、已经忘记细节的模块时,那套完整的测试用例就是你的“救命稻草”。它给你重构的勇气,让你能确信自己的修改没有破坏任何现有功能。
更多推荐


所有评论(0)