Python单元测试与测试驱动开发:从入门到实践
引言
测试驱动开发(TDD)是一种软件开发方法论,强调在编写实际代码之前先编写测试。Python提供了强大的测试框架支持,使得TDD成为一种高效的开发方式。
本文将深入探讨Python单元测试的核心概念,并分享TDD的最佳实践。
一、单元测试基础
1.1 使用unittest模块
import unittest class TestMathOperations(unittest.TestCase): def setUp(self): """测试前置条件""" self.base = 10 def tearDown(self): """测试清理工作""" pass def test_addition(self): """测试加法运算""" result = self.base + 5 self.assertEqual(result, 15) def test_subtraction(self): """测试减法运算""" result = self.base - 3 self.assertTrue(result == 7) def test_multiplication(self): """测试乘法运算""" result = self.base * 2 self.assertNotEqual(result, 19) if __name__ == '__main__': unittest.main()1.2 使用pytest框架
import pytest def add(a, b): return a + b def test_add_positive_numbers(): assert add(2, 3) == 5 def test_add_negative_numbers(): assert add(-1, -1) == -2 def test_add_with_zero(): assert add(0, 0) == 0 @pytest.mark.parametrize("a, b, expected", [ (1, 2, 3), (-1, 1, 0), (0, 0, 0), (10, 20, 30), ]) def test_add_multiple_cases(a, b, expected): assert add(a, b) == expected二、测试驱动开发流程
2.1 TDD三步法
# 步骤1: 编写失败的测试 def test_calculate_discount(): """测试折扣计算""" price = 100 discount = 0.2 expected = 80 assert calculate_discount(price, discount) == expected # 步骤2: 编写最小代码使测试通过 def calculate_discount(price, discount): return price * (1 - discount) # 步骤3: 重构代码 def calculate_discount(price: float, discount: float) -> float: """计算折扣后价格""" if discount < 0 or discount > 1: raise ValueError("折扣必须在0到1之间") return price * (1 - discount)2.2 TDD实战示例
# 需求:实现一个栈数据结构 class TestStack: def test_stack_is_empty_initially(self): stack = Stack() assert stack.is_empty() def test_push_adds_element(self): stack = Stack() stack.push(1) assert not stack.is_empty() def test_pop_returns_last_element(self): stack = Stack() stack.push(1) stack.push(2) assert stack.pop() == 2 def test_pop_from_empty_stack_raises_error(self): stack = Stack() with pytest.raises(IndexError): stack.pop() # 实现栈 class Stack: def __init__(self): self.items = [] def is_empty(self): return len(self.items) == 0 def push(self, item): self.items.append(item) def pop(self): if self.is_empty(): raise IndexError("Cannot pop from empty stack") return self.items.pop()三、高级测试技术
3.1 Mock对象
from unittest.mock import Mock, patch def test_api_call(mocker): # 创建mock对象 mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"data": "test"} # 替换requests.get with patch('requests.get', return_value=mock_response): result = fetch_data('https://api.example.com') assert result == {"data": "test"} requests.get.assert_called_once_with('https://api.example.com')3.2 Fixture机制
import pytest @pytest.fixture def database_connection(): """创建数据库连接fixture""" conn = create_connection() yield conn conn.close() @pytest.fixture def test_user(): """创建测试用户fixture""" user = User(name="Test", email="test@example.com") return user def test_user_creation(database_connection, test_user): """测试用户创建""" database_connection.save(test_user) retrieved = database_connection.get_by_id(test_user.id) assert retrieved.name == "Test"3.3 参数化测试
import pytest @pytest.mark.parametrize( "input_data, expected", [ ("hello", "HELLO"), ("world", "WORLD"), ("Python", "PYTHON"), ], ids=["lowercase", "mixed_case", "title_case"] ) def test_string_upper(input_data, expected): assert input_data.upper() == expected @pytest.mark.parametrize("value", [1, 2, 3, 4, 5]) def test_positive_numbers(value): assert value > 0四、测试覆盖率
4.1 使用coverage.py
# 安装coverage pip install coverage # 运行测试并生成报告 coverage run -m pytest tests/ coverage report -m coverage html4.2 覆盖率目标
# .coveragerc配置文件 [run] source = . omit = */tests/* */__init__.py [report] show_missing = True fail_under = 80五、测试最佳实践
5.1 测试命名规范
# 好的测试命名 def test_user_can_login_with_valid_credentials(): pass def test_user_cannot_login_with_invalid_password(): pass def test_api_returns_404_for_nonexistent_resource(): pass # 避免的命名 def test_login(): # 太模糊 pass def test_case_1(): # 没有描述性 pass5.2 测试隔离
def test_database_transactions(): """每个测试应该独立""" # 在测试开始时清理状态 clear_database() # 执行测试 create_user("test") # 验证结果 assert get_user_count() == 1 def clear_database(): """清理数据库""" # 删除所有数据 pass5.3 测试文档化
def test_checkout_process_with_insufficient_stock(): """ 测试库存不足时的结账流程 场景:用户尝试购买库存不足的商品 预期:系统应返回错误信息,订单不应创建 """ product = Product(name="Book", stock=0) cart = Cart(items=[product]) with pytest.raises(InsufficientStockError): checkout(cart)六、持续集成中的测试
6.1 GitHub Actions配置
# .github/workflows/tests.yml name: Run Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip pip install pytest pytest-mock coverage pip install -e . - name: Run tests run: pytest tests/ -v - name: Check coverage run: coverage run -m pytest tests/ && coverage report --fail-under=806.2 测试报告集成
# 生成JUnit格式报告 pytest tests/ --junitxml=results.xml # 发送测试结果到Slack def notify_slack(results): message = f"测试完成: {results.passed}/{results.total} 通过" send_slack_message(message)七、总结
TDD的优势:
- 更早发现问题:在开发早期捕获bug
- 更好的设计:测试驱动产生更清晰的API
- 文档作用:测试用例作为活文档
- 重构信心:测试确保重构不会破坏功能
在实际项目中,建议:
- 采用TDD方法论
- 保持测试简洁独立
- 追求合理的测试覆盖率
- 集成到CI/CD流程中
思考:在你的项目中,TDD带来了哪些好处?欢迎分享!