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 html

4.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():  # 没有描述性
    pass

5.2 测试隔离

def test_database_transactions():
    """每个测试应该独立"""
    # 在测试开始时清理状态
    clear_database()
    
    # 执行测试
    create_user("test")
    
    # 验证结果
    assert get_user_count() == 1

def clear_database():
    """清理数据库"""
    # 删除所有数据
    pass

5.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=80

6.2 测试报告集成

# 生成JUnit格式报告
pytest tests/ --junitxml=results.xml

# 发送测试结果到Slack
def notify_slack(results):
    message = f"测试完成: {results.passed}/{results.total} 通过"
    send_slack_message(message)

七、总结

TDD的优势:

  1. 更早发现问题:在开发早期捕获bug
  2. 更好的设计:测试驱动产生更清晰的API
  3. 文档作用:测试用例作为活文档
  4. 重构信心:测试确保重构不会破坏功能

在实际项目中,建议:

  • 采用TDD方法论
  • 保持测试简洁独立
  • 追求合理的测试覆盖率
  • 集成到CI/CD流程中

思考:在你的项目中,TDD带来了哪些好处?欢迎分享!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