强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Python 编程教程 / 15 - 测试

第 15 章:测试

掌握 pytest 测试框架、fixture、参数化、Mock 和测试驱动开发。


15.1 测试基础

15.1.1 为什么需要测试?

测试类型目的工具
单元测试验证单个函数/类pytest, unittest
集成测试验证模块协作pytest
端到端测试验证完整流程Selenium, Playwright
性能测试验证性能指标pytest-benchmark

15.1.2 pytest 基本用法

# calculator.py
def add(a: int, b: int) -> int:
    return a + b

def divide(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("除数不能为零")
    return a / b
# test_calculator.py
from calculator import add, divide

def test_add_positive():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, -1) == -2

def test_add_zero():
    assert add(0, 0) == 0

def test_divide():
    assert divide(10, 2) == 5.0

def test_divide_by_zero():
    import pytest
    with pytest.raises(ValueError, match="除数不能为零"):
        divide(1, 0)
# 运行测试
$ pytest
$ pytest -v  # 详细输出
$ pytest test_calculator.py  # 指定文件
$ pytest -k "add"  # 匹配测试名

15.2 pytest Fixture

15.2.1 基本 fixture

import pytest
from pathlib import Path

@pytest.fixture
def sample_data():
    """提供测试数据。"""
    return {"name": "Alice", "age": 30}

@pytest.fixture
def temp_dir(tmp_path):
    """使用 pytest 内置的 tmp_path。"""
    data_file = tmp_path / "data.txt"
    data_file.write_text("hello")
    return data_file

def test_sample_data(sample_data):
    assert sample_data["name"] == "Alice"

def test_file_content(temp_dir):
    assert temp_dir.read_text() == "hello"

15.2.2 fixture 作用域

@pytest.fixture(scope="function")   # 默认,每个测试函数执行一次
def db_connection():
    conn = create_connection()
    yield conn
    conn.close()

@pytest.fixture(scope="module")     # 整个模块执行一次
def database():
    db = setup_database()
    yield db
    teardown_database(db)

@pytest.fixture(scope="session")    # 整个测试会话执行一次
def app():
    return create_app()

@pytest.fixture(scope="class")      # 每个测试类执行一次
def shared_resource():
    ...

15.2.3 yield fixture(清理资源)

@pytest.fixture
def user_service():
    # setup
    service = UserService()
    service.connect()
    yield service  # 测试执行期间使用
    # teardown
    service.disconnect()
    service.cleanup()

def test_create_user(user_service):
    user = user_service.create("Alice")
    assert user.name == "Alice"

15.3 参数化测试

import pytest

# 基本参数化
@pytest.mark.parametrize("input, expected", [
    ("hello", "HELLO"),
    ("world", "WORLD"),
    ("Python", "PYTHON"),
])
def test_upper(input, expected):
    assert input.upper() == expected

# 多参数组合
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert x * y > 0

# 自定义测试 ID
@pytest.mark.parametrize("input, expected", [
    pytest.param(1, 2, id="one"),
    pytest.param(2, 4, id="two"),
], ids=["小写", "大写"])
def test_double(input, expected):
    assert input * 2 == expected

15.4 Mock

15.4.1 基本用法

from unittest.mock import Mock, patch, MagicMock

# 创建 Mock 对象
mock_api = Mock()
mock_api.get_user.return_value = {"name": "Alice"}

result = mock_api.get_user(1)
assert result == {"name": "Alice"}
mock_api.get_user.assert_called_once_with(1)

15.4.2 patch 装饰器

# service.py
import requests

def get_user(user_id: int) -> dict:
    response = requests.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status()
    return response.json()
# test_service.py
from unittest.mock import patch, MagicMock
from service import get_user

@patch("service.requests.get")
def test_get_user(mock_get):
    # 设置 mock 返回值
    mock_response = MagicMock()
    mock_response.json.return_value = {"id": 1, "name": "Alice"}
    mock_response.raise_for_status.return_value = None
    mock_get.return_value = mock_response

    result = get_user(1)
    
    assert result["name"] == "Alice"
    mock_get.assert_called_once_with("https://api.example.com/users/1")

15.4.3 Mock 常用方法

mock = Mock()

# 设置返回值
mock.method.return_value = 42

# 设置副作用
mock.method.side_effect = ValueError("错误")

# 多次调用不同返回值
mock.method.side_effect = [1, 2, 3]

