Python 测试与质量工程:pytest、mock 与覆盖率实战

Python 测试金字塔完整实践:pytest 核心(fixture/parametrize/monkeypatch)、unittest.mock/patch、Monkeypatch、覆盖率 pytest-cov、类型测试、CI 集成策略与 doctest。覆盖从单元测试到集成测试的完整工程方案。

测试不是质量 assurance,而是质量 feedback。好的测试让你「无所畏惧」地重构。本文建立完整的 Python 测试工程体系。


1. pytest 基础

1.1 为什么选择 pytest

特性unittestpytest说明
断言写法self.assertEqualassert a == b更直观
FixturesetUp/tearDown@pytest.fixture更灵活
插件生态丰富(200+)扩展性强
失败信息基础详细 diff调试友好

1.2 快速上手

pip install pytest pytest-asyncio
# test_calculator.py — 无需类,纯函数即可
def add(a: int, b: int) -> int:
    return a + b

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, 5) == 5

运行:

pytest test_calculator.py -v      # 详细输出
pytest -x                           # 遇到失败立即停止
pytest --tb=short                   # 短 traceback
pytest -k "test_add"                # 按名称筛选
pytest --lf                         # 只运行上次失败的
pytest --sw                         # 失败时进入 pdb

2. Fixture:测试依赖管理

2.1 基础 Fixture

import pytest

@pytest.fixture
def sample_user():
    """创建一个测试用户,测试结束后自动清理"""
    user = {"id": 1, "name": "Alice", "email": "alice@example.com"}
    yield user
    # teardown 代码(在 yield 之后执行)
    print(f"Cleanup user {user['id']}")

@pytest.fixture
def db_connection():
    """数据库连接 fixture"""
    conn = create_connection(":memory:")
    conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
    yield conn
    conn.close()

# 使用 fixture
def test_user_name(sample_user):
    assert sample_user["name"] == "Alice"

def test_db_insert(db_connection):
    db_connection.execute("INSERT INTO users VALUES (1, 'Bob')")
    result = db_connection.execute("SELECT * FROM users").fetchall()
    assert len(result) == 1

2.2 Fixture 作用域

@pytest.fixture(scope="function")   # 默认:每个测试函数
@pytest.fixture(scope="class")      # 每个测试类
@pytest.fixture(scope="module")     # 每个模块(文件)
@pytest.fixture(scope="session")    # 整个测试会话(只执行一次)
def expensive_resource():
    return load_heavy_model()

2.3 Fixture 组合与依赖

@pytest.fixture
def api_client():
    from fastapi.testclient import TestClient
    from app.main import app
    return TestClient(app)

@pytest.fixture
def auth_headers(api_client):  # fixture 依赖另一个 fixture
    """获取认证后的请求头"""
    response = api_client.post("/token", data={"username": "test", "password": "test"})
    token = response.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}

def test_protected_endpoint(api_client, auth_headers):
    response = api_client.get("/users/me", headers=auth_headers)
    assert response.status_code == 200

3. Parametrize:参数化测试

3.1 基本用法

import pytest

@pytest.mark.parametrize(
    "input,expected",
    [
        ("hello", "HELLO"),
        ("World", "WORLD"),
        ("", ""),
        ("123", "123"),
    ]
)
def test_uppercase(input, expected):
    assert input.upper() == expected

# 多参数
@pytest.mark.parametrize(
    "a,b,expected",
    [
        (1, 1, 2),
        (2, 3, 5),
        (10, 20, 30),
    ]
)
def test_add(a, b, expected):
    assert a + b == expected

3.2 与 Fixture 结合

@pytest.fixture(params=["sqlite", "postgresql"])
def database(request):
    if request.param == "sqlite":
        return SQLiteDB(":memory:")
    else:
        return PostgreSQLDB("postgresql://test:test@localhost/test")

def test_insert(database):  # 对每个 param 都运行
    database.insert({"name": "test"})
    assert database.count() == 1

4. Mock 与 Patch

4.1 unittest.mock 基础

from unittest.mock import Mock, patch, MagicMock

# Mock 对象
mock = Mock()
mock.return_value = 42
assert mock() == 42

