Python代码质量与测试
1. 代码质量标准
高质量的代码不仅仅是功能正确,还应该具有可读性、可维护性和可扩展性。Python社区有一系列的代码质量标准和工具来帮助开发者编写更好的代码。
1.1 PEP 8 - Python代码风格指南
PEP 8是Python官方的代码风格指南,它提供了编写Python代码的规范。
mindmap
root((PEP 8))
代码布局
缩进使用4个空格
行长度最大79字符
顶层函数和类定义用两个空行分隔
类中的方法定义用一个空行分隔
导入
导入应该分行书写
导入顺序:标准库、相关第三方库、本地应用/库
导入应该在文件顶部
空格
运算符两侧加空格
逗号、冒号、分号之后加空格
括号内部不加空格
命名约定
类名使用驼峰命名法
函数和变量名使用小写字母和下划线
常量使用大写字母和下划线
私有属性以单下划线开头
"私有"属性以双下划线开头
注释
注释应该是完整的句子
块注释每行以#和一个空格开始
行内注释应该至少用两个空格和代码分开
其他建议
使用空行分隔逻辑段落
避免无意义的空行
避免在一行中写多条语句
1.2 代码复杂度
代码复杂度是衡量代码质量的重要指标,包括:
- 圈复杂度(Cyclomatic Complexity): 衡量代码分支的复杂程度
- 认知复杂度(Cognitive Complexity): 衡量代码理解难度
- 维护指数(Maintainability Index): 综合衡量代码可维护性
# 高复杂度示例
def complex_function(a, b, c, d, e):
result = 0
if a > 0:
if b > 0:
if c > 0:
result = a + b + c
else:
result = a + b - c
elif d > 0:
if e > 0:
result = a + d + e
else:
result = a + d - e
else:
result = a
else:
if d > 0:
result = d
else:
result = 0
return result
# 优化后的低复杂度示例
def calculate_result(a, b, c):
return a + b + c if c > 0 else a + b - c
def improved_function(a, b, c, d, e):
if a <= 0:
return d if d > 0 else 0
if b > 0:
return calculate_result(a, b, c)
elif d > 0:
return calculate_result(a, d, e)
else:
return a
1.3 代码异味
代码异味是指代码中可能存在问题的迹象,常见的代码异味包括:
graph TD
A[代码异味] --> B[重复代码]
A --> C[过长函数]
A --> D[过大类]
A --> E[过长参数列表]
A --> F[数据泥团]
A --> G[基本类型偏执]
A --> H[Switch语句]
A --> I[临时字段]
A --> J[拒绝继承]
A --> K[注释过多]
style A fill:#d0e0ff,stroke:#333,stroke-width:2px
style B fill:#ffe0d0,stroke:#333,stroke-width:2px
style C fill:#d0ffe0,stroke:#333,stroke-width:2px
style D fill:#ffd0e0,stroke:#333,stroke-width:2px
style E fill:#e0d0ff,stroke:#333,stroke-width:2px
2. 代码质量工具
2.1 静态代码分析工具
静态代码分析工具可以在不运行代码的情况下检查代码质量问题。
Pylint
Pylint是一个全面的Python代码分析工具,可以检查代码风格、错误和潜在问题。
# 安装Pylint
pip install pylint
# 使用Pylint分析代码
pylint your_module.py
Pylint配置示例(.pylintrc):
[MASTER]
# 忽略特定文件或目录
ignore=CVS,venv,migrations
[MESSAGES CONTROL]
# 禁用特定警告
disable=C0111,R0903
[FORMAT]
# 每行最大字符数
max-line-length=100
[REPORTS]
# 输出格式
output-format=text
Flake8
Flake8是一个Python代码检查工具,结合了PyFlakes、pycodestyle和McCabe复杂度检查。
# 安装Flake8
pip install flake8
# 使用Flake8分析代码
flake8 your_module.py
Flake8配置示例(.flake8):
[flake8]
max-line-length = 100
exclude = .git,__pycache__,build,dist
ignore = E203, W503
Mypy
Mypy是Python的静态类型检查器,可以检查类型注解的正确性。
# 安装Mypy
pip install mypy
# 使用Mypy检查代码
mypy your_module.py
Mypy配置示例(mypy.ini):
[mypy]
python_version = 3.9
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
[mypy.plugins.django-stubs]
django_settings_module = "myproject.settings"
2.2 代码格式化工具
Black
Black是一个不妥协的Python代码格式化工具,它会自动格式化代码以符合特定的风格。
# 安装Black
pip install black
# 使用Black格式化代码
black your_module.py
Black配置示例(pyproject.toml):
[tool.black]
line-length = 88
target-version = ['py38']
include = '\.pyi?$'
exclude = '''
/(
\.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
)/
'''
isort
isort是一个Python导入语句排序工具,可以自动对导入语句进行分组和排序。
# 安装isort
pip install isort
# 使用isort排序导入语句
isort your_module.py
isort配置示例(pyproject.toml):
[tool.isort]
profile = "black"
line_length = 88
multi_line_output = 3
include_trailing_comma = true
2.3 代码覆盖率工具
Coverage.py
Coverage.py是一个用于测量Python代码覆盖率的工具,可以帮助识别未被测试覆盖的代码。
# 安装Coverage.py
pip install coverage
# 使用Coverage.py运行测试
coverage run -m unittest discover
# 生成覆盖率报告
coverage report
coverage html # 生成HTML报告
Coverage.py配置示例(.coveragerc):
[run]
source = mypackage
omit = */tests/*,*/migrations/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
pie title "代码覆盖率示例"
"已覆盖" : 75
"未覆盖" : 25
3. 测试策略
3.1 测试金字塔
测试金字塔是一种测试策略,它建议在不同级别进行不同数量的测试。
pie title 测试金字塔
"UI测试" : 10
"集成测试" : 30
"单元测试" : 60
3.2 单元测试
单元测试是测试单个组件或函数的测试,通常是测试金字塔的基础。
unittest
unittest是Python标准库中的单元测试框架。
import unittest
def add(a, b):
return a + b
class TestAddFunction(unittest.TestCase):
def test_add_positive_numbers(self):
self.assertEqual(add(1, 2), 3)
def test_add_negative_numbers(self):
self.assertEqual(add(-1, -2), -3)
def test_add_mixed_numbers(self):
self.assertEqual(add(-1, 2), 1)
if __name__ == "__main__":
unittest.main()
pytest
pytest是一个更现代化的Python测试框架,提供了更简洁的语法和更强大的功能。
# test_add.py
def add(a, b):
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
# 运行pytest测试
pytest test_add.py
pytest高级功能:
import pytest
# 参数化测试
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(-1, -2, -3),
(-1, 2, 1),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
# 夹具(fixtures)
@pytest.fixture
def sample_data():
return [1, 2, 3, 4, 5]
def test_sum_with_fixture(sample_data):
assert sum(sample_data) == 15
# 标记测试
@pytest.mark.slow
def test_slow_operation():
# 一个耗时的测试
pass
3.3 集成测试
集成测试是测试多个组件协同工作的测试,通常位于测试金字塔的中间层。
import unittest
from unittest.mock import patch
from myapp.database import Database
from myapp.user_service import UserService
class TestUserService(unittest.TestCase):
def setUp(self):
self.db = Database(":memory:") # 使用内存数据库
self.db.create_tables()
self.user_service = UserService(self.db)
def tearDown(self):
self.db.close()
def test_create_user(self):
user_id = self.user_service.create_user("test_user", "password123")
user = self.db.get_user_by_id(user_id)
self.assertEqual(user["username"], "test_user")
@patch("myapp.email_service.send_email")
def test_password_reset(self, mock_send_email):
user_id = self.user_service.create_user("test_user", "password123")
self.user_service.request_password_reset("test_user")
mock_send_email.assert_called_once()
3.4 端到端测试
端到端测试是测试整个应用程序从前端到后端的测试,通常位于测试金字塔的顶层。
from selenium import webdriver
from selenium.webdriver.common.by import By
import unittest
class TestLoginPage(unittest.TestCase):
def setUp(self):
self.driver = webdriver.Chrome()
self.driver.get("http://localhost:8000/login")
def tearDown(self):
self.driver.quit()
def test_successful_login(self):
# 输入用户名和密码
username_input = self.driver.find_element(By.ID, "username")
password_input = self.driver.find_element(By.ID, "password")
submit_button = self.driver.find_element(By.ID, "submit")
username_input.send_keys("test_user")
password_input.send_keys("password123")
submit_button.click()
# 验证登录成功
welcome_message = self.driver.find_element(By.ID, "welcome-message")
self.assertIn("Welcome, test_user", welcome_message.text)
3.5 测试驱动开发(TDD)
测试驱动开发是一种开发方法,它要求在编写功能代码之前先编写测试代码。
graph LR
A[编写测试] --> B[运行测试]
B --> C{测试通过?}
C -->|否| D[编写代码]
D --> B
C -->|是| E[重构代码]
E --> B
style A fill:#d0e0ff,stroke:#333,stroke-width:2px
style B fill:#ffe0d0,stroke:#333,stroke-width:2px
style C fill:#ffd0e0,stroke:#333,stroke-width:2px
style D fill:#d0ffe0,stroke:#333,stroke-width:2px
style E fill:#e0d0ff,stroke:#333,stroke-width:2px
TDD示例:
# 步骤1:编写测试
def test_calculate_discount():
# 测试常规折扣
assert calculate_discount(100, 10) == 90
# 测试最小消费
assert calculate_discount(50, 10) == 50
# 测试最大折扣
assert calculate_discount(1000, 30) == 800
# 步骤2:运行测试(会失败,因为函数还不存在)
# 步骤3:编写代码
def calculate_discount(amount, discount_percent):
if amount < 100: # 最小消费
return amount
discount = amount * discount_percent / 100
if discount > 200: # 最大折扣
discount = 200
return amount - discount
# 步骤4:运行测试(应该通过)
# 步骤5:重构代码(如果需要)
def calculate_discount(amount, discount_percent):
# 检查最小消费
if amount < 100:
return amount
# 计算折扣金额
discount = min(amount * discount_percent / 100, 200)
return amount - discount
3.6 行为驱动开发(BDD)
行为驱动开发是一种开发方法,它使用自然语言描述系统行为,然后将这些描述转换为自动化测试。
behave
behave是Python的BDD框架,它使用Gherkin语言描述行为。
# features/discount.feature
Feature: Product Discount
As a customer
I want to receive discounts on my purchases
So that I can save money
Scenario: Apply regular discount
Given the product price is 100
When a 10% discount is applied
Then the final price should be 90
Scenario: Minimum purchase for discount
Given the product price is 50
When a 10% discount is applied
Then the final price should be 50
Scenario: Maximum discount limit
Given the product price is 1000
When a 30% discount is applied
Then the final price should be 800
# features/steps/discount_steps.py
from behave import given, when, then
from myapp.discount import calculate_discount
@given("the product price is {price:d}")
def step_given_price(context, price):
context.price = price
@when("a {discount:d}% discount is applied")
def step_when_discount_applied(context, discount):
context.discount = discount
context.final_price = calculate_discount(context.price, context.discount)
@then("the final price should be {expected:d}")
def step_then_final_price(context, expected):
assert context.final_price == expected
4. 测试最佳实践
4.1 测试隔离
每个测试应该是独立的,不依赖于其他测试的状态。
# 不好的做法:测试之间有依赖
def test_create_user():
user_id = user_service.create_user("test_user", "password123")
assert user_id is not None
# 全局变量,会影响其他测试
global created_user_id
created_user_id = user_id
def test_get_user():
# 依赖于前一个测试创建的用户
user = user_service.get_user_by_id(created_user_id)
assert user["username"] == "test_user"
# 好的做法:测试隔离
def test_create_user():
user_id = user_service.create_user("test_user", "password123")
assert user_id is not None
return user_id
def test_get_user():
# 在测试内部创建所需的状态
user_id = user_service.create_user("another_user", "password456")
user = user_service.get_user_by_id(user_id)
assert user["username"] == "another_user"
4.2 测试夹具(Fixtures)
测试夹具是测试前后执行的代码,用于设置和清理测试环境。
# unittest中的夹具
class TestUserService(unittest.TestCase):
def setUp(self):
# 在每个测试方法前执行
self.db = Database(":memory:")
self.db.create_tables()
self.user_service = UserService(self.db)
def tearDown(self):
# 在每个测试方法后执行
self.db.close()
@classmethod
def setUpClass(cls):
# 在所有测试方法前执行一次
cls.global_resource = create_expensive_resource()
@classmethod
def tearDownClass(cls):
# 在所有测试方法后执行一次
cls.global_resource.cleanup()
# pytest中的夹具
import pytest
@pytest.fixture
def db():
# 设置
db = Database(":memory:")
db.create_tables()
yield db # 返回夹具值
# 清理
db.close()
@pytest.fixture
def user_service(db):
return UserService(db)
def test_create_user(user_service):
user_id = user_service.create_user("test_user", "password123")
assert user_id is not None
4.3 模拟(Mocking)
模拟是在测试中替换真实对象的技术,用于隔离被测试的代码。
from unittest.mock import Mock, patch
# 使用Mock对象
def test_process_payment():
# 创建支付网关的模拟对象
mock_gateway = Mock()
mock_gateway.process_payment.return_value = {"status": "success", "transaction_id": "123"}
# 使用模拟对象
payment_service = PaymentService(gateway=mock_gateway)
result = payment_service.process_payment(100, "4111111111111111")
# 验证结果
assert result["status"] == "success"
# 验证模拟对象的方法被调用
mock_gateway.process_payment.assert_called_once()
# 使用patch装饰器
@patch("myapp.email_service.send_email")
def test_password_reset(mock_send_email):
user_service = UserService()
user_service.create_user("test_user", "password123")
user_service.request_password_reset("test_user")
# 验证发送邮件的函数被调用
mock_send_email.assert_called_once()
4.4 参数化测试
参数化测试允许使用不同的输入值运行相同的测试代码。
# unittest中的参数化测试
class TestMathFunctions(unittest.TestCase):
def test_add(self):
test_cases = [
(1, 2, 3),
(-1, -2, -3),
(-1, 2, 1),
]
for a, b, expected in test_cases:
with self.subTest(a=a, b=b):
self.assertEqual(add(a, b), expected)
# pytest中的参数化测试
import pytest
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(-1, -2, -3),
(-1, 2, 1),
])
def test_add(a, b, expected):
assert add(a, b) == expected
4.5 测试边界条件
测试应该包括边界条件和异常情况。
def test_divide():
# 正常情况
assert divide(10, 2) == 5
# 边界条件
assert divide(0, 5) == 0
# 异常情况
with pytest.raises(ValueError):
divide(10, 0)
5. 持续集成与测试自动化
5.1 持续集成工作流
sequenceDiagram
participant D as 开发者
participant G as Git仓库
participant CI as CI服务器
participant T as 测试环境
D->>G: 提交代码
G->>CI: 触发CI流程
CI->>CI: 运行代码检查
CI->>CI: 运行单元测试
CI->>CI: 运行集成测试
CI->>CI: 构建应用
CI->>T: 部署到测试环境
CI->>CI: 运行端到端测试
CI->>D: 发送结果通知
5.2 GitHub Actions配置
# .github/workflows/python-tests.yml
name: Python Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, 3.10]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest pytest-cov
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Test with pytest
run: |
pytest --cov=mypackage tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
5.3 预提交钩子
预提交钩子可以在代码提交前自动运行代码检查和测试。
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
hooks:
- id: flake8
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.0.1
hooks:
- id: mypy
additional_dependencies: [types-requests]
6. 练习:代码质量与测试实践
6.1 代码质量改进
以下是一个需要改进的函数:
def process_data(d):
r = []
for i in range(len(d)):
if d[i] % 2 == 0:
r.append(d[i] * 2)
else:
r.append(d[i] + 1)
return r
改进后的代码:
def process_data(data: list[int]) -> list[int]:
"""
处理整数列表,对偶数乘以2,对奇数加1
Args:
data: 要处理的整数列表
Returns:
处理后的整数列表
"""
result = []
for item in data:
if item % 2 == 0:
result.append(item * 2)
else:
result.append(item + 1)
return result
# 更简洁的实现(使用列表推导式)
def process_data_concise(data: list[int]) -> list[int]:
"""
处理整数列表,对偶数乘以2,对奇数加1
Args:
data: 要处理的整数列表
Returns:
处理后的整数列表
"""
return [item * 2 if item % 2 == 0 else item + 1 for item in data]
6.2 编写单元测试
为上面的函数编写单元测试:
import unittest
class TestProcessData(unittest.TestCase):
def test_empty_list(self):
"""测试空列表"""
self.assertEqual(process_data([]), [])
def test_even_numbers(self):
"""测试偶数列表"""
self.assertEqual(process_data([2, 4, 6]), [4, 8, 12])
def test_odd_numbers(self):
"""测试奇数列表"""
self.assertEqual(process_data([1, 3, 5]), [2, 4, 6])
def test_mixed_numbers(self):
"""测试混合列表"""
self.assertEqual(process_data([1, 2, 3, 4]), [2, 4, 4, 8])
# 使用pytest
def test_process_data_empty():
assert process_data([]) == []
def test_process_data_even():
assert process_data([2, 4, 6]) == [4, 8, 12]
def test_process_data_odd():
assert process_data([1, 3, 5]) == [2, 4, 6]
def test_process_data_mixed():
assert process_data([1, 2, 3, 4]) == [2, 4, 4, 8]
6.3 使用TDD开发新功能
使用TDD方法开发一个计算购物车总价的功能:
# 步骤1:编写测试
def test_calculate_cart_total():
# 测试空购物车
assert calculate_cart_total([]) == 0
# 测试单个商品
assert calculate_cart_total([{"price": 10, "quantity": 2}]) == 20
# 测试多个商品
cart = [
{"price": 10, "quantity": 2},
{"price": 5, "quantity": 3}
]
assert calculate_cart_total(cart) == 35
# 测试折扣
cart_with_discount = [
{"price": 10, "quantity": 2, "discount": 0.1},
{"price": 5, "quantity": 3}
]
assert calculate_cart_total(cart_with_discount) == 33
# 步骤2:实现功能
def calculate_cart_total(cart):
"""
计算购物车中所有商品的总价
Args:
cart: 购物车商品列表,每个商品包含价格和数量
Returns:
float: 购物车总价
"""
total = 0
for item in cart:
price = item["price"]
quantity = item["quantity"]
# 计算商品小计
subtotal = price * quantity
# 应用折扣(如果有)
if "discount" in item:
subtotal *= (1 - item["discount"])
total += subtotal
return total
7. 总结
本章介绍了Python代码质量与测试的最佳实践,包括:
- 代码质量标准,如PEP 8、代码复杂度和代码异味
- 代码质量工具,包括静态代码分析工具、代码格式化工具和代码覆盖率工具
- 测试策略,包括测试金字塔、单元测试、集成测试、端到端测试、TDD和BDD
- 测试最佳实践,包括测试隔离、测试夹具、模拟、参数化测试和测试边界条件
- 持续集成与测试自动化,包括CI工作流、GitHub Actions配置和预提交钩子
- 通过实践练习改进代码质量和编写测试
通过遵循这些最佳实践,您可以提高代码质量,减少bug,并确保软件的可靠性和可维护性。