第22章 测试驱动开发

学习目标

完成本章学习后,读者应能够:

  1. 理解TDD方法论:掌握红-绿-重构循环与测试金字塔
  2. 精通pytest框架:灵活运用夹具、参数化、标记与插件
  3. 掌握unittest体系:使用标准库构建完整测试套件
  4. 实现Mock与桩代码:隔离外部依赖,编写独立可重复的单元测试
  5. 掌握集成测试:测试数据库、API与Web应用
  6. 运用性能测试:使用cProfile与pytest-benchmark进行性能分析
  7. 精通调试技术:使用pdb、日志与性能分析定位问题

22.1 测试驱动开发方法论

22.1.1 TDD核心循环

测试驱动开发(Test-Driven Development)遵循”红-绿-重构”循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────┐
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 🔴 RED │───▶│ 🟢 GREEN│───┐ │
│ │ 编写失败 │ │ 快速通过 │ │ │
│ │ 的测试 │ │ 的实现 │ │ │
│ └─────────┘ └─────────┘ │ │
│ ▲ │ │
│ │ ┌────────▼───┐ │
│ │ │ 🔵 REFACTOR│ │
│ └──────────────│ 重构优化 │◀─┘
│ └────────────┘
└─────────────────────────────────────────┘

22.1.2 测试金字塔

1
2
3
4
5
6
7
8
9
10
11
12
          ╱╲
╱ ╲ E2E测试(少量)
╱ E2E╲ - 完整用户流程
╱──────╲ - 执行慢、脆弱
╱ ╲
╱ 集成测试 ╲ 集成测试(适量)
╱────────────╲ - 组件交互
╱ ╲ - 数据库/API
╱ 单元测试 ╲ 单元测试(大量)
╱──────────────────╲ - 快速、独立
╱ ╲- 覆盖率高
╱──────────────────────╲

22.1.3 TDD实践示例

以开发一个购物车系统为例:

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
class ShoppingCart:
def __init__(self):
self._items: dict[str, dict] = {}

def add_item(self, name: str, price: float, quantity: int = 1):
if name in self._items:
self._items[name]["quantity"] += quantity
else:
self._items[name] = {"price": price, "quantity": quantity}

def remove_item(self, name: str, quantity: int | None = None):
if name not in self._items:
raise KeyError(f"商品 '{name}' 不在购物车中")
if quantity is None or quantity >= self._items[name]["quantity"]:
del self._items[name]
else:
self._items[name]["quantity"] -= quantity

def get_total(self) -> float:
return sum(item["price"] * item["quantity"] for item in self._items.values())

def get_item_count(self) -> int:
return sum(item["quantity"] for item in self._items.values())

def clear(self):
self._items.clear()

@property
def is_empty(self) -> bool:
return len(self._items) == 0

22.2 pytest框架

22.2.1 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
import pytest
from decimal import Decimal


class TestShoppingCart:
def test_add_item(self):
cart = ShoppingCart()
cart.add_item("Python书", 89.00, 2)
assert not cart.is_empty
assert cart.get_item_count() == 2

def test_add_existing_item(self):
cart = ShoppingCart()
cart.add_item("Python书", 89.00, 1)
cart.add_item("Python书", 89.00, 2)
assert cart.get_item_count() == 3

def test_remove_item(self):
cart = ShoppingCart()
cart.add_item("Python书", 89.00, 3)
cart.remove_item("Python书", 1)
assert cart.get_item_count() == 2

def test_remove_item_completely(self):
cart = ShoppingCart()
cart.add_item("Python书", 89.00, 1)
cart.remove_item("Python书")
assert cart.is_empty

def test_remove_nonexistent_item(self):
cart = ShoppingCart()
with pytest.raises(KeyError, match="不在购物车中"):
cart.remove_item("不存在")

def test_get_total(self):
cart = ShoppingCart()
cart.add_item("Python书", 89.00, 2)
cart.add_item("键盘", 399.00, 1)
assert cart.get_total() == pytest.approx(577.00)

def test_clear(self):
cart = ShoppingCart()
cart.add_item("Python书", 89.00)
cart.clear()
assert cart.is_empty

def test_empty_cart_total(self):
cart = ShoppingCart()
assert cart.get_total() == 0.0

22.2.2 夹具系统

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 pathlib import Path
import json
import tempfile


@pytest.fixture
def cart():
return ShoppingCart()


@pytest.fixture
def cart_with_items(cart):
cart.add_item("Python书", 89.00, 2)
cart.add_item("键盘", 399.00, 1)
return cart


