Pytest框架详解
目录
02. Pytest框架详解
最强大的Python测试框架完全指南
Pytest简介
Pytest是Python最流行的测试框架,具有:
- 简洁的测试语法
- 强大的fixture系统
- 丰富的插件生态
- 详细的测试报告
- 参数化测试支持
为什么选择Pytest?
# unittest需要继承TestCase类
import unittest
class TestPlayer(unittest.TestCase):
def test_level_up(self):
player = Player('test')
player.gain_exp(1000)
self.assertEqual(player.level, 2)
# Pytest更简洁
def test_level_up():
player = Player('test')
player.gain_exp(1000)
assert player.level == 2
基础用法
1. 简单测试
# test_basic.py
def add(a, b):
return a + b
def test_add():
"""测试加法函数"""
assert add(1, 2) == 3
assert add(-1, 1) == 0
assert add(0, 0) == 0
def test_add_negative():
"""测试负数"""
assert add(-5, -3) == -8
运行测试:
pytest test_basic.py -v
2. 测试类组织
# test_player.py
class Player:
def __init__(self, name, level=1):
self.name = name
self.level = level
self.hp = 100
self.exp = 0
def gain_exp(self, amount):
self.exp += amount
while self.exp >= 1000:
self.level += 1
self.exp -= 1000
def take_damage(self, damage):
self.hp = max(0, self.hp - damage)
class TestPlayer:
"""玩家系统测试"""
def test_creation(self):
"""测试创建玩家"""
player = Player('hero', level=1)
assert player.name == 'hero'
assert player.level == 1
assert player.hp == 100
def test_gain_exp(self):
"""测试获得经验"""
player = Player('hero')
player.gain_exp(1500)
assert player.level == 2
assert player.exp == 500
def test_take_damage(self):
"""测试受伤"""
player = Player('hero')
player.take_damage(30)
assert player.hp == 70
player.take_damage(100)
assert player.hp == 0
Fixture详解
Fixture是Pytest最强大的功能,用于测试前的准备和测试后的清理。
1. 基础Fixture
import pytest
@pytest.fixture
def player():
"""创建测试玩家"""
print("\n创建玩家")
p = Player('test_player')
yield p # 返回给测试函数
print("\n清理玩家")
def test_player_level(player):
"""测试使用fixture"""
assert player.level == 1
player.gain_exp(1000)
assert player.level == 2
def test_player_hp(player):
"""每个测试都获得新的player"""
assert player.hp == 100
player.take_damage(50)
assert player.hp == 50
2. Fixture作用域
import pytest
# function级别 - 每个测试函数都执行(默认)
@pytest.fixture(scope='function')
def temp_player():
return Player('temp')
# class级别 - 每个测试类执行一次
@pytest.fixture(scope='class')
def shared_player():
return Player('shared')
# module级别 - 每个模块执行一次
@pytest.fixture(scope='module')
def db_connection():
print("\n连接数据库")
conn = connect_to_db()
yield conn
print("\n关闭数据库")
conn.close()
# session级别 - 整个测试会话执行一次
@pytest.fixture(scope='session')
def browser():
print("\n启动浏览器")
driver = webdriver.Chrome()
yield driver
print("\n关闭浏览器")
driver.quit()
3. Fixture依赖
import pytest
@pytest.fixture
def database():
"""数据库fixture"""
db = Database()
db.connect()
yield db
db.close()
@pytest.fixture
def player_repository(database):
"""依赖database的fixture"""
return PlayerRepository(database)
@pytest.fixture
def test_player(player_repository):
"""依赖player_repository的fixture"""
player = player_repository.create_player('test')
yield player
player_repository.delete_player(player.id)
def test_save_player(test_player, player_repository):
"""测试保存玩家"""
test_player.level = 10
player_repository.save(test_player)
loaded = player_repository.load(test_player.id)
assert loaded.level == 10
4. conftest.py - 共享Fixture
# conftest.py - 整个项目共享的fixture
import pytest
from selenium import webdriver
@pytest.fixture(scope='session')
def browser():
"""浏览器fixture"""
driver = webdriver.Chrome()
driver.implicitly_wait(10)
yield driver
driver.quit()
@pytest.fixture
def logged_in_player(browser):
"""已登录的玩家"""
browser.get('https://game.com/login')
browser.find_element('id', 'username').send_keys('test')
browser.find_element('id', 'password').send_keys('test123')
browser.find_element('id', 'login').click()
yield browser
# 测试后登出
browser.get('https://game.com/logout')
使用:
# test_game_features.py
def test_player_profile(logged_in_player):
"""测试玩家资料页"""
logged_in_player.get('https://game.com/profile')
assert 'Profile' in logged_in_player.title
def test_inventory(logged_in_player):
"""测试背包"""
logged_in_player.get('https://game.com/inventory')
items = logged_in_player.find_elements('class name', 'item')
assert len(items) >= 0
参数化测试
1. 基础参数化
import pytest
@pytest.mark.parametrize('exp,expected_level', [
(0, 1),
(500, 1),
(1000, 2),
(2500, 3),
(5000, 6),
])
def test_level_calculation(exp, expected_level):
"""测试等级计算"""
player = Player('test')
player.gain_exp(exp)
assert player.level == expected_level
运行结果:
test_player.py::test_level_calculation[0-1] PASSED
test_player.py::test_level_calculation[500-1] PASSED
test_player.py::test_level_calculation[1000-2] PASSED
test_player.py::test_level_calculation[2500-3] PASSED
test_player.py::test_level_calculation[5000-6] PASSED
2. 多参数组合
@pytest.mark.parametrize('username,password,expected', [
('valid_user', 'valid_pass', True),
('invalid_user', 'any_pass', False),
('valid_user', 'wrong_pass', False),
('', '', False),
])
def test_login(username, password, expected):
"""测试登录"""
result = login(username, password)
assert result == expected
3. 笛卡尔积参数化
@pytest.mark.parametrize('weapon', ['sword', 'bow', 'staff'])
@pytest.mark.parametrize('level', [1, 10, 50])
def test_damage_calculation(weapon, level):
"""测试不同武器和等级的伤害"""
player = Player('hero', level=level)
player.equip(weapon)
damage = player.calculate_damage()
assert damage > 0
# 将生成9个测试: 3武器 × 3等级
4. 使用pytest.param添加ID
@pytest.mark.parametrize('test_input,expected', [
pytest.param(100, 100, id='normal_hp'),
pytest.param(0, 0, id='zero_hp'),
pytest.param(-10, 0, id='negative_hp_clamped'),
])
def test_hp_values(test_input, expected):
"""测试HP值处理"""
player = Player('test')
player.hp = test_input
assert player.hp == expected
标记(Markers)
1. 内置标记
import pytest
@pytest.mark.skip(reason='功能未实现')
def test_new_feature():
"""跳过测试"""
pass
@pytest.mark.skipif(sys.version_info < (3, 8), reason='需要Python 3.8+')
def test_modern_feature():
"""条件跳过"""
pass
@pytest.mark.xfail(reason='已知的bug')
def test_known_bug():
"""预期失败"""
assert False
@pytest.mark.slow
def test_performance():
"""自定义标记"""
time.sleep(5)
assert True
运行特定标记的测试:
# 只运行slow标记的测试
pytest -m slow
# 排除slow标记
pytest -m "not slow"
# 组合条件
pytest -m "slow and important"
2. 自定义标记
# pytest.ini
[pytest]
markers =
smoke: 冒烟测试
regression: 回归测试
integration: 集成测试
ui: UI测试
# test_game.py
import pytest
@pytest.mark.smoke
def test_game_launch():
"""测试游戏启动"""
assert launch_game() == True
@pytest.mark.regression
@pytest.mark.ui
def test_main_menu():
"""测试主菜单"""
menu = get_main_menu()
assert menu.is_visible()
@pytest.mark.integration
def test_save_load():
"""测试存档加载"""
game = Game()
game.save()
game2 = Game()
game2.load()
assert game2.state == game.state
断言和异常
1. 高级断言
def test_assertions():
"""Pytest的智能断言"""
x = 5
y = 10
# 简单断言
assert x < y
# 带消息
assert x < y, f"x({x}) should be less than y({y})"
# 复杂条件
assert x > 0 and y > 0
# 集合
assert 'sword' in ['sword', 'shield', 'bow']
# 字典
player = {'name': 'hero', 'level': 10}
assert player['level'] == 10
def test_float_comparison():
"""浮点数比较"""
result = 0.1 + 0.2
assert result == pytest.approx(0.3) # 处理浮点精度
2. 异常测试
import pytest
def divide(a, b):
if b == 0:
raise ValueError('除数不能为0')
return a / b
def test_divide_by_zero():
"""测试除零异常"""
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert '除数不能为0' in str(exc_info.value)
def test_divide_by_zero_match():
"""使用match参数"""
with pytest.raises(ValueError, match=r'除数不能为0'):
divide(10, 0)
def test_no_exception():
"""测试不应该抛出异常"""
result = divide(10, 2)
assert result == 5
Mock和Patch
1. unittest.mock基础
from unittest.mock import Mock, patch
def test_api_call_with_mock():
"""使用Mock对象"""
# 创建Mock对象
mock_api = Mock()
mock_api.get_player_info.return_value = {
'name': 'hero',
'level': 10
}
# 测试
result = mock_api.get_player_info('player_id')
assert result['level'] == 10
# 验证调用
mock_api.get_player_info.assert_called_once_with('player_id')
def test_with_patch():
"""使用patch"""
with patch('game.api.get_player') as mock_get:
mock_get.return_value = Player('hero', level=5)
player = game.api.get_player('id123')
assert player.level == 5
mock_get.assert_called_once()
2. Patch装饰器
@patch('requests.get')
def test_api_request(mock_get):
"""测试API请求"""
# 配置mock返回值
mock_response = Mock()
mock_response.json.return_value = {'status': 'success'}
mock_response.status_code = 200
mock_get.return_value = mock_response
# 测试
response = make_api_request('https://api.game.com/status')
assert response['status'] == 'success'
@patch('game.database.save')
@patch('game.database.load')
def test_save_load(mock_load, mock_save):
"""测试保存和加载"""
player = Player('test')
save_player(player)
mock_save.assert_called_once()
测试报告
1. HTML报告
# 安装插件
pip install pytest-html
# 生成报告
pytest --html=report.html --self-contained-html
2. Allure报告
# 安装
pip install allure-pytest
# 运行并生成数据
pytest --alluredir=./allure-results
# 查看报告
allure serve ./allure-results
添加Allure装饰器:
import allure
@allure.feature('玩家系统')
@allure.story('等级系统')
@allure.severity(allure.severity_level.CRITICAL)
def test_level_up():
"""测试升级功能"""
with allure.step('创建1级玩家'):
player = Player('hero', level=1)
with allure.step('获得1000经验'):
player.gain_exp(1000)
with allure.step('验证升到2级'):
assert player.level == 2
3. 覆盖率报告
# 安装
pip install pytest-cov
# 运行测试并生成覆盖率
pytest --cov=game --cov-report=html
# 查看报告
open htmlcov/index.html
Pytest插件
常用插件
# 并行执行
pip install pytest-xdist
pytest -n 4 # 使用4个进程
# 重试失败测试
pip install pytest-rerunfailures
pytest --reruns 3 # 失败重试3次
# 超时控制
pip install pytest-timeout
pytest --timeout=300 # 每个测试最多5分钟
# 随机执行顺序
pip install pytest-randomly
pytest # 自动随机化测试顺序
# BDD支持
pip install pytest-bdd
# 异步测试
pip install pytest-asyncio
使用示例:
# test_with_plugins.py
import pytest
import time
@pytest.mark.timeout(5)
def test_quick_operation():
"""5秒超时"""
result = quick_function()
assert result == True
@pytest.mark.flaky(reruns=3)
def test_unstable_feature():
"""不稳定的测试,失败重试3次"""
assert random_api_call() == 'success'
@pytest.mark.asyncio
async def test_async_operation():
"""异步测试"""
result = await async_function()
assert result == True
实战示例
完整的游戏测试套件
# conftest.py
import pytest
from game import Game, Player, Database
@pytest.fixture(scope='session')
def db():
"""数据库连接"""
database = Database()
database.connect()
yield database
database.close()
@pytest.fixture
def game(db):
"""游戏实例"""
g = Game(db)
g.initialize()
yield g
g.cleanup()
@pytest.fixture
def player(game):
"""测试玩家"""
p = game.create_player('test_player')
yield p
game.remove_player(p.id)
# test_player_system.py
import pytest
class TestPlayerSystem:
"""玩家系统测试"""
def test_player_creation(self, player):
"""测试创建玩家"""
assert player.name == 'test_player'
assert player.level == 1
assert player.hp > 0
@pytest.mark.parametrize('exp,expected_level', [
(0, 1), (1000, 2), (5000, 6)
])
def test_leveling(self, player, exp, expected_level):
"""测试升级系统"""
player.gain_exp(exp)
assert player.level == expected_level
def test_combat(self, player, game):
"""测试战斗系统"""
enemy = game.spawn_enemy('goblin')
initial_hp = player.hp
player.attack(enemy)
assert enemy.hp < enemy.max_hp
enemy.attack(player)
assert player.hp < initial_hp
最佳实践
- 测试命名: 使用清晰的test_前缀和描述性名称
- 一个测试一个目的: 每个测试只验证一个功能点
- 使用fixture: 避免重复的setup代码
- 参数化: 用参数化测试覆盖多个场景
- 独立性: 测试之间不应该有依赖
- 快速反馈: 优先运行快速测试
- 清晰断言: 断言失败时应该容易理解原因
下一步
继续学习: