目录

Python 测试基础 pytest

学习目标:掌握 pytest 测试框架的核心用法,学会编写和组织单元测试,理解测试驱动开发的理念。


1. 为什么需要测试

1.1 没有测试的问题

代码修改 → 手动验证 → "好像没问题" → 上线 → 💥 出 Bug

1.2 测试的价值

  • 防止回归:修改代码后立即发现破坏
  • 代码文档:测试用例即使用文档
  • 设计反馈:难以测试的代码通常设计不佳
  • 重构信心:有测试保护,敢于重构
  • 交付质量:减少线上故障

1.3 测试金字塔

        /\
       /  \         E2E 测试(少量)
      /    \        端到端,模拟用户操作
     /------\
    /        \      集成测试(适量)
   /          \     测试模块间协作
  /------------\
 /              \   单元测试(大量)
/                \  测试单个函数/类
------------------

2. pytest 入门

2.1 安装

pip install pytest

# 可选:常用插件
pip install pytest-cov pytest-mock pytest-asyncio pytest-xdist

2.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 auto

3. 断言

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 None

3.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) == 5

4.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"] = False

4.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) == 2

5. 参数化测试

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 > 0

6. 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.toml

7.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():
    pass

7.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 NotImplementedError

9. 实战示例

# 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. 练习题

  1. 为之前写的计算器模块编写完整的单元测试。
  2. 使用 Mock 测试一个调用外部 API 的函数。
  3. 使用参数化测试测试字符串的各种操作。
  4. 配置 pytest 的测试覆盖率,确保达到 80% 以上。

下节预告:我们将学习 Python 类型提示(typing),提升代码的可读性和可靠性。