Python测试与覆盖率工具完全指南 - pytest与coverage

在现代软件开发中,测试是保证代码质量的重要手段。本文将详细介绍Python生态中最流行的测试框架 pytest 和代码覆盖率工具 coverage.py,帮助你建立完善的测试体系。

一、pytest - Python首选测试框架

1.1 什么是pytest?

pytest 是Python生态中最流行的测试框架,它具有简洁的语法、强大的功能和丰富的插件生态系统。相比Python内置的unittest,pytest提供了更现代、更灵活的测试体验。

主要特点:

  • 简洁的断言语法:使用原生Python的assert语句
  • 强大的fixture机制:灵活的测试数据和资源管理
  • 参数化测试:轻松运行相同测试的多个变体
  • 丰富的插件生态:支持并行测试、覆盖率报告、Django集成等
  • 自动发现测试:自动查找和运行测试文件

1.2 安装pytest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 基本安装
pip install pytest

# 安装常用插件
pip install pytest-xdist # 并行测试
pip install pytest-cov # 覆盖率集成
pip install pytest-django # Django项目支持
pip install pytest-asyncio # 异步测试支持
pip install pytest-mock # Mock支持
pip install pytest-testmon # 智能测试选择

# 使用uv(推荐)
uv add pytest pytest-xdist pytest-cov --group dev

# 使用Poetry
poetry add pytest pytest-xdist pytest-cov --group dev

1.3 编写第一个测试

创建测试文件 test_example.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# test_example.py

def add(a: int, b: int) -> int:
"""两数相加"""
return a + b


def test_add_positive_numbers():
"""测试正数相加"""
assert add(1, 2) == 3


def test_add_negative_numbers():
"""测试负数相加"""
assert add(-1, -2) == -3


def test_add_mixed_numbers():
"""测试混合数字相加"""
assert add(-1, 2) == 1
assert add(1, -2) == -1


class TestCalculator:
"""计算器测试类"""

def test_add(self):
assert add(1, 2) == 3

def test_add_zeros(self):
assert add(0, 0) == 0

运行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 运行所有测试
pytest

# 运行特定文件
pytest test_example.py

# 运行特定测试函数
pytest test_example.py::test_add_positive_numbers

# 运行特定测试类
pytest test_example.py::TestCalculator

# 详细输出
pytest -v

# 显示print输出
pytest -s

# 第一个失败后停止
pytest -x

# 显示最慢的N个测试
pytest --durations=10

1.4 pytest fixtures - 测试夹具

Fixtures是pytest最强大的特性之一,用于为测试提供数据或资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import pytest
from typing import Generator


# 基本fixture
@pytest.fixture
def sample_data() -> dict:
"""返回示例数据"""
return {"name": "Alice", "age": 30}


def test_with_sample_data(sample_data: dict):
"""使用sample_data fixture"""
assert sample_data["name"] == "Alice"
assert sample_data["age"] == 30


# fixture的作用域
@pytest.fixture(scope="function") # 每个测试函数执行一次(默认)
def per_function():
return []

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

@pytest.fixture(scope="module") # 每个模块执行一次
def per_module():
return []

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


# 带清理的fixture(使用yield)
@pytest.fixture
def database_connection() -> Generator[dict, None, None]:
"""数据库连接fixture"""
# Setup
connection = {"connected": True, "host": "localhost"}
print("Opening database connection...")

yield connection # 提供给测试使用

# Teardown
print("Closing database connection...")
connection["connected"] = False


def test_database(database_connection: dict):
"""测试数据库连接"""
assert database_connection["connected"] is True


# fixture依赖其他fixture
@pytest.fixture
def user(database_connection: dict) -> dict:
"""依赖database_connection的用户fixture"""
return {"id": 1, "name": "Test User", "db": database_connection}


def test_user(user: dict):
"""测试用户fixture"""
assert user["db"]["connected"] is True

1.5 参数化测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import pytest


# 基本参数化
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add_parametrized(a: int, b: int, expected: int):
"""参数化测试:多组数据"""
assert add(a, b) == expected


# 多个参数装饰器(笛卡尔积)
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply_combinations(x: int, y: int):
"""测试所有x和y的组合"""
result = x * y
assert result == x * y # 会测试6种组合


# 带ID的参数化
@pytest.mark.parametrize("input_val, expected", [
pytest.param(1, 2, id="positive"),
pytest.param(-1, 0, id="negative"),
pytest.param(0, 1, id="zero"),
])
def test_increment(input_val: int, expected: int):
"""带ID的参数化测试"""
assert input_val + 1 == expected

1.6 测试标记(Markers)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import pytest


# 跳过测试
@pytest.mark.skip(reason="功能尚未实现")
def test_not_implemented():
pass


# 条件跳过
@pytest.mark.skipif(
condition=True, # 实际中可以是版本检查等
reason="在当前环境下不适用"
)
def test_conditional_skip():
pass


# 预期失败
@pytest.mark.xfail(reason="已知bug,待修复")
def test_known_bug():
assert False


# 自定义标记
@pytest.mark.slow
def test_slow_operation():
"""标记为慢测试"""
import time
time.sleep(1)
assert True


@pytest.mark.integration
def test_database_integration():
"""标记为集成测试"""
pass

pyproject.toml 中注册自定义标记:

1
2
3
4
5
6
[tool.pytest.ini_options]
markers = [
"slow: 标记为慢测试",
"integration: 集成测试",
"unit: 单元测试",
]

运行特定标记的测试:

1
2
3
4
5
6
7
8
# 运行慢测试
pytest -m slow

# 排除慢测试
pytest -m "not slow"

# 组合条件
pytest -m "unit and not slow"

1.7 pyproject.toml配置详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
[tool.pytest.ini_options]
# 测试目录
testpaths = ["tests"]

# 测试文件匹配模式
python_files = ["test_*.py", "*_test.py"]

# 测试类匹配模式
python_classes = ["Test*"]

# 测试函数匹配模式
python_functions = ["test_*"]

# 默认命令行参数
addopts = [
"--strict-markers", # 严格检查未注册的标记
"-n", "auto", # 自动并行(需要pytest-xdist)
"--dist=loadscope", # 按作用域分配测试到worker
"-ra", # 显示所有非通过测试的简短摘要
"--tb=short", # 简短的traceback
]

# 过滤警告
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning",
]

# 自定义标记注册
markers = [
"slow: 运行时间较长的测试",
"integration: 集成测试",
"unit: 单元测试",
"smoke: 冒烟测试",
]

# 最小pytest版本
minversion = "7.0"

# 日志配置
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"

1.8 并行测试(pytest-xdist)

1
2
3
4
5
6
7
8
9
10
11
# 使用所有CPU核心
pytest -n auto

# 指定worker数量
pytest -n 4

# 按文件分配
pytest -n auto --dist=loadfile

# 按作用域分配(推荐)
pytest -n auto --dist=loadscope

配置在 pyproject.toml 中:

1
2
3
4
5
[tool.pytest.ini_options]
addopts = [
"-n", "auto",
"--dist=loadscope",
]

二、coverage.py - 代码覆盖率工具

2.1 什么是coverage.py?

coverage.py 是Python的代码覆盖率测量工具,它可以监控你的程序,记录哪些代码被执行了,分析哪些代码可以被执行但没有被执行。

主要功能:

  • 行覆盖率:记录每一行代码是否被执行
  • 分支覆盖率:记录条件语句的所有分支是否都被测试
  • 多种报告格式:终端、HTML、XML、JSON等
  • 最小覆盖率阈值:可设置覆盖率门槛,低于门槛时失败

2.2 安装coverage.py

1
2
3
4
5
6
7
8
9
10
11
# 基本安装
pip install coverage

# 与pytest集成
pip install pytest-cov

# 使用uv(推荐)
uv add coverage pytest-cov --group dev

# 使用Poetry
poetry add coverage pytest-cov --group dev

2.3 基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 使用coverage运行测试
coverage run -m pytest

# 查看覆盖率报告(终端)
coverage report

# 查看哪些行没有被覆盖
coverage report --show-missing

# 生成HTML报告
coverage html

# 生成XML报告(用于CI/CD集成)
coverage xml

# 清除之前的覆盖率数据
coverage erase

# 合并多个覆盖率数据文件
coverage combine

2.4 使用pytest-cov

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 运行测试并收集覆盖率
pytest --cov=src tests/

# 显示缺失的行
pytest --cov=src --cov-report=term-missing tests/

# 生成HTML报告
pytest --cov=src --cov-report=html tests/

# 设置最小覆盖率阈值(低于则失败)
pytest --cov=src --cov-fail-under=85 tests/

# 多种报告格式
pytest --cov=src \
--cov-report=term-missing \
--cov-report=html \
--cov-report=xml \
tests/

2.5 pyproject.toml配置详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
[tool.coverage.run]
# 分支覆盖率
branch = true

# 指定要测量的源代码目录
source = ["src", "backend"]