# 验证调用
mock.method(1, 2, key="value")
mock.method.assert_called_once_with(1, 2, key="value")
mock.method.assert_called()
mock.method.assert_called_with(1, 2, key="value")
assert mock.method.call_count == 1

15.5 测试覆盖率

# 安装
$ pip install pytest-cov

# 运行并生成覆盖率报告
$ pytest --cov=myproject --cov-report=term-missing
$ pytest --cov=myproject --cov-report=html  # HTML 报告
# pyproject.toml 配置
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=src --cov-report=term-missing"

[tool.coverage.run]
source = ["src"]
omit = ["tests/*"]

[tool.coverage.report]
fail_under = 80
show_missing = true

15.6 hypothesis(属性测试)

from hypothesis import given, strategies as st

@given(st.integers(), st.integers())
def test_add_commutative(a, b):
    assert a + b == b + a

@given(st.lists(st.integers()))
def test_sort_idempotent(lst):
    assert sorted(sorted(lst)) == sorted(lst)

@given(st.text(min_size=1))
def test_encode_decode(s):
    assert s.encode("utf-8").decode("utf-8") == s

15.7 测试目录结构

myproject/
├── src/
│   └── myproject/
│       ├── __init__.py
│       ├── models.py
│       └── services.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py         # 共享 fixture
│   ├── test_models.py
│   └── test_services.py
└── pyproject.toml
# tests/conftest.py(共享 fixture)
import pytest

@pytest.fixture
def sample_user():
    return {"id": 1, "name": "Alice", "email": "alice@example.com"}

@pytest.fixture
def db_session():
    session = create_session()
    yield session
    session.rollback()
    session.close()

15.8 TDD(测试驱动开发)

TDD 流程:
1. 🔴 Red:编写一个失败的测试
2. 🟢 Green:编写最少代码使测试通过
3. 🔵 Refactor:重构代码,保持测试通过
# 第一步:写测试
def test_fizzbuzz():
    assert fizzbuzz(3) == "Fizz"
    assert fizzbuzz(5) == "Buzz"
    assert fizzbuzz(15) == "FizzBuzz"
    assert fizzbuzz(7) == "7"

# 第二步:实现
def fizzbuzz(n: int) -> str:
    if n % 15 == 0:
        return "FizzBuzz"
    if n % 3 == 0:
        return "Fizz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)

# 第三步:重构
def fizzbuzz(n: int) -> str:
    parts = []
    if n % 3 == 0:
        parts.append("Fizz")
    if n % 5 == 0:
        parts.append("Buzz")
    return "".join(parts) or str(n)

15.9 pytest 常用插件

插件用途
pytest-cov覆盖率
pytest-xdist并行测试
pytest-asyncio异步测试
pytest-mockMock 支持
pytest-djangoDjango 测试
pytest-httpxhttpx Mock
pytest-benchmark性能基准
# 并行运行测试
$ pip install pytest-xdist
$ pytest -n auto  # 自动使用所有 CPU 核心

15.10 注意事项

🔴 注意

  • 测试文件命名为 test_*.py*_test.py
  • 测试函数命名为 test_*
  • 不要测试实现细节,测试行为
  • Mock 外部依赖(网络、数据库、文件系统),不 Mock 被测代码

💡 提示

  • 使用 conftest.py 共享 fixture
  • 使用 @pytest.mark.parametrize 减少重复测试代码
  • 使用 tmp_path fixture 处理临时文件
  • 保持测试独立,测试之间不应有依赖

📌 业务场景

import pytest
from unittest.mock import AsyncMock, patch
from dataclasses import dataclass

@dataclass
class Order:
    id: int
    amount: float
    status: str = "pending"

class OrderService:
    def __init__(self, repo):
        self.repo = repo
    
    def create_order(self, amount: float) -> Order:
        if amount <= 0:
            raise ValueError("金额必须大于零")
        order = Order(id=1, amount=amount)
        return self.repo.save(order)

# 测试
class TestOrderService:
    @pytest.fixture
    def mock_repo(self):
        repo = Mock()
        repo.save.side_effect = lambda o: o
        return repo
    
    @pytest.fixture
    def service(self, mock_repo):
        return OrderService(mock_repo)
    
    def test_create_order(self, service, mock_repo):
        order = service.create_order(100.0)
        assert order.amount == 100.0
        assert order.status == "pending"
        mock_repo.save.assert_called_once()
    
    def test_create_order_negative_amount(self, service):
        with pytest.raises(ValueError, match="金额必须大于零"):
            service.create_order(-10)

15.11 扩展阅读