Day 8: 测试自动化
学习目标
完成今天的学习后,你将能够:
- 编写单元测试和集成测试
- 使用pytest高级功能
- 测量测试覆盖率
- 使用Mock和桩进行测试
技术原理
测试类型
- 单元测试:测试单个函数或类
- 集成测试:测试多个组件的交互
- 功能测试:测试完整功能
- 性能测试:测试系统性能
pytest框架
pytest是Python最流行的测试框架:
python
# test_example.py
def test_addition():
assert 1 + 1 == 2
def test_string():
assert "hello".upper() == "HELLO"测试覆盖率
使用pytest-cov测量测试覆盖率:
bash
pytest --cov=myproject --cov-report=html案例:完整测试套件
创建一个完整的测试套件,实现:
- 单元测试编写
- 集成测试
- 测试覆盖率报告
- Mock和桩的使用
应用场景
1. 代码质量保证
- 回归测试
- 代码审查
- 持续集成
2. 开发流程
- 测试驱动开发(TDD)
- 行为驱动开发(BDD)
- 重构支持
3. 部署验证
- 冒烟测试
- 验收测试
- 部署后验证
代码案例
案例1:pytest高级用法
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pytest高级用法示例
功能:演示pytest的各种功能
"""
import pytest
# 被测试的类
class Calculator:
"""计算器类"""
def add(self, a, b):
"""加法"""
return a + b
def subtract(self, a, b):
"""减法"""
return a - b
def multiply(self, a, b):
"""乘法"""
return a * b
def divide(self, a, b):
"""除法"""
if b == 0:
raise ValueError("除数不能为零")
return a / b
# fixture
@pytest.fixture
def calculator():
"""创建计算器实例"""
return Calculator()
# 基本测试
def test_add(calculator):
"""测试加法"""
assert calculator.add(1, 2) == 3
assert calculator.add(-1, 1) == 0
assert calculator.add(0, 0) == 0
def test_subtract(calculator):
"""测试减法"""
assert calculator.subtract(5, 3) == 2
assert calculator.subtract(3, 5) == -2
def test_multiply(calculator):
"""测试乘法"""
assert calculator.multiply(2, 3) == 6
assert calculator.multiply(-2, 3) == -6
def test_divide(calculator):
"""测试除法"""
assert calculator.divide(6, 2) == 3
assert calculator.divide(5, 2) == 2.5
def test_divide_by_zero(calculator):
"""测试除零异常"""
with pytest.raises(ValueError, match="除数不能为零"):
calculator.divide(1, 0)
# 参数化测试
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add_parametrize(calculator, a, b, expected):
"""参数化测试加法"""
assert calculator.add(a, b) == expected
# 标记测试
@pytest.mark.slow
def test_slow_operation():
"""慢速测试"""
import time
time.sleep(2)
assert True
@pytest.mark.skip(reason="功能尚未实现")
def test_future_feature():
"""跳过的测试"""
pass
@pytest.mark.skipif(
not pytest.importorskip("numpy"),
reason="需要numpy库"
)
def test_numpy_integration():
"""条件跳过的测试"""
import numpy as np
assert np.array([1, 2, 3]).sum() == 6
# 测试类
class TestCalculatorAdvanced:
"""高级测试类"""
def test_chaining(self, calculator):
"""测试链式操作"""
result = calculator.add(1, 2)
result = calculator.multiply(result, 3)
assert result == 9
def test_negative_numbers(self, calculator):
"""测试负数"""
assert calculator.add(-1, -2) == -3
assert calculator.multiply(-2, -3) == 6
# conftest.py示例
"""
# conftest.py
import pytest
@pytest.fixture(scope="session")
def db_connection():
"""数据库连接fixture"""
# 创建连接
conn = create_connection()
yield conn
# 清理
conn.close()
@pytest.fixture(autouse=True)
def setup_teardown():
"""自动执行的setup和teardown"""
# setup
print("\nSetup")
yield
# teardown
print("\nTeardown")
"""
if __name__ == "__main__":
pytest.main([__file__, '-v'])案例2:Mock和桩
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Mock和桩测试示例
功能:演示如何使用Mock进行测试
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
import requests
# 被测试的类
class WeatherService:
"""天气服务"""
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.weather.com"
def get_temperature(self, city):
"""获取城市温度"""
response = requests.get(
f"{self.base_url}/temperature",
params={'city': city, 'key': self.api_key}
)
data = response.json()
return data['temperature']
def get_forecast(self, city, days=3):
"""获取天气预报"""
response = requests.get(
f"{self.base_url}/forecast",
params={'city': city, 'days': days, 'key': self.api_key}
)
return response.json()
# 使用Mock测试
class TestWeatherService:
"""天气服务测试"""
def test_get_temperature_with_mock(self):
"""使用Mock测试获取温度"""
# 创建Mock对象
mock_response = Mock()
mock_response.json.return_value = {'temperature': 25}
# 使用patch替换requests.get
with patch('requests.get', return_value=mock_response) as mock_get:
service = WeatherService('test_key')
temp = service.get_temperature('Beijing')
# 验证结果
assert temp == 25
# 验证调用
mock_get.assert_called_once_with(
f"https://api.weather.com/temperature",
params={'city': 'Beijing', 'key': 'test_key'}
)
def test_get_forecast_with_mock(self):
"""使用Mock测试获取预报"""
mock_response = Mock()
mock_response.json.return_value = {
'forecast': [
{'day': 1, 'temp': 25},
{'day': 2, 'temp': 26},
{'day': 3, 'temp': 24},
]
}
with patch('requests.get', return_value=mock_response):
service = WeatherService('test_key')
forecast = service.get_forecast('Beijing', days=3)
assert len(forecast['forecast']) == 3
assert forecast['forecast'][0]['temp'] == 25
def test_get_temperature_error(self):
"""测试获取温度失败"""
with patch('requests.get', side_effect=requests.RequestException("网络错误")):
service = WeatherService('test_key')
with pytest.raises(requests.RequestException):
service.get_temperature('Beijing')
# 使用MagicMock
class TestWithMagicMock:
"""使用MagicMock测试"""
def test_magic_mock(self):
"""MagicMock示例"""
mock = MagicMock()
mock.__len__.return_value = 5
assert len(mock) == 5
mock.__len__.assert_called_once()
# 使用spec限制Mock
class TestWithSpec:
"""使用spec限制Mock"""
def test_spec(self):
"""spec示例"""
mock_service = Mock(spec=WeatherService)
# 可以调用存在的方法
mock_service.get_temperature.return_value = 25
assert mock_service.get_temperature('Beijing') == 25
# 调用不存在的方法会抛出AttributeError
with pytest.raises(AttributeError):
mock_service.non_existent_method()
if __name__ == "__main__":
pytest.main([__file__, '-v'])案例3:测试覆盖率
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试覆盖率示例
功能:演示如何测量和提高测试覆盖率
"""
# 被测试的模块
class StringUtils:
"""字符串工具类"""
@staticmethod
def reverse(s):
"""反转字符串"""
return s[::-1]
@staticmethod
def is_palindrome(s):
"""检查是否为回文"""
s = s.lower().replace(' ', '')
return s == s[::-1]
@staticmethod
def count_words(s):
"""统计单词数"""
if not s:
return 0
return len(s.split())
@staticmethod
def capitalize_words(s):
"""首字母大写"""
return ' '.join(word.capitalize() for word in s.split())
@staticmethod
def truncate(s, max_length, suffix='...'):
"""截断字符串"""
if len(s) <= max_length:
return s
return s[:max_length - len(suffix)] + suffix
# 测试用例
class TestStringUtils:
"""字符串工具测试"""
def test_reverse(self):
"""测试反转"""
assert StringUtils.reverse('hello') == 'olleh'
assert StringUtils.reverse('') == ''
assert StringUtils.reverse('a') == 'a'
def test_is_palindrome(self):
"""测试回文检查"""
assert StringUtils.is_palindrome('racecar') is True
assert StringUtils.is_palindrome('A man a plan a canal Panama') is True
assert StringUtils.is_palindrome('hello') is False
assert StringUtils.is_palindrome('') is True
def test_count_words(self):
"""测试单词统计"""
assert StringUtils.count_words('hello world') == 2
assert StringUtils.count_words('') == 0
assert StringUtils.count_words('single') == 1
assert StringUtils.count_words(' spaces ') == 1
def test_capitalize_words(self):
"""测试首字母大写"""
assert StringUtils.capitalize_words('hello world') == 'Hello World'
assert StringUtils.capitalize_words('python') == 'Python'
assert StringUtils.capitalize_words('') == ''
def test_truncate(self):
"""测试截断"""
assert StringUtils.truncate('Hello World', 5) == 'He...'
assert StringUtils.truncate('Hi', 5) == 'Hi'
assert StringUtils.truncate('Hello World', 5, '...') == 'He...'
# 运行覆盖率测试命令
"""
# 运行测试并生成覆盖率报告
pytest --cov=string_utils --cov-report=term-missing
# 生成HTML报告
pytest --cov=string_utils --cov-report=html
# 查看报告
open htmlcov/index.html
"""
if __name__ == "__main__":
pytest.main([__file__, '-v', '--cov=.', '--cov-report=term-missing'])课后练习
练习1:单元测试
- 为现有项目编写单元测试
- 达到80%以上的测试覆盖率
- 使用参数化测试减少重复代码
练习2:集成测试
- 编写数据库集成测试
- 编写API集成测试
- 使用fixtures管理测试数据
练习3:测试自动化
- 配置pytest.ini
- 创建conftest.py
- 设置测试标记和慢速测试
常见问题
Q1: 何时使用Mock?
A: 当测试依赖外部服务、数据库或复杂对象时使用Mock。
Q2: 测试覆盖率多少合适?
A: 一般建议80%以上,但更重要的是测试质量而非数量。
Q3: 如何测试私有方法?
A: 通常不测试私有方法,而是通过公共接口间接测试。
下一步学习
完成今天的学习后,建议你:
- 为项目编写完整的测试套件
- 配置测试覆盖率报告
- 将测试集成到CI流程
- 准备进入Day 9的学习:部署自动化
明天我们将学习如何自动化部署应用。