# 忽略某些文件或目录
omit = [
"*/migrations/*",
"*/__pycache__/*",
"*/tests/*",
"*/.venv/*",
"*/venv/*",
"*_test.py",
"test_*.py",
"conftest.py",
"setup.py",
"manage.py",
"*/settings/*",
"*/celery.py",
"*/wsgi.py",
"*/asgi.py",
]

# 数据文件位置
data_file = ".coverage"

# 并行模式(用于多进程测试)
parallel = true


[tool.coverage.paths]
# 路径映射(用于合并不同机器上的覆盖率数据)
source = [
"src",
"*/src",
]


[tool.coverage.report]
# 最小覆盖率阈值(低于此值则报告失败)
fail_under = 85

# 跳过完全覆盖的文件
skip_covered = true

# 跳过空文件
skip_empty = true

# 显示缺失的行号
show_missing = true

# 精度(小数位数)
precision = 2

# 排除特定模式的代码行
exclude_lines = [
# 默认排除
"pragma: no cover",

# 调试代码
"def __repr__",
"def __str__",

# 防御性断言
"raise AssertionError",
"raise NotImplementedError",

# 非运行代码
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",

# 抽象方法
"@abstractmethod",

# 省略号(未实现)
"\\.\\.\\.",
"pass",
]

# 额外排除(添加到默认排除之上)
exclude_also = [
# 类型检查相关
"if TYPE_CHECKING:",
"@overload",

# 协议定义
"class .*\\bProtocol\\):",

# 无法到达的代码
"assert_never\\(",
]


[tool.coverage.html]
# HTML报告输出目录
directory = "htmlcov"

# 报告标题
title = "Coverage Report"

# 跳过覆盖的文件
skip_covered = false


[tool.coverage.xml]
# XML报告输出位置
output = "coverage.xml"


[tool.coverage.json]
# JSON报告输出位置
output = "coverage.json"

2.6 排除代码行不被覆盖

在代码中使用特殊注释排除特定行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def complex_function():
"""复杂函数示例"""

# 这行永远不会被测试到
if False: # pragma: no cover
print("This will never run")

# 调试代码
if DEBUG: # pragma: no cover
print("Debug mode")

return True


# 排除整个函数
def debug_function(): # pragma: no cover
"""仅用于调试的函数"""
pass


# 类型检查代码(运行时不执行)
from typing import TYPE_CHECKING

if TYPE_CHECKING: # 自动排除
from mymodule import MyClass

2.7 解读覆盖率报告

终端报告示例:

1
2
3
4
5
6
7
Name                      Stmts   Miss  Cover   Missing
-------------------------------------------------------
src/__init__.py 1 0 100%
src/calculator.py 25 3 88% 15-17
src/utils.py 42 12 71% 23-27, 45-51
-------------------------------------------------------
TOTAL 68 15 78%
列名 说明
Stmts 总语句数
Miss 未覆盖的语句数
Cover 覆盖率百分比
Missing 未覆盖的行号

分支覆盖率:

1
2
3
Name                      Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------------
src/calculator.py 25 3 12 2 85% 15-17, 8->10
  • Branch: 总分支数
  • BrPart: 部分覆盖的分支数
  • 8->10: 表示第8行到第10行的分支未被覆盖

2.8 在CI/CD中使用覆盖率

GitLab CI配置示例:

1
2
3
4
5
6
7
8
9
10
11
test:
stage: test
script:
- poetry install
- poetry run pytest --cov=src --cov-report=xml --cov-report=term
coverage: '/TOTAL.*?(\d+%)/' # 从输出中提取覆盖率
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml

三、pytest与coverage集成最佳实践

3.1 项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
myproject/
├── pyproject.toml
├── src/
│ └── mypackage/
│ ├── __init__.py
│ ├── calculator.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # pytest fixtures
│ ├── test_calculator.py
│ └── test_utils.py
└── htmlcov/ # 覆盖率HTML报告(gitignore)

3.2 conftest.py - 共享fixtures

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# tests/conftest.py
import pytest
from typing import Generator


@pytest.fixture(scope="session")
def test_config() -> dict:
"""测试配置(整个测试会话共享)"""
return {
"database": "test_db",
"debug": True,
}


@pytest.fixture
def sample_user() -> dict:
"""示例用户数据"""
return {
"id": 1,
"username": "testuser",
"email": "test@example.com",
}


@pytest.fixture
def temp_file(tmp_path) -> Generator[str, None, None]:
"""临时文件fixture"""
file_path = tmp_path / "test_file.txt"
file_path.write_text("test content")
yield str(file_path)
# 清理由pytest的tmp_path自动处理

