Python 测试基础 pytest
目录
学习目标:掌握 pytest 测试框架的核心用法,学会编写和组织单元测试,理解测试驱动开发的理念。
1. 为什么需要测试
1.1 没有测试的问题
代码修改 → 手动验证 → "好像没问题" → 上线 → 💥 出 Bug1.2 测试的价值
- 防止回归:修改代码后立即发现破坏
- 代码文档:测试用例即使用文档
- 设计反馈:难以测试的代码通常设计不佳
- 重构信心:有测试保护,敢于重构
- 交付质量:减少线上故障
1.3 测试金字塔
/\
/ \ E2E 测试(少量)
/ \ 端到端,模拟用户操作
/------\
/ \ 集成测试(适量)
/ \ 测试模块间协作
/------------\
/ \ 单元测试(大量)
/ \ 测试单个函数/类
------------------2. pytest 入门
2.1 安装
pip install pytest
# 可选:常用插件
pip install pytest-cov pytest-mock pytest-asyncio pytest-xdist2.2 第一个测试
# src/calculator.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("除数不能为零")
return a / b# tests/test_calculator.py
from src.calculator import add, divide
import pytest
def test_add():
assert add(1, 2) == 3
assert add(-1, 1) == 0
assert add(0, 0) == 0
def test_add_floats():
assert add(0.1, 0.2) == pytest.approx(0.3)
def test_divide():
assert divide(10, 2) == 5
assert divide(7, 2) == 3.5
def test_divide_by_zero():
with pytest.raises(ValueError, match="除数不能为零"):
divide(10, 0)2.3 运行测试
# 运行所有测试
pytest
# 运行指定文件
pytest tests/test_calculator.py
# 运行指定测试函数
pytest tests/test_calculator.py::test_add
# 详细输出
pytest -v
# 只显示失败的信息
pytest -q
# 匹配测试名称
pytest -k "add"
# 遇到第一个失败就停止
pytest -x
# 失败后进入调试模式
pytest --pdb
# 显示局部变量
pytest -l
# 生成 HTML 报告
pytest --html=report.html
# 多进程并行运行
pytest -n auto3. 断言
3.1 基本断言
def test_assertions():
# 相等
assert 1 + 1 == 2
assert "hello" == "hello"
# 不等
assert 1 != 2
# 比较
assert 3 > 2
assert 3 >= 3
assert 2 < 3
assert 2 <= 2
# 布尔
assert True
assert not False
# 包含
assert "a" in "abc"
assert 1 in [1, 2, 3]
assert "key" in {"key": "value"}
# None 判断
assert None is None
assert "x" is not None3.2 浮点数近似
import pytest
def test_float():
# pytest.approx 处理浮点精度
assert 0.1 + 0.2 == pytest.approx(0.3)
assert 1/3 == pytest.approx(0.333, rel=1e-2)
# 列表近似
assert [0.1 + 0.2, 0.3] == pytest.approx([0.3, 0.3])3.3 异常断言
import pytest
def test_exception():
# 基本异常
with pytest.raises(ZeroDivisionError):
1 / 0
# 匹配异常消息
with pytest.raises(ValueError, match="invalid"):
int("abc")
# 捕获异常对象
with pytest.raises(ValueError) as exc_info:
raise ValueError("自定义错误消息")
assert str(exc_info.value) == "自定义错误消息"
assert "自定义" in str(exc_info.value)4. Fixture(测试夹具)
4.1 基础 Fixture
import pytest
@pytest.fixture
def sample_data():
"""提供测试数据"""
return [1, 2, 3, 4, 5]
def test_sum(sample_data):
assert sum(sample_data) == 15
def test_length(sample_data):
assert len(sample_data) == 5
def test_max(sample_data):
assert max(sample_data) == 54.2 Fixture 作用域
import pytest
@pytest.fixture(scope="function") # 默认,每个测试函数
def func_fixture():
return {}
@pytest.fixture(scope="class") # 每个测试类
def class_fixture():
return []
@pytest.fixture(scope="module") # 每个测试模块
def module_fixture():
return set()
@pytest.fixture(scope="session") # 整个测试会话
def db_connection():
"""数据库连接(整个会话只创建一次)"""
print("\n创建数据库连接")
conn = {"connected": True}
yield conn # yield 提供数据
print("\n关闭数据库连接")
conn["connected"] = False4.3 Fixture 进阶
import pytest
@pytest.fixture
def db_session(db_connection):
"""依赖其他 fixture"""
session = {"conn": db_connection, "data": []}
return session
# conftest.py 中的 fixture 自动对所有测试可用
# tests/conftest.py
@pytest.fixture
def app():
"""全局应用 fixture"""
return {"debug": True, "db": "sqlite:///:memory:"}
# 参数化 fixture
@pytest.fixture(params=[1, 2, 3])
def number(request):
return request.param
def test_positive(number):
assert number > 0
# 这个测试会运行 3 次,分别传入 1, 2, 3
# autouse fixture(自动使用)
@pytest.fixture(autouse=True)
def reset_state():
"""每个测试前自动执行"""
yield
# 清理操作4.4 临时目录
import pytest
import json
import os
def test_write_file(tmp_path):
"""tmp_path 提供临时目录"""
# tmp_path 是 pathlib.Path 对象
file_path = tmp_path / "data.json"
data = {"name": "test", "value": 42}
file_path.write_text(json.dumps(data))
# 验证
assert file_path.exists()
loaded = json.loads(file_path.read_text())
assert loaded == data
def test_multiple_files(tmp_path):
"""创建多个临时文件"""
dir_path = tmp_path / "subdir"
dir_path.mkdir()
(dir_path / "a.txt").write_text("content A")
(dir_path / "b.txt").write_text("content B")
files = list(dir_path.iterdir())
assert len(files) == 25. 参数化测试
import pytest
# 基本参数化
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
(0.1, 0.2, 0.3),
])
def test_add(a, b, expected):
assert add(a, b) == pytest.approx(expected)
# 参数化 + ID
@pytest.mark.parametrize("input_str, expected", [
("hello", "HELLO"),
("world", "WORLD"),
("", ""),
], ids=["hello", "world", "empty"])
def test_upper(input_str, expected):
assert input_str.upper() == expected
# 多参数组合
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
# 会产生 4 种组合: (1,10), (1,20), (2,10), (2,20)
assert x * y > 06. Mock(模拟对象)
6.1 基础 Mock
from unittest.mock import Mock, patch, MagicMock
import pytest
def test_mock_basic():
# 创建 Mock 对象
mock_api = Mock()
# 设置返回值
mock_api.get_user.return_value = {"id": 1, "name": "张三"}
# 使用
result = mock_api.get_user(1)
# 验证
assert result["name"] == "张三"
mock_api.get_user.assert_called_once_with(1)6.2 patch 替换
import requests
from unittest.mock import patch
# 被测试的函数
def get_weather(city):
"""获取天气信息"""
response = requests.get(f"https://api.weather.com/{city}")
return response.json()["temperature"]
# 测试时替换 requests.get
@patch("requests.get")
def test_get_weather(mock_get):
# 配置 mock
mock_response = Mock()
mock_response.json.return_value = {"temperature": 25}
mock_get.return_value = mock_response
# 测试
temp = get_weather("beijing")
assert temp == 25
mock_get.assert_called_once_with("https://api.weather.com/beijing")6.3 使用 pytest-mock
# pip install pytest-mock
def test_with_mocker(mocker):
# 使用 mocker fixture
mock_random = mocker.patch("random.randint")
mock_random.return_value = 42
import random
result = random.randint(1, 100)
assert result == 42
mock_random.assert_called_once_with(1, 100)7. 测试组织
7.1 项目结构
project/
├── src/
│ ├── __init__.py
│ ├── calculator.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py ← 共享 fixture
│ ├── test_calculator.py
│ ├── test_utils.py
│ └── integration/ ← 集成测试
│ ├── __init__.py
│ └── test_api.py
├── pytest.ini ← pytest 配置
└── pyproject.toml7.2 测试标记
import pytest
@pytest.mark.slow
def test_large_dataset():
"""耗时测试"""
pass
@pytest.mark.integration
def test_database():
"""需要数据库的集成测试"""
pass
@pytest.mark.skip(reason="功能未实现")
def test_future_feature():
pass
@pytest.mark.skipif(
"win32" in __import__("sys").platform,
reason="Windows 不支持"
)
def test_unix_only():
pass
@pytest.mark.xfail(reason="已知 Bug")
def test_known_bug():
pass7.3 pytest 配置
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short --strict-markers
markers =
slow: 标记为慢测试
integration: 集成测试
unit: 单元测试# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
markers = [
"slow: marks tests as slow",
"integration: marks tests as integration tests",
]8. 测试覆盖率
# 安装
pip install pytest-cov
# 运行并生成覆盖率报告
pytest --cov=src --cov-report=term-missing
# 生成 HTML 报告
pytest --cov=src --cov-report=html
# 覆盖率要求
pytest --cov=src --cov-fail-under=80# .coveragerc
[run]
source = src
omit = tests/*
[report]
exclude_lines =
pragma: no cover
def __repr__
if __name__ == .__main__.:
raise NotImplementedError9. 实战示例
# src/string_utils.py
def is_palindrome(s: str) -> bool:
"""检查是否为回文"""
s = s.lower().replace(" ", "")
return s == s[::-1]
def count_words(text: str) -> dict:
"""统计单词频率"""
words = text.lower().split()
freq = {}
for word in words:
freq[word] = freq.get(word, 0) + 1
return freq
def reverse_words(sentence: str) -> str:
"""反转单词顺序"""
words = sentence.split()
return " ".join(reversed(words))# tests/test_string_utils.py
import pytest
from src.string_utils import is_palindrome, count_words, reverse_words
class TestIsPalindrome:
"""回文检测测试"""
@pytest.mark.parametrize("text", [
"level", "radar", "noon", "racecar"
])
def test_palindrome(self, text):
assert is_palindrome(text) is True
@pytest.mark.parametrize("text", [
"hello", "python", "world"
])
def test_not_palindrome(self, text):
assert is_palindrome(text) is False
def test_with_spaces(self):
assert is_palindrome("a b a") is True
assert is_palindrome("was it a car or a cat i saw") is True
class TestCountWords:
"""单词统计测试"""
def test_basic(self):
result = count_words("hello world hello")
assert result == {"hello": 2, "world": 1}
def test_case_insensitive(self):
result = count_words("Hello HELLO hello")
assert result == {"hello": 3}
def test_empty_string(self):
assert count_words("") == {}
class TestReverseWords:
"""反转单词测试"""
def test_basic(self):
assert reverse_words("hello world") == "world hello"
def test_single_word(self):
assert reverse_words("hello") == "hello"
def test_empty(self):
assert reverse_words("") == ""10. 小结
| 概念 | 要点 |
|---|---|
| pytest | Python 最流行的测试框架 |
| assert | 简洁的断言语法 |
| Fixture | 测试前置/后置数据准备 |
| 参数化 | 数据驱动测试 |
| Mock | 模拟外部依赖 |
| conftest.py | 共享测试配置 |
| 覆盖率 | 衡量测试质量 |
11. 练习题
- 为之前写的计算器模块编写完整的单元测试。
- 使用 Mock 测试一个调用外部 API 的函数。
- 使用参数化测试测试字符串的各种操作。
- 配置 pytest 的测试覆盖率,确保达到 80% 以上。
下节预告:我们将学习 Python 类型提示(typing),提升代码的可读性和可靠性。