白盒测试系统教程
第9章:Python单元测试实战(pytest)
第9章:Python单元测试实战(pytest)
pytest从入门到高级——掌握Python测试的瑞士军刀
在Python生态中,提到单元测试,pytest是绝对的王者——它比标准库的unittest更简洁、比nose更强大、比doctest更专业。如果你只学一个Python测试框架,那就是pytest。
今天,我们从零开始,通过实战案例掌握pytest的核心功能,最后完成一个Flask API项目的完整测试。
pytest 的核心设计理念是"让测试尽可能接近真实代码"。它没有强制你继承某个测试基类,不要求在测试类里写 self,断言直接用 Python 原生的 assert 关键字——当断言失败时,pytest 会自动展开表达式,告诉你到底哪个值不对。
为什么不用 unittest? 很多人在学 Python 时先学了 unittest,因为它在标准库里,不需要安装。但 unittest 的设计灵感来自 Java 的 JUnit,有大量面向对象的包袱:每个测试必须是类的方法且类必须继承 TestCase,断言用 self.assertEqual(a, b) 而不是 assert a == b。pytest 解决了这些痛点,同时还完全兼容 unittest 的测试——你可以在 pytest 里直接运行以前写的 unittest 测试,无需修改。
一、pytest快速入门
1. 安装与第一个测试
bash
# 环境要求:Python 3.9+
# 安装pytest
uv pip install pytest pytest-cov
第一个测试:
python
# calculator.py
def add(a, b):
return a + b
# test_calculator.py
def test_add():
"""测试加法"""
assert add(2, 3) == 5
assert add(-1, 1) == 0
# 运行测试
# pytest test_calculator.py
输出示例:
===== test session starts =====
collected 1 item
test_calculator.py . [100%]
===== 1 passed in 0.01s =====
2. pytest的优势
| 对比项 | unittest | pytest |
|-------|----------|--------|
| 断言 | self.assertEqual(a, b) | assert a == b |
| 测试类 | 必须继承TestCase | 不需要继承 |
| Setup/Teardown | setUp()/tearDown() | fixture |
| 参数化 | 需要子类化 | @pytest.mark.parametrize |
| 插件 | 少 | 丰富(500+) |
二、pytest核心功能
1. 断言增强
pytest 最让人喜欢的特性之一是断言内省(assertion introspection)。当 assert 失败时,pytest 不只是告诉你"断言失败",而是自动展开表达式,精确指出哪个值和预期不一致。这在调试复杂的字典、列表比较时极其有用——你不需要在 assert 后面再加 f"期望{expected},实际{actual}" 这样的注释,pytest 已经帮你做了。
python
def test_dict_assertion():
result = {"name": "张三", "age": 25}
expected = {"name": "张三", "age": 26}
assert result == expected
# 运行测试失败时的输出:
# AssertionError: assert {'age': 25, 'name': '张三'} == {'age': 26, 'name': '张三'}
# - {'age': 26, 'name': '张三'}
# ? ^^
# + {'age': 25, 'name': '张三'}
# ? ^^
2. Fixture:测试前置条件
Fixture 与 setUp/tearDown 的本质区别
传统测试框架用 setUp() 和 tearDown() 管理前置条件,所有测试共享同一套初始化逻辑,无法灵活地为不同测试提供不同的前置条件。
pytest 的 fixture 彻底改变了这个模式。fixture 是一个普通的 Python 函数,标记了 @pytest.fixture 装饰器。当测试函数的参数名与某个 fixture 同名时,pytest 会自动调用该 fixture,将返回值注入给测试函数。这个机制带来三大好处:
• 按需使用:fixture 只在需要它的测试里才执行,不会每个测试都跑一遍初始化代码
• 可组合:一个 fixture 可以依赖另一个 fixture,pytest 自动按依赖顺序创建
• 作用域控制:通过 scope 参数控制生命周期,避免重复创建数据库连接等昂贵资源
python
import pytest
@pytest.fixture
def user():
"""创建测试用户"""
return {"name": "测试用户", "email": "test@example.com"}
def test_user_info(user):
"""使用fixture"""
assert user["name"] == "测试用户"
assert "@" in user["email"]
Fixture作用域:
python
@pytest.fixture(scope="function") # 默认:每个测试函数执行一次
def setup_function():
pass
@pytest.fixture(scope="class") # 每个测试类执行一次
def setup_class():
pass
@pytest.fixture(scope="module") # 每个测试文件执行一次
def setup_module():
pass
@pytest.fixture(scope="session") # 整个测试会话执行一次
def setup_session():
pass
Fixture的yield用法(Setup & Teardown):
python
@pytest.fixture
def database():
"""数据库连接fixture"""
# Setup
db = Database.connect("test.db")
print("数据库连接已建立")
yield db # 测试代码运行在这里
# Teardown
db.close()
print("数据库连接已关闭")
def test_query(database):
result = database.query("SELECT * FROM users")
assert len(result) > 0
3. 参数化测试
参数化的真正价值:不是"少写代码",而是让测试用例的覆盖范围一目了然。一张参数化表就是业务场景的"等价类清单"——一眼看出覆盖了哪些场景、遗漏了哪些,比 Excel 测试用例更直观,而且是可执行的文档。
命名技巧:用 pytest.param(..., id="描述性名称") 给每组参数起有意义的名字,测试报告里显示的就是业务语言,而不是 test_discount[100-normal-0.0] 这样难以理解的字符串。
python
import pytest
@pytest.mark.parametrize("input,expected", [
(2, 4),
(3, 9),
(4, 16),
(5, 25),
])
def test_square(input, expected):
"""测试平方运算"""
assert input ** 2 == expected
# 自动生成4个测试用例!
# test_square[2-4] PASSED
# test_square[3-9] PASSED
# test_square[4-16] PASSED
# test_square[5-25] PASSED
多参数组合:
python
@pytest.mark.parametrize("user_type", ["VIP", "Normal"])
@pytest.mark.parametrize("amount", [100, 500, 1000])
def test_discount(user_type, amount):
"""测试折扣计算"""
discount = calculate_discount(user_type, amount)
assert discount >= 0
# 自动生成 2 × 3 = 6个测试用例!
三、pytest-cov:覆盖率检测
1. 基础用法
bash
# 运行测试并生成覆盖率报告
pytest --cov=src --cov-report=term
# 生成HTML报告
pytest --cov=src --cov-report=html
# 指定覆盖率阈值
pytest --cov=src --cov-fail-under=80
2. 配置文件(.coveragerc)
ini
[run]
source = src
branch = True
omit =
*/tests/*
*/migrations/*
*/__pycache__/*
[report]
precision = 2
show_missing = True
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
[html]
directory = htmlcov
3. 排除不需要测试的代码
python
def debug_function(): # pragma: no cover
"""开发调试用,不需要测试"""
print("Debug info")
def main():
if __name__ == "__main__": # pragma: no cover
run()
四、Mock与Monkey Patch
为什么需要 Mock?
"单元"测试的关键在于"单元"——每次只测试一个功能单元,排除外部因素的干扰。但真实代码往往依赖外部资源:数据库、HTTP 接口、文件系统、时间函数……如果测试直接调用真实依赖,会带来三个问题:
• 慢:一个数据库查询可能需要几百毫秒,1000 个测试就需要好几分钟
• 不稳定:网络抖动、数据库不可用会导致测试失败,但这不是你代码的问题
• 难以构造边界场景:你怎么让数据库在测试时主动抛出连接超时异常?
Mock 就是"替身演员":用一个行为完全受控的假对象替换真实依赖,让测试专注于验证代码逻辑本身。
Mock vs Monkeypatch 的选择:
• unittest.mock.patch:替换模块级别的依赖(如 requests.get、smtplib.SMTP),支持复杂的调用验证
• monkeypatch:pytest 内置的轻量替换机制,适合临时修改函数行为、环境变量、类属性,在 pytest 测试函数里更自然
python
from unittest.mock import patch, MagicMock
def send_email(to, subject, body):
"""发送邮件"""
smtp_server.send(to, subject, body)
@patch('module.smtp_server')
def test_send_email(mock_smtp):
"""Mock SMTP服务器"""
send_email("test@example.com", "测试", "测试内容")
# 验证函数被调用
mock_smtp.send.assert_called_once_with(
"test@example.com",
"测试",
"测试内容"
)
2. Monkeypatch:运行时替换
python
def test_api_call(monkeypatch):
"""Monkeypatch修改函数行为"""
def mock_requests_get(url):
class MockResponse:
status_code = 200
def json(self):
return {"data": "mocked"}
return MockResponse()
# 替换 requests.get 函数
monkeypatch.setattr("requests.get", mock_requests_get)
response = call_external_api()
assert response["data"] == "mocked"
3. Mock时间
python
import pytest
from datetime import datetime
def test_time_sensitive_function(monkeypatch):
"""Mock时间"""
class MockDatetime:
@classmethod
def now(cls):
return datetime(2024, 1, 1, 12, 0, 0)
monkeypatch.setattr("datetime.datetime", MockDatetime)
result = get_current_date()
assert result == "2024-01-01"
五、pytest高级功能
测试标记(Markers)的实际意义
markers 不只是给测试打标签,它解决了一个实际工程问题:不同场景需要运行不同范围的测试。
• 本地开发:只运行快速的单元测试(跳过慢速集成测试)pytest -m "not slow and not integration"
• PR 合并前:运行所有单元测试和集成测试,但跳过需要外部服务的 e2e 测试
• 每日构建:运行全套测试包括 e2e
把这些约定写进 pytest.ini 的 markers 配置里,团队所有人都遵守同一套规则,测试套件的运行速度和覆盖范围就能按需调节。
python
import pytest
@pytest.mark.slow
def test_large_computation():
"""耗时测试"""
pass
@pytest.mark.integration
def test_database_query():
"""集成测试"""
pass
# 只运行特定标记的测试
# pytest -m "not slow" # 跳过慢速测试
# pytest -m integration # 只运行集成测试
2. 跳过测试
python
@pytest.mark.skip(reason="功能未实现")
def test_future_feature():
pass
@pytest.mark.skipif(sys.platform == "win32", reason="仅Linux")
def test_linux_only():
pass
3. 预期失败
python
@pytest.mark.xfail(reason="已知bug #123")
def test_known_bug():
assert 1 == 2 # 测试失败,但不会报错
六、完整实战:Flask API单元测试
项目结构
flask-api/
├── app/
│ ├── __init__.py
│ ├── models.py
│ ├── routes.py
│ └── utils.py
├── tests/
│ ├── conftest.py
│ ├── test_models.py
│ ├── test_routes.py
│ └── test_utils.py
├── .coveragerc
└── pytest.ini
1. Flask应用代码
python
# app/__init__.py
from flask import Flask
def create_app():
app = Flask(__name__)
app.config['TESTING'] = True
return app
# app/models.py
class User:
def __init__(self, name, email):
self.name = name
self.email = email
def to_dict(self):
return {"name": self.name, "email": self.email}
# app/routes.py
from flask import jsonify, request
from app import create_app
app = create_app()
@app.route('/users', methods=['POST'])
def create_user():
data = request.get_json()
if not data.get('email'):
return jsonify({"error": "email required"}), 400
user = User(name=data['name'], email=data['email'])
return jsonify(user.to_dict()), 201
2. conftest.py:共享 Fixture 的核心机制
conftest.py 是 pytest 的全局配置文件,放在测试目录(或其父目录)下,pytest 会自动发现它。所有在 conftest.py 里定义的 fixture,对同目录及子目录下的所有测试文件都可见,不需要 import——这是 pytest 中共享测试基础设施的标准方式。
一个好的 conftest.py 通常包含:数据库连接 fixture、Flask/FastAPI 测试客户端 fixture、测试用账号和测试数据的创建逻辑。把这些放在 conftest.py 里,整个测试项目都能复用,而不需要在每个测试文件里重复定义。
import pytest
from app import create_app
@pytest.fixture
def app():
"""创建Flask应用"""
app = create_app()
return app
@pytest.fixture
def client(app):
"""创建测试客户端"""
return app.test_client()
### 3. 单元测试
tests/test_models.py
from app.models import User
def test_user_creation():
"""测试用户创建"""
user = User("张三", "zhangsan@example.com")
assert user.name == "张三"
assert user.email == "zhangsan@example.com"
def test_user_to_dict():
"""测试用户序列化"""
user = User("李四", "lisi@example.com")
data = user.to_dict()
assert data == {"name": "李四", "email": "lisi@example.com"}
tests/test_routes.py
def test_create_user_success(client):
"""测试创建用户成功"""
response = client.post('/users', json={
"name": "王五",
"email": "wangwu@example.com"
})
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "王五"
assert data["email"] == "wangwu@example.com"
def test_create_user_missing_email(client):
"""测试缺少email"""
response = client.post('/users', json={"name": "赵六"})
assert response.status_code == 400
data = response.get_json()
assert "error" in data
### 4. 运行测试
运行所有测试
pytest
运行带覆盖率报告
pytest --cov=app --cov-report=term --cov-report=html
只运行特定文件
pytest tests/test_models.py
运行特定测试
pytest tests/test_routes.py::test_create_user_success -v
详细输出
pytest -v --tb=short
**输出示例**:
===== test session starts =====
collected 4 items
tests/test_models.py .. [50%]
tests/test_routes.py .. [100%]
----------- coverage: --------------------
Name Stmts Miss Cover
app/init.py 3 0 100%
app/models.py 6 0 100%
app/routes.py 10 0 100%
TOTAL 19 0 100%
===== 4 passed in 0.23s =====
---
## 七、CI/CD集成
将测试接入 CI 是自动化测试产生真正价值的关键一步。没有 CI,测试只能靠开发者自觉运行,容易被跳过;接入 CI 后,每次 push 代码都自动触发测试,任何破坏性改动在几分钟内就会被发现,而不是等到集成测试或上线后才暴露。
覆盖率报告上传到 Codecov 等平台后,还能在 PR 页面直接看到本次改动对覆盖率的影响——新增代码的覆盖率是上升还是下降,一目了然。
.github/workflows/test.yml
name: Python Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Run tests
run: |
pytest --cov=app --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v2