3.3 完整的pyproject.toml配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# ============================================
# pytest 测试配置
# ============================================
[tool.pytest.ini_options]
# 测试目录
testpaths = ["tests"]

# Python文件模式
python_files = ["test_*.py", "*_test.py"]

# 默认参数
addopts = [
"--strict-markers",
"-ra",
"-q",
"--tb=short",
]

# 过滤警告
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning",
]

# 自定义标记
markers = [
"slow: 运行时间较长的测试",
"integration: 集成测试",
"unit: 单元测试",
]

# 日志级别
log_cli_level = "INFO"

# ============================================
# coverage 覆盖率配置
# ============================================
[tool.coverage.run]
branch = true
source = ["src"]
omit = [
"*/migrations/*",
"*/__pycache__/*",
"*/tests/*",
"*/.venv/*",
]

[tool.coverage.report]
fail_under = 85
skip_covered = true
skip_empty = true
show_missing = true
precision = 2
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
"@abstractmethod",
]

[tool.coverage.html]
directory = "htmlcov"
title = "Test Coverage Report"

3.4 运行测试的常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 运行所有测试
pytest

# 带覆盖率运行
pytest --cov=src --cov-report=term-missing

# 生成HTML覆盖率报告
pytest --cov=src --cov-report=html

# 只运行单元测试
pytest -m unit

# 排除慢测试
pytest -m "not slow"

# 并行运行测试
pytest -n auto

# 失败后停止
pytest -x

# 失败后进入调试器
pytest --pdb

# 只运行上次失败的测试
pytest --lf

# 先运行上次失败的测试
pytest --ff

# 使用testmon智能选择测试
pytest --testmon

四、VS Code集成配置

4.1 settings.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
// pytest配置
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"--cov=src",
"--cov-report=term-missing",
"-v"
],

// 测试发现
"python.testing.autoTestDiscoverOnSaveEnabled": true,

// 覆盖率显示
"coverage-gutters.coverageFileNames": [
"coverage.xml",
"lcov.info",
".coverage"
],
"coverage-gutters.showLineCoverage": true,
"coverage-gutters.showRulerCoverage": true
}

4.2 推荐扩展

  • Python Test Explorer - 测试发现和运行
  • Coverage Gutters - 在编辑器中显示覆盖率
  • Test Adapter Converter - 测试适配器

五、高级技巧

5.1 Mock和补丁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from unittest.mock import Mock, patch, MagicMock
import pytest


def get_user_from_api(user_id: int) -> dict:
"""从API获取用户(需要mock)"""
import requests
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()


def test_get_user_from_api():
"""使用mock测试API调用"""
with patch("requests.get") as mock_get:
# 设置mock返回值
mock_get.return_value.json.return_value = {"id": 1, "name": "Test"}

result = get_user_from_api(1)

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


# 使用pytest-mock
def test_with_mocker(mocker):
"""使用mocker fixture"""
mock_get = mocker.patch("requests.get")
mock_get.return_value.json.return_value = {"id": 1, "name": "Test"}

result = get_user_from_api(1)

assert result["id"] == 1

5.2 异步测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pytest
import asyncio


async def async_function() -> str:
"""异步函数"""
await asyncio.sleep(0.1)
return "done"


@pytest.mark.asyncio
async def test_async_function():
"""测试异步函数"""
result = await async_function()
assert result == "done"

5.3 数据库测试(Django示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import pytest
from django.contrib.auth import get_user_model

User = get_user_model()


@pytest.fixture
def user(db) -> User:
"""创建测试用户"""
return User.objects.create_user(
username="testuser",
email="test@example.com",
password="testpass123"
)


@pytest.mark.django_db
def test_user_creation(user):
"""测试用户创建"""
assert user.username == "testuser"
assert User.objects.count() == 1


@pytest.mark.django_db(transaction=True)
def test_with_transaction(user):
"""带事务的测试"""
user.delete()
assert User.objects.count() == 0

六、总结

工具 作用 主要配置节
pytest 测试框架 [tool.pytest.ini_options]
coverage 覆盖率测量 [tool.coverage.*]
pytest-cov 集成两者 命令行参数

核心建议:

  1. pytest 提供了现代化的测试体验,使用fixtures管理测试数据
  2. coverage.py 帮助你了解测试覆盖了多少代码
  3. 设置合理的覆盖率阈值(如85%),并在CI中强制执行
  4. 使用参数化测试减少重复代码
  5. 使用markers组织和选择测试

相关文章