@pytest.fixture
def temp_json_file():
data = {"items": {"Python书": {"price": 89.00, "quantity": 2}}}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(data, f)
filepath = f.name
yield Path(filepath)
Path(filepath).unlink(missing_ok=True)


class TestWithFixtures:
def test_empty_cart(self, cart):
assert cart.is_empty

def test_cart_with_items_total(self, cart_with_items):
assert cart_with_items.get_total() == pytest.approx(577.00)

def test_cart_with_items_count(self, cart_with_items):
assert cart_with_items.get_item_count() == 3

def test_json_file_load(self, temp_json_file):
data = json.loads(temp_json_file.read_text())
assert "items" in data
assert data["items"]["Python书"]["price"] == 89.00


@pytest.fixture(scope="session")
def db_connection():
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL
)
""")
conn.execute("INSERT INTO products (name, price) VALUES ('Python书', 89.00)")
conn.commit()
yield conn
conn.close()


def test_db_query(db_connection):
cursor = db_connection.execute("SELECT name, price FROM products")
row = cursor.fetchone()
assert row == ("Python书", 89.00)

22.2.3 参数化与标记

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
import pytest


@pytest.mark.parametrize("price, quantity, expected_total", [
(89.00, 1, 89.00),
(89.00, 2, 178.00),
(0.00, 5, 0.00),
(99.99, 3, 299.97),
])
def test_item_total(price, quantity, expected_total):
cart = ShoppingCart()
cart.add_item("商品", price, quantity)
assert cart.get_total() == pytest.approx(expected_total)


@pytest.mark.parametrize("items, expected_count, expected_total", [
([], 0, 0.0),
([("A", 10.0, 1)], 1, 10.0),
([("A", 10.0, 2), ("B", 20.0, 1)], 3, 40.0),
([("A", 100.0, 1), ("B", 200.0, 2), ("C", 50.0, 3)], 6, 650.0),
])
def test_cart_scenarios(items, expected_count, expected_total):
cart = ShoppingCart()
for name, price, qty in items:
cart.add_item(name, price, qty)
assert cart.get_item_count() == expected_count
assert cart.get_total() == pytest.approx(expected_total)


@pytest.mark.slow
def test_large_cart():
cart = ShoppingCart()
for i in range(10000):
cart.add_item(f"商品{i}", 10.0, 1)
assert cart.get_item_count() == 10000


@pytest.mark.skipif(
sys.platform == "win32",
reason="Unix-specific test",
)
def test_unix_feature():
pass


@pytest.mark.xfail(reason="Known bug: #123")
def test_known_bug():
assert 1 == 2

pytest配置文件 pytest.ini

1
2
3
4
5
6
7
8
9
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short --strict-markers
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks integration tests

22.3 Mock与桩代码

22.3.1 unittest.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
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
from unittest.mock import Mock, patch, MagicMock, call
import pytest


class EmailService:
def send_email(self, to: str, subject: str, body: str) -> bool:
import smtplib
server = smtplib.SMTP("smtp.example.com", 587)
server.sendmail("noreply@example.com", to, f"Subject: {subject}\n\n{body}")
server.quit()
return True


class UserService:
def __init__(self, email_service: EmailService):
self.email_service = email_service
self.users: dict[str, dict] = {}

def register(self, username: str, email: str) -> dict:
if username in self.users:
raise ValueError(f"用户 '{username}' 已存在")
user = {"username": username, "email": email}
self.users[username] = user
self.email_service.send_email(
to=email,
subject="欢迎注册",
body=f"你好 {username},欢迎加入!",
)
return user


class TestUserServiceWithMock:
def test_register_success(self):
mock_email = Mock()
mock_email.send_email.return_value = True

service = UserService(mock_email)
user = service.register("alice", "alice@example.com")

assert user["username"] == "alice"
assert user["email"] == "alice@example.com"
mock_email.send_email.assert_called_once_with(
to="alice@example.com",
subject="欢迎注册",
body="你好 alice,欢迎加入!",
)

def test_register_duplicate_user(self):
mock_email = Mock()
service = UserService(mock_email)
service.register("alice", "alice@example.com")

with pytest.raises(ValueError, match="已存在"):
service.register("alice", "alice2@example.com")

mock_email.send_email.assert_called_once()

def test_register_email_failure(self):
mock_email = Mock()
mock_email.send_email.side_effect = ConnectionError("SMTP连接失败")

service = UserService(mock_email)
with pytest.raises(ConnectionError):
service.register("alice", "alice@example.com")


class TestWithPatch:
@patch("smtplib.SMTP")
def test_send_email(self, mock_smtp_class):
mock_instance = MagicMock()
mock_smtp_class.return_value = mock_instance

service = EmailService()
result = service.send_email("test@example.com", "测试", "内容")

assert result is True
mock_smtp_class.assert_called_once_with("smtp.example.com", 587)
mock_instance.sendmail.assert_called_once()
mock_instance.quit.assert_called_once()

@patch("builtins.open", create=True)
def test_file_read(self, mock_open):
mock_open.return_value.__enter__.return_value.read.return_value = "test content"
with open("test.txt") as f:
content = f.read()
assert content == "test content"


class TestCallAssertions:
def test_multiple_calls(self):
mock = Mock()
mock(1)
mock(2)
mock(3)

assert mock.call_count == 3
assert mock.call_args_list == [call(1), call(2), call(3)]
mock.assert_has_calls([call(1), call(3)], any_order=True)

22.3.2 pytest-mock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def test_with_mocker(mocker):
mock_requests = mocker.patch("requests.get")
mock_requests.return_value.json.return_value = {"status": "ok"}

import requests
response = requests.get("https://api.example.com/status")
assert response.json() == {"status": "ok"}

mock_requests.assert_called_once_with("https://api.example.com/status")


def test_spy_with_mocker(mocker):
original_list = [1, 2, 3]
spy = mocker.spy(original_list, "append")

original_list.append(4)

spy.assert_called_once_with(4)
assert original_list == [1, 2, 3, 4]

22.4 集成测试

22.4.1 Flask应用测试

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
import pytest
from flask import Flask, jsonify, request


def create_app():
app = Flask(__name__)

@app.route("/api/users", methods=["GET"])
def get_users():
return jsonify({"users": [{"id": 1, "name": "Alice"}]})

@app.route("/api/users", methods=["POST"])
def create_user():
data = request.get_json()
if not data or "name" not in data:
return jsonify({"error": "name is required"}), 400
return jsonify({"id": 2, "name": data["name"]}), 201

return app


@pytest.fixture
def app():
app = create_app()
app.config["TESTING"] = True
return app


@pytest.fixture
def client(app):
return app.test_client()


class TestUserAPI:
def test_get_users(self, client):
response = client.get("/api/users")
assert response.status_code == 200
data = response.get_json()
assert "users" in data
assert len(data["users"]) > 0

def test_create_user(self, client):
response = client.post(
"/api/users",
json={"name": "Bob"},
content_type="application/json",
)
assert response.status_code == 201
data = response.get_json()
assert data["name"] == "Bob"

def test_create_user_missing_name(self, client):
response = client.post(
"/api/users",
json={},
content_type="application/json",
)
assert response.status_code == 400

22.4.2 数据库集成测试

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
import pytest
import sqlite3
from contextlib import contextmanager


class UserRepository:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn

def create_table(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL
)
""")
self.conn.commit()