# 记录调用
mock("hello", key="value")
assert mock.called
assert mock.call_args == (("hello",), {"key": "value"})
assert mock.call_count == 1

# 指定返回值序列
mock.side_effect = [1, 2, 3, Exception("done")]
assert mock() == 1
assert mock() == 2
assert mock() == 3
# mock()  # 抛出 Exception

4.2 patch:替换导入

from unittest.mock import patch
import requests

# patch 方式 1:装饰器
@patch('mymodule.requests.get')
def test_fetch_data(mock_get):
    mock_get.return_value.json.return_value = {"data": "test"}
    
    result = mymodule.fetch_data("https://api.example.com")
    assert result == {"data": "test"}
    mock_get.assert_called_once_with("https://api.example.com")

# patch 方式 2:上下文管理器
def test_fetch_data_context():
    with patch('mymodule.requests.get') as mock_get:
        mock_get.return_value.status_code = 200
        # ...

# patch 方式 3:手动
patcher = patch('mymodule.requests.get')
mock_get = patcher.start()
# ... 测试 ...

# patch 多个
@patch('mymodule.send_email')
@patch('mymodule.log_activity')
def test_user_registration(mock_log, mock_email):
    ...

4.3 Monkeypatch

pytest 内置的 monkeypatch 更适合简单替换:

def test_env_var(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    assert os.environ["API_KEY"] == "test-key-123"

def test_modify_function(monkeypatch):
    def mock_add(a, b):
        return 100
    
    monkeypatch.setattr("mymodule.add", mock_add)
    assert mymodule.add(1, 2) == 100  # 被替换了

5. 异步测试

import pytest
import asyncio

@pytest.mark.asyncio
async def test_async_fetch():
    import aiohttp
    
    async with aiohttp.ClientSession() as session:
        async with session.get("https://httpbin.org/get") as resp:
            assert resp.status == 200
            data = await resp.json()
            assert "url" in data

# Fixture 也支持 async
@pytest.fixture
async def async_db():
    db = await create_async_db()
    yield db
    await db.close()

@pytest.mark.asyncio
async def test_async_insert(async_db):
    await async_db.insert({"name": "test"})
    result = await async_db.find_one({"name": "test"})
    assert result["name"] == "test"

6. 覆盖率

pip install pytest-cov

# 运行测试 + 生成覆盖率报告
pytest --cov=src --cov-report=term-missing
pytest --cov=src --cov-report=html  # 生成 htmlcov/ 目录
pytest --cov=src --cov-report=xml   # 生成 coverage.xml(CI 使用)

配置 .coveragerc

[run]
source = src
branch = True  # 分支覆盖率

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:

[html]
directory = htmlcov

覆盖率目标:

  • 单元测试:≥ 80%
  • 核心业务逻辑:≥ 90%
  • 工具脚本:≥ 60%

7. 测试策略金字塔

     /\
    /  \  端到端测试 (E2E) - 慢但全面 - 10%
   /____\
  /      \  集成测试 - 验证组件交互 - 20%
 /________\
/          \  单元测试 - 快而精确 - 70%

7.1 单元测试原则(FIRST)

原则说明
Fast< 1ms,无外部依赖
Independent不依赖其他测试
Repeatable任何环境结果一致
Self-validating断言明确,不假手于人
Timely与代码同步编写

7.2 集成测试模式

# 测试数据库集成
@pytest.fixture
def test_db():
    import tempfile
    db_path = tempfile.mktemp(suffix=".db")
    db = create_db(db_path)
    yield db
    db.close()
    os.unlink(db_path)

# 测试 API 集成
def test_create_user_integration(api_client, test_db):
    response = api_client.post("/users", json={"name": "Alice", "email": "alice@example.com"})
    assert response.status_code == 201
    
    # 验证数据库状态
    user = test_db.query(User).filter_by(email="alice@example.com").first()
    assert user is not None
    assert user.name == "Alice"

8. CI 集成

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Install uv
        uses: astral-sh/setup-uv@v2
      
      - name: Sync dependencies
        run: uv sync
      
      - name: Run tests
        run: uv run pytest -xvs --cov=src --cov-report=xml
      
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.xml

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章