Pytest及相关测试工具实战指南
一个完整的例子,手把手教你从零开始使用Pytest,Pytest-cov,Pylint,flake8。
例子:银行账户系统
编写测试 -> 检查覆盖率 -> 做静态分析 -> 代码风格检查
第一部分:Pytest入门 - 从零到熟练
1.1 什么是Pytest?
Pytest是Python最流行的测试框架,比unittest更简洁强大。
安装:
conda activate mybookwise conda install pytest pytest-cov1.2 创建第一个项目
步骤1:创建项目文件夹
# 创建一个新的学习项目 mkdir learn-testing cd learn-testing # 创建代码文件夹 mkdir bank touch bank/__init__.py touch bank/account.py步骤2:编写银行账户类
用编辑器打开bank/account.py,写入:
""" 银行账户模块 """ class BankAccount: """银行账户类""" def __init__(self, account_holder: str, initial_balance: float = 0.0): """初始化账户""" if initial_balance < 0: raise ValueError("初始余额不能为负数") self.account_holder = account_holder self._balance = initial_balance self.transaction_history = [] @property def balance(self) -> float: """获取余额(只读属性)""" return self._balance def deposit(self, amount: float) -> float: """存款""" if amount <= 0: raise ValueError("存款金额必须大于0") self._balance += amount self.transaction_history.append(f"存款: +{amount}") return self._balance def withdraw(self, amount: float) -> float: """取款""" if amount <= 0: raise ValueError("取款金额必须大于0") if amount > self._balance: raise ValueError("余额不足") self._balance -= amount self.transaction_history.append(f"取款: -{amount}") return self._balance def transfer(self, target_account: 'BankAccount', amount: float) -> tuple: """转账到另一个账户""" if amount <= 0: raise ValueError("转账金额必须大于0") if amount > self._balance: raise ValueError("余额不足,无法转账") # 先从当前账户扣款 self.withdraw(amount) # 再存到目标账户 target_account.deposit(amount) self.transaction_history.append(f"转账给{target_account.account_holder}: -{amount}") target_account.transaction_history.append(f"{self.account_holder}的转账: +{amount}") return self._balance, target_account.balance1.3 编写第一个测试
步骤1:创建测试文件
mkdir tests touch tests/__init__.py touch tests/test_account.py步骤2:编写基础测试
用编辑器打开tests/test_account.py,写入:
""" 银行账户的单元测试 """ import pytest from bank.account import BankAccount # 测试类,类名要以Test开头 class TestBankAccount: """测试BankAccount类""" def test_account_creation(self): """测试账户创建""" # 1. 准备数据 account = BankAccount("张三", 100.0) # 2. 执行操作 # 这里创建账户就是操作 # 3. 验证结果 assert account.account_holder == "张三" assert account.balance == 100.0 assert len(account.transaction_history) == 0 def test_deposit(self): """测试存款""" # 准备 account = BankAccount("李四", 50.0) # 执行 new_balance = account.deposit(30.0) # 验证 assert new_balance == 80.0 assert account.balance == 80.0 assert len(account.transaction_history) == 1 assert "存款: +30.0" in account.transaction_history[0] def test_withdraw_success(self): """测试成功取款""" account = BankAccount("王五", 100.0) new_balance = account.withdraw(40.0) assert new_balance == 60.0 assert "取款: -40.0" in account.transaction_history[0] def test_withdraw_insufficient_balance(self): """测试余额不足取款""" account = BankAccount("赵六", 50.0) # 验证会抛出异常 with pytest.raises(ValueError, match="余额不足"): account.withdraw(100.0) def test_withdraw_zero_or_negative(self): """测试取款0或负数""" account = BankAccount("钱七", 100.0) with pytest.raises(ValueError, match="取款金额必须大于0"): account.withdraw(0) with pytest.raises(ValueError, match="取款金额必须大于0"): account.withdraw(-10.0)步骤3:运行测试
# 进入项目根目录 cd learn-testing # 运行所有测试 pytest # 运行指定测试文件 pytest tests/test_account.py # 运行指定测试类 pytest tests/test_account.py::TestBankAccount # 运行指定测试方法 pytest tests/test_account.py::TestBankAccount::test_deposit # 详细模式 pytest -v # 显示print输出 pytest -s第二部分:Pytest高级功能
2.1 使用夹具(Fixture)
夹具是Pytest最强大的功能之一,用于准备测试数据。
修改test_account.py,添加夹具:
# 在import下面添加 import pytest from bank.account import BankAccount # 定义一个夹具 @pytest.fixture def empty_account(): """返回一个余额为0的账户""" return BankAccount("测试用户", 0.0) @pytest.fixture def funded_account(): """返回一个有1000元的账户""" return BankAccount("测试用户", 1000.0) # 在测试类中使用夹具 class TestBankAccountWithFixtures: """使用夹具的测试""" def test_deposit_with_fixture(self, empty_account): """测试存款(使用夹具)""" new_balance = empty_account.deposit(500.0) assert new_balance == 500.0 def test_withdraw_with_fixture(self, funded_account): """测试取款(使用夹具)""" new_balance = funded_account.withdraw(300.0) assert new_balance == 700.02.2 参数化测试
一次测试多组数据,减少重复代码。
# 参数化测试示例 import pytest class TestParameterized: """参数化测试示例""" # 测试存款的不同金额 @pytest.mark.parametrize("initial, deposit_amount, expected", [ (0, 100, 100), # 从0存100 (50, 30, 80), # 从50存30 (100, 0.01, 100.01), # 存小数 ]) def test_deposit_amounts(self, initial, deposit_amount, expected): """测试不同金额的存款""" account = BankAccount("测试", initial) new_balance = account.deposit(deposit_amount) assert new_balance == expected # 测试边界值 @pytest.mark.parametrize("withdraw_amount, should_succeed", [ (1, True), # 最小取款1元 (500, True), # 刚好取一半 (999, True), # 取999 (1000, True), # 刚好取完 (1001, False), # 多取1元,应该失败 (2000, False), # 多取很多 ]) def test_withdraw_boundaries(self, withdraw_amount, should_succeed): """测试取款的边界值""" account = BankAccount("测试", 1000.0) if should_succeed: new_balance = account.withdraw(withdraw_amount) assert new_balance == 1000.0 - withdraw_amount else: with pytest.raises(ValueError, match="余额不足"): account.withdraw(withdraw_amount)2.3 测试标记(Mark)
用于分类测试,可以只运行部分测试。
# 标记测试 import pytest class TestWithMarks: """使用标记的测试""" @pytest.mark.slow def test_slow_operation(self): """标记为慢测试""" import time time.sleep(0.5) # 模拟慢操作 assert True @pytest.mark.fast def test_fast_operation(self): """标记为快测试""" assert True @pytest.mark.skip(reason="功能还没实现") def test_skip_this(self): """跳过这个测试""" assert False @pytest.mark.skipif(True, reason="条件跳过") def test_skip_if(self): """条件跳过""" assert False @pytest.mark.xfail def test_expected_to_fail(self): """预期会失败的测试""" assert False # 这里预期会失败运行特定标记的测试:
# 只运行标记为fast的测试 pytest -m fast # 运行标记为slow的测试 pytest -m slow # 运行除slow外的所有测试 pytest -m "not slow" # 查看所有标记 pytest --markers第三部分:测试覆盖率(pytest-cov)
3.1 什么是测试覆盖率?
覆盖率衡量你的测试覆盖了多少代码,包括:
行覆盖率:测试执行了多少行代码
分支覆盖率:测试覆盖了多少分支(if/else)
函数覆盖率:测试覆盖了多少函数
3.2 检查覆盖率
# 基本覆盖率检查 pytest --cov=bank # 详细报告(显示哪些行没覆盖) pytest --cov=bank --cov-report=term-missing # 生成HTML报告 pytest --cov=bank --cov-report=html # 设置最低覆盖率要求 pytest --cov=bank --cov-fail-under=80 # 只计算特定文件 pytest --cov=bank.account运行后打开htmlcov/index.html,可以看到:
总体覆盖率百分比
每个文件的覆盖率
点击文件名查看哪些行没被覆盖(红色是没覆盖的)
3.3 提高覆盖率
假设我们的覆盖率报告显示account.py的__init__方法中负数检查没被测试:
# 添加测试来覆盖这个分支 def test_negative_initial_balance(): """测试负数初始余额应该抛异常""" with pytest.raises(ValueError, match="初始余额不能为负数"): BankAccount("测试", -100.0)第四部分:静态代码分析(pylint)
4.1 什么是静态代码分析?
不运行代码,只分析代码结构,发现潜在问题:
代码风格问题
潜在的bug
代码复杂度
重复代码
4.2 使用pylint
# 分析整个bank模块 pylint bank # 只显示错误 pylint -E bank # 生成详细报告 pylint --reports=y bank # 设置分数阈值 pylint --fail-under=8.0 bank4.3 修复pylint问题
常见问题1:缺少文档字符串
# 修复前 def some_function(): pass # 修复后 def some_function(): """函数的文档字符串""" pass常见问题2:变量命名不规范
# 修复前 def MyFunction(): # 函数名应该小写 MyVar = 1 # 变量名应该小写 # 修复后 def my_function(): my_var = 1常见问题3:行太长
# 修复前 long_line = "这是一个非常非常非常非常非常非常非常非常非常非常非常非常长的字符串" # 修复后 long_line = ( "这是一个非常非常非常非常非常非常非常" "非常非常非常非常非常长的字符串" )常见问题4:过于复杂的函数
# 修复前 def complex_function(x): if x > 0: if x < 10: if x % 2 == 0: return True return False # 修复后 def is_small_positive_even(x): """检查是否是小正偶数""" if not (0 < x < 10): return False return x % 2 == 0第五部分:代码风格检查(flake8)
5.1 什么是flake8?
检查代码是否符合PEP8(Python官方代码风格指南)。
5.2 使用flake8
# 检查整个项目 flake8 . # 只检查bank模块 flake8 bank # 显示统计信息 flake8 --statistics bank # 忽略特定错误 flake8 --ignore=E501 bank # 忽略行太长错误5.3 常见flake8错误
E501 行太长
# 修复前(超过79字符) result = some_really_long_function_name(with_many_parameters, and_another_one, plus_one_more) # 修复后 result = some_really_long_function_name( with_many_parameters, and_another_one, plus_one_more )E302 期望2个空行
# 修复前 import os def function1(): pass def function2(): # 这里应该有两个空行 pass # 修复后 import os def function1(): pass def function2(): passW291 尾部空格
# 修复前 some_code = 1 # 这里有个空格 # 修复后 some_code = 1第六部分:完整实战项目
6.1 扩展银行账户系统
让我们扩展bank/account.py,添加更多功能:
# 添加信用账户类 class CreditAccount(BankAccount): """信用账户,可以透支""" def __init__(self, account_holder: str, credit_limit: float = 1000.0): """初始化信用账户""" if credit_limit < 0: raise ValueError("信用额度不能为负数") super().__init__(account_holder, 0.0) self.credit_limit = credit_limit self.used_credit = 0.0 @property def available_credit(self) -> float: """可用信用额度""" return self.credit_limit - self.used_credit @property def total_available(self) -> float: """总额度(余额+可用信用)""" return self._balance + self.available_credit def withdraw(self, amount: float) -> float: """取款(可以透支)""" if amount <= 0: raise ValueError("取款金额必须大于0") # 如果需要透支 if amount > self._balance: credit_needed = amount - self._balance if credit_needed > self.available_credit: raise ValueError(f"额度不足,可用额度: {self.total_available}") # 使用信用 self.used_credit += credit_needed # 余额设为0 self._balance = 0 else: # 不需要透支 self._balance -= amount self.transaction_history.append(f"取款: -{amount}") return self._balance def repay_credit(self, amount: float) -> tuple: """还款""" if amount <= 0: raise ValueError("还款金额必须大于0") if self.used_credit == 0: raise ValueError("当前无欠款") # 实际还款金额(不能超过欠款) actual_repayment = min(amount, self.used_credit) self.used_credit -= actual_repayment # 如果有剩余,存入余额 remaining = amount - actual_repayment if remaining > 0: self._balance += remaining self.transaction_history.append(f"还款: -{actual_repayment}") if remaining > 0: self.transaction_history.append(f"剩余转存: +{remaining}") return self.used_credit, self._balance6.2 编写全面的测试
创建tests/test_credit_account.py:
""" 信用账户的全面测试 """ import pytest from bank.account import CreditAccount class TestCreditAccount: """测试信用账户""" @pytest.fixture def credit_account(self): """返回一个新的信用账户""" return CreditAccount("测试用户", credit_limit=1000.0) def test_credit_account_creation(self, credit_account): """测试信用账户创建""" assert credit_account.account_holder == "测试用户" assert credit_account.credit_limit == 1000.0 assert credit_account.used_credit == 0.0 assert credit_account.available_credit == 1000.0 assert credit_account.total_available == 1000.0 def test_deposit_to_credit_account(self, credit_account): """测试信用账户存款""" # 先存500 credit_account.deposit(500.0) assert credit_account.balance == 500.0 assert credit_account.total_available == 1500.0 # 500余额 + 1000信用 def test_withdraw_with_credit(self, credit_account): """测试使用信用取款""" # 取1200(超过余额,使用信用) credit_account.withdraw(1200.0) # 余额为0,用了200信用 assert credit_account.balance == 0.0 assert credit_account.used_credit == 200.0 assert credit_account.available_credit == 800.0 def test_withdraw_exceed_limit(self, credit_account): """测试超过总额度取款""" with pytest.raises(ValueError, match="额度不足"): credit_account.withdraw(1500.0) # 超过1000的限额 def test_repay_credit(self, credit_account): """测试还款""" # 先取800(使用信用) credit_account.withdraw(800.0) assert credit_account.used_credit == 800.0 # 还款300 remaining_credit, new_balance = credit_account.repay_credit(300.0) assert remaining_credit == 500.0 assert new_balance == 0.0 def test_repay_more_than_owed(self, credit_account): """测试超额还款""" # 先取500 credit_account.withdraw(500.0) # 还700(多还200) remaining_credit, new_balance = credit_account.repay_credit(700.0) # 欠款为0,余额有200 assert remaining_credit == 0.0 assert new_balance == 200.0 def test_repay_zero(self, credit_account): """测试还款0元""" with pytest.raises(ValueError, match="还款金额必须大于0"): credit_account.repay_credit(0) def test_repay_when_no_debt(self, credit_account): """测试无欠款时还款""" with pytest.raises(ValueError, match="当前无欠款"): credit_account.repay_credit(100.0) # 参数化测试 @pytest.mark.parametrize("withdraw_amount, expected_credit_used, should_succeed", [ (500, 500, True), # 用500信用 (1000, 1000, True), # 用1000信用 (1001, 0, False), # 超过限额 (0, 0, False), # 取0元 (-100, 0, False), # 取负数 ]) def test_withdraw_scenarios(self, credit_account, withdraw_amount, expected_credit_used, should_succeed): """测试各种取款场景""" if should_succeed: credit_account.withdraw(withdraw_amount) assert credit_account.used_credit == expected_credit_used else: with pytest.raises(ValueError): credit_account.withdraw(withdraw_amount)6.3 运行完整测试套件
# 1. 运行所有测试 pytest # 2. 检查覆盖率 pytest --cov=bank --cov-report=term-missing # 3. 生成HTML覆盖率报告 pytest --cov=bank --cov-report=html # 4. 运行静态分析 pylint bank # 5. 检查代码风格 flake8 bank tests第七部分:创建配置文件
7.1 创建pytest.ini
在项目根目录创建pytest.ini:
[pytest] testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* addopts = -v --cov=bank --cov-report=term-missing --cov-report=html --cov-fail-under=807.2 创建.pylintrc
[MASTER] ignore=tests jobs=1 [MESSAGES CONTROL] disable= C0103, # 允许非snake_case的变量名 R0903, # 允许类有少量公共方法 C0114, # 缺少模块文档字符串 C0115, # 缺少类文档字符串 C0116, # 缺少函数文档字符串 R0913, # 允许函数有多个参数 R0914 # 允许函数有多个局部变量 [FORMAT] max-line-length=1207.3 创建.flake8
[flake8] max-line-length=120 exclude=.git,__pycache__,venv ignore=E203,W503第八部分:完整工作流程
8.1 开发一个功能的完整流程
假设我们要添加计算利息功能:
步骤1:编写功能代码
# 在account.py中添加 class SavingsAccount(BankAccount): """储蓄账户,有利息""" def __init__(self, account_holder: str, interest_rate: float = 0.03): super().__init__(account_holder, 0.0) self.interest_rate = interest_rate def add_interest(self) -> float: """计算并添加利息""" interest = self._balance * self.interest_rate self._balance += interest self.transaction_history.append(f"利息: +{interest:.2f}") return interest步骤2:编写测试
# 在tests/下创建test_savings_account.py import pytest from bank.account import SavingsAccount def test_savings_account_interest(): """测试利息计算""" account = SavingsAccount("测试", interest_rate=0.1) # 10%利率 account.deposit(1000.0) interest = account.add_interest() assert interest == 100.0 # 1000 * 10% assert account.balance == 1100.0步骤3:运行测试
pytest tests/test_savings_account.py -v步骤4:检查覆盖率
pytest --cov=bank --cov-report=term-missing步骤5:静态分析
pylint bank/account.py步骤6:代码风格检查
flake8 bank/account.py8.2 创建自动化脚本
创建run_tests.sh(Linux/Mac)或run_tests.bat(Windows):
run_tests.bat(Windows):
@echo off echo 开始运行完整测试套件... echo. echo 1. 运行单元测试... pytest if %errorlevel% neq 0 ( echo 测试失败! exit /b 1 ) echo. echo 2. 检查覆盖率... pytest --cov=bank --cov-fail-under=85 if %errorlevel% neq 0 ( echo 覆盖率不足85%% exit /b 1 ) echo. echo 3. 静态代码分析... pylint bank --fail-under=8.0 if %errorlevel% neq 0 ( echo 代码质量需改进 ) echo. echo 4. 代码风格检查... flake8 bank tests if %errorlevel% neq 0 ( echo 代码风格需改进 ) echo. echo 所有检查完成! pause运行:
./run_tests.bat第九部分:实际应用到你的项目
现在你学会了基本知识,应用到你的支付信用项目:
9.1 你的测试结构应该是:
bookstore/ ├── signals.py # 你的业务代码 └── tests/ ├── __init__.py ├── conftest.py # 共享夹具 ├── unit/ │ ├── __init__.py │ ├── test_signals_core.py # 测试纯函数版本 │ └── test_signals_django.py # 测试Django版本 └── integration/ # 集成测试 └── __init__.py9.2 测试你的支付函数示例:
# tests/unit/test_signals_core.py import pytest from decimal import Decimal from bookstore.signals import calculate_credit_level_core, process_payment_core def test_credit_level(): """测试信用等级计算""" result = calculate_credit_level_core(Decimal('1500')) assert result == 2 def test_payment(): """测试支付""" from bookstore.signals import TestCustomer customer = TestCustomer(1, "test", Decimal('100'), 3, Decimal('0')) success, _, _, updated = process_payment_core( customer, Decimal('50'), use_credit=True ) assert success is True9.3 运行测试命令:
# 在项目根目录运行 pytest bookstore/tests/unit/ -v pytest --cov=bookstore --cov-report=html pylint bookstore flake8 bookstore总结
Pytest基础:编写和运行测试
夹具使用:创建可重用的测试数据
参数化测试:一次测试多组数据
测试覆盖率:衡量测试完整性
静态分析:提高代码质量
代码风格:遵循PEP8规范