def add_user(self, username: str, email: str) -> int:
cursor = self.conn.execute(
"INSERT INTO users (username, email) VALUES (?, ?)",
(username, email),
)
self.conn.commit()
return cursor.lastrowid

def get_user(self, user_id: int) -> dict | None:
cursor = self.conn.execute(
"SELECT id, username, email FROM users WHERE id = ?", (user_id,),
)
row = cursor.fetchone()
if row:
return {"id": row[0], "username": row[1], "email": row[2]}
return None

def get_all_users(self) -> list[dict]:
cursor = self.conn.execute("SELECT id, username, email FROM users")
return [{"id": r[0], "username": r[1], "email": r[2]} for r in cursor.fetchall()]


@pytest.fixture
def db_conn():
conn = sqlite3.connect(":memory:")
conn.row_factory = None
yield conn
conn.close()


@pytest.fixture
def user_repo(db_conn):
repo = UserRepository(db_conn)
repo.create_table()
return repo


class TestUserRepository:
def test_add_user(self, user_repo):
user_id = user_repo.add_user("alice", "alice@example.com")
assert user_id == 1

def test_get_user(self, user_repo):
user_id = user_repo.add_user("alice", "alice@example.com")
user = user_repo.get_user(user_id)
assert user is not None
assert user["username"] == "alice"
assert user["email"] == "alice@example.com"

def test_get_nonexistent_user(self, user_repo):
user = user_repo.get_user(999)
assert user is None

def test_get_all_users(self, user_repo):
user_repo.add_user("alice", "alice@example.com")
user_repo.add_user("bob", "bob@example.com")
users = user_repo.get_all_users()
assert len(users) == 2
assert users[0]["username"] == "alice"
assert users[1]["username"] == "bob"

def test_unique_username_constraint(self, user_repo):
user_repo.add_user("alice", "alice@example.com")
with pytest.raises(sqlite3.IntegrityError):
user_repo.add_user("alice", "alice2@example.com")

22.5 性能测试

22.5.1 pytest-benchmark

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
def fibonacci_recursive(n: int) -> int:
if n <= 1:
return n
return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)


def fibonacci_memo(n: int, cache: dict = None) -> int:
if cache is None:
cache = {0: 0, 1: 1}
if n not in cache:
cache[n] = fibonacci_memo(n - 1, cache) + fibonacci_memo(n - 2, cache)
return cache[n]


def fibonacci_iterative(n: int) -> int:
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b


def test_fibonacci_correctness():
assert fibonacci_iterative(10) == 55
assert fibonacci_memo(10) == 55


def test_fibonacci_benchmark(benchmark):
benchmark(fibonacci_iterative, 30)


def test_fibonacci_compare(benchmark):
benchmark(fibonacci_memo, 30)

22.5.2 cProfile分析

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
import cProfile
import pstats
import io


def profile_function(func, *args, **kwargs):
profiler = cProfile.Profile()
profiler.enable()
result = func(*args, **kwargs)
profiler.disable()

stream = io.StringIO()
stats = pstats.Stats(profiler, stream=stream)
stats.sort_stats("cumulative")
stats.print_stats(20)

print(stream.getvalue())
return result


def analyze_data():
data = list(range(100000))
_ = [x ** 2 for x in data]
_ = sum(data)
_ = sorted(data, reverse=True)


if __name__ == "__main__":
profile_function(analyze_data)

22.6 调试技术

22.6.1 pdb调试器

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
import pdb


def calculate_discount(price: float, discount_rate: float, min_price: float = 0.0) -> float:
discounted = price * (1 - discount_rate)
result = max(discounted, min_price)
return result


def process_order(order: dict) -> dict:
subtotal = 0.0
for item in order["items"]:
price = item["price"]
qty = item["quantity"]
line_total = price * qty
subtotal += line_total

discount_rate = order.get("discount", 0.0)
total = calculate_discount(subtotal, discount_rate)
return {"subtotal": subtotal, "total": total}


order = {
"items": [
{"name": "Python书", "price": 89.00, "quantity": 2},
{"name": "键盘", "price": 399.00, "quantity": 1},
],
"discount": 0.1,
}

result = process_order(order)
print(result)

pdb常用命令:

命令缩写说明
nextn执行下一行(不进入函数)
steps单步执行(进入函数)
continuec继续执行到下一个断点
breakb设置断点
printp打印变量值
pp美化打印
listl显示源代码
wherew显示调用栈
up/downu/d在调用栈中上下移动
quitq退出调试

22.6.2 结构化日志

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
import logging
import logging.config
from functools import wraps
from datetime import datetime


LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"detailed": {
"format": "%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"simple": {
"format": "[%(levelname)s] %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "simple",
"stream": "ext://sys.stdout",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"level": "DEBUG",
"formatter": "detailed",
"filename": "app.log",
"maxBytes": 10485760,
"backupCount": 5,
},
},
"loggers": {
"app": {
"level": "DEBUG",
"handlers": ["console", "file"],
"propagate": False,
},
},
"root": {
"level": "WARNING",
"handlers": ["console"],
},
}

logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger("app")


def log_execution(func):
@wraps(func)
def wrapper(*args, **kwargs):
func_name = func.__qualname__
logger.debug(f"→ {func_name} args={args[1:]} kwargs={kwargs}")
start = datetime.now()
try:
result = func(*args, **kwargs)
elapsed = (datetime.now() - start).total_seconds()
logger.debug(f"← {func_name} returned in {elapsed:.4f}s")
return result
except Exception as e:
elapsed = (datetime.now() - start).total_seconds()
logger.error(f"✗ {func_name} failed in {elapsed:.4f}s: {e}")
raise
return wrapper


@log_execution
def process_payment(amount: float, currency: str = "CNY") -> dict:
if amount <= 0:
raise ValueError(f"无效金额: {amount}")
logger.info(f"处理支付: {amount} {currency}")
return {"status": "success", "amount": amount, "currency": currency}

22.7 测试覆盖率

22.7.1 coverage.py

1
2
pip install pytest-cov
pytest --cov=src --cov-report=html --cov-report=term-missing
1
2
3
4
5
6
7
8
9
10
11
12
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/migrations/*"]

[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
"raise NotImplementedError",
"pass",
]
fail_under = 80

22.7.2 覆盖率实践

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
class Calculator:
def add(self, a: float, b: float) -> float:
return a + b

def divide(self, a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("除数不能为零")
return a / b

def factorial(self, n: int) -> int:
if n < 0:
raise ValueError("负数没有阶乘")
if n <= 1:
return 1
return n * self.factorial(n - 1)


class TestCalculator:
def test_add(self):
calc = Calculator()
assert calc.add(1, 2) == 3

def test_divide(self):
calc = Calculator()
assert calc.divide(6, 3) == 2.0

def test_divide_by_zero(self):
calc = Calculator()
with pytest.raises(ZeroDivisionError):
calc.divide(1, 0)

def test_factorial(self):
calc = Calculator()
assert calc.factorial(5) == 120
assert calc.factorial(0) == 1

def test_factorial_negative(self):
calc = Calculator()
with pytest.raises(ValueError):
calc.factorial(-1)

22.8 前沿技术动态

22.8.1 现代测试工具

  • pytest-asyncio:异步代码测试
  • hypothesis:基于属性的测试(Property-Based Testing)
  • mutmut:变异测试(Mutation Testing)
  • pytest-cov:覆盖率集成
  • allure-pytest:测试报告生成

22.8.2 基于属性的测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from hypothesis import given, strategies as st, settings


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


@given(st.integers(min_value=0, max_value=20))
def test_factorial_positive(n):
result = Calculator().factorial(n)
assert result >= 1


@given(st.integers(min_value=1, max_value=100), st.integers(min_value=1, max_value=100))
def test_divide_multiplication_inverse(a, b):
calc = Calculator()
assert calc.divide(a * b, b) == pytest.approx(float(a))

22.9 本章小结

本章系统阐述了测试驱动开发的核心知识体系:

  1. TDD方法论:红-绿-重构循环与测试金字塔
  2. pytest框架:夹具系统、参数化、标记与配置
  3. Mock与桩代码:unittest.mock、pytest-mock与依赖隔离
  4. 集成测试:Flask应用测试与数据库测试
  5. 性能测试:pytest-benchmark与cProfile分析
  6. 调试技术:pdb调试器与结构化日志
  7. 测试覆盖率:coverage.py与覆盖率最佳实践
  8. 前沿工具:基于属性的测试与变异测试

22.10 习题与项目练习

基础题

  1. 使用TDD方式开发一个Stack类,要求先写测试再写实现,覆盖push、pop、peek、is_empty、size等方法。

  2. 为一个StringCalculator类编写pytest测试,要求支持:空字符串返回0、单个数字、逗号分隔、换行分隔、自定义分隔符。

  3. 使用Mock对象测试一个发送HTTP请求的函数,不实际发送网络请求。

进阶题

  1. 为Flask应用编写完整的测试套件,包含单元测试、集成测试和API端点测试,使用夹具管理测试数据库。

  2. 使用hypothesis编写基于属性的测试,验证排序算法的正确性(幂等性、稳定性、长度保持)。

  3. 实现一个日志装饰器,支持函数调用追踪、异常捕获和性能计时,编写测试验证其行为。

综合项目

  1. 电商系统测试套件:为一个电商系统编写完整的测试套件,包含:

    • 用户注册/登录的单元测试
    • 购物车逻辑的参数化测试
    • 订单处理的集成测试(含数据库)
    • 支付服务的Mock测试
    • API端点的端到端测试
    • 覆盖率目标 ≥ 90%
  2. CI/CD测试流水线:配置一个完整的测试流水线,包含:

    • pytest配置与conftest.py
    • 单元测试/集成测试/端到端测试分层
    • 覆盖率报告与阈值检查
    • GitHub Actions工作流配置
    • 测试报告生成(Allure)

思考题

  1. 在TDD中,如何决定测试的粒度?过度测试与测试不足各有什么问题?请结合测试金字塔理论分析。

  2. Mock对象在测试中可能带来哪些问题?如何避免”Mock过度”导致的测试脆弱性?请讨论何时应该使用真实依赖而非Mock。

22.11 延伸阅读

22.11.1 测试理论

  • 《Test Driven Development》 (Kent Beck) — TDD经典著作
  • 《xUnit Test Patterns》 (Gerard Meszaros) — 单元测试模式
  • 《Growing Object-Oriented Software, Guided by Tests》 — 测试驱动开发实践

22.11.2 pytest生态

22.11.3 测试工具

22.11.4 调试与日志


下一章:第23章 版本控制与协作