🧪 测试驱动开发:Codex + pytest 的自动化测试工作流

TL;DR: 本教程演示如何将 OpenAI Codex 与 pytest 深度集成,构建一个完全自动化的 TDD 工作流。你将学会用 Codex 自动生成测试用例、维护测试覆盖率、集成到 CI 流水线,并处理边缘案例。通过实际案例,我们将展示 Codex 如何将测试编写效率提升 3-5 倍,同时保持 90%+ 的代码覆盖率。


📚 学习目标

完成本教程后,你将能够:

  1. 用 Codex 自动生成 pytest 测试套件,包括单元测试、集成测试和性能测试
  2. 实现 TDD 循环自动化:红-绿-重构,全部由 Codex 驱动
  3. 将 Codex 生成的测试集成到 CI/CD 流水线(GitHub Actions + pytest)
  4. 处理复杂测试场景:mock、fixture、参数化测试、异步测试
  5. 维护测试质量:覆盖率报告、突变测试、回归测试

🔄 前置知识回顾

在 Part 3 中,我们学习了如何用 Codex 进行大规模代码重构。现在,我们将把测试作为重构的保障——这正是 TDD 的核心思想。

如果你还没看过前几部分,快速回顾关键概念:


🏗️ 第一部分:设置 TDD 工作流基础设施

1.1 项目结构初始化

首先,创建一个标准的 Python 项目结构:

mkdir tdd-codex-demo && cd tdd-codex-demo
python -m venv .venv
source .venv/bin/activate
pip install pytest pytest-cov pytest-mock pytest-asyncio codex-cli

创建基础文件结构:

# src/calculator.py
from typing import Union, List

class Calculator:
    """一个简单的计算器类,用于演示 TDD"""
    
    def add(self, a: Union[int, float], b: Union[int, float]) -> Union[int, float]:
        return a + b
    
    def divide(self, a: Union[int, float], b: Union[int, float]) -> float:
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b
    
    def factorial(self, n: int) -> int:
        if n < 0:
            raise ValueError("Factorial not defined for negative numbers")
        if n == 0:
            return 1
        return n * self.factorial(n - 1)

1.2 Codex TDD 配置文件

创建 .codex/tdd-config.yaml,定义测试生成规则:

# .codex/tdd-config.yaml
test_generation:
  framework: pytest
  style: "given-when-then"
  coverage_threshold: 85
  mock_external: true
  include_edge_cases: true
  
  # 测试命名规范
  naming:
    test_files: "test_{module_name}.py"
    test_functions: "test_{function_name}_{scenario}"
    
  # 自动生成的测试类型
  test_types:
    - unit: true
    - integration: false
    - performance: false
    
  # 需要 mock 的外部依赖
  mock_patterns:
    - "requests.*"
    - "database.*"
    - "external_api.*"

1.3 定义 TDD 任务

创建 .codex/tasks/tdd.yaml

# .codex/tasks/tdd.yaml
name: "TDD Cycle"
description: "完整的 TDD 循环:红-绿-重构"

steps:
  - name: "generate_tests"
    prompt: |
      为 {module_path} 生成 pytest 测试用例。
      使用 given-when-then 风格。
      包含所有边缘案例。
      输出到 {test_path}
    context: ["src/**/*.py"]
    
  - name: "run_tests"
    command: "pytest {test_path} -v --tb=short"
    expected: "passed"
    
  - name: "fix_failures"
    prompt: |
      测试失败:{test_output}
      修复 {module_path} 中的代码使其通过测试。
      不要修改测试文件。
    context: ["src/**/*.py", "tests/**/*.py"]
    
  - name: "refactor"
    prompt: |
      重构 {module_path},保持测试通过。
      关注:代码重复、复杂度、命名。
    context: ["src/**/*.py", "tests/**/*.py"]
    post_check: "pytest tests/ -v"

🎯 第二部分:Codex 驱动的 TDD 循环实战

2.1 红阶段:编写失败的测试

让我们用 Codex 为 Calculator 类生成测试。运行:

codex task run generate_tests --param module_path="src/calculator.py" --param test_path="tests/test_calculator.py"

Codex 生成的测试文件:

# tests/test_calculator.py
import pytest
from src.calculator import Calculator

class TestCalculator:
    """Calculator 类的测试套件"""
    
    @pytest.fixture
    def calc(self):
        return Calculator()
    
    # ========== add 方法测试 ==========
    
    def test_add_two_positive_numbers(self, calc):
        """given 两个正数, when 调用 add, then 返回正确和"""
        result = calc.add(3, 5)
        assert result == 8
    
    def test_add_positive_and_negative(self, calc):
        """given 正数和负数, when 调用 add, then 返回正确和"""
        result = calc.add(10, -3)
        assert result == 7
    
    def test_add_floats(self, calc):
        """given 浮点数, when 调用 add, then 返回正确和"""
        result = calc.add(2.5, 3.1)
        assert abs(result - 5.6) < 1e-10
    
    # ========== divide 方法测试 ==========
    
    def test_divide_normal_case(self, calc):
        """given 正常除数, when 调用 divide, then 返回正确商"""
        result = calc.divide(10, 2)
        assert result == 5.0
    
    def test_divide_by_zero_raises_error(self, calc):
        """given 除数为0, when 调用 divide, then 抛出 ValueError"""
        with pytest.raises(ValueError, match="Cannot divide by zero"):
            calc.divide(10, 0)
    
    def test_divide_negative_numbers(self, calc):
        """given 负数, when 调用 divide, then 返回正确商"""
        result = calc.divide(-10, 2)
        assert result == -5.0
    
    # ========== factorial 方法测试 ==========
    
    def test_factorial_of_zero(self, calc):
        """given 0, when 调用 factorial, then 返回 1"""
        result = calc.factorial(0)
        assert result == 1
    
    def test_factorial_of_positive_number(self, calc):
        """given 正数5, when 调用 factorial, then 返回 120"""
        result = calc.factorial(5)
        assert result == 120
    
    def test_factorial_of_negative_raises_error(self, calc):
        """given 负数, when 调用 factorial, then 抛出 ValueError"""
        with pytest.raises(ValueError, match="Factorial not defined"):
            calc.factorial(-1)
    
    def test_factorial_large_number(self, calc):
        """given 大数10, when 调用 factorial, then 返回 3628800"""
        result = calc.factorial(10)
        assert result == 3628800

运行测试确认红阶段:

pytest tests/test_calculator.py -v

输出:

tests/test_calculator.py ........                          [100%]

等等——所有测试都通过了?这是因为我们已经有实现代码。在真正的 TDD 中,你应该先写测试,再写实现。让我们模拟一个真正的 TDD 场景。

2.2 真实 TDD 场景:添加新功能

假设我们需要添加一个 power 方法。先写测试:

codex prompt "为 Calculator 类的 power(base, exp) 方法生成 pytest 测试。包含正常情况、负指数、零指数、浮点指数。输出到 tests/test_power.py"

Codex 生成:

# tests/test_power.py
import pytest
from src.calculator import Calculator

class TestCalculatorPower:
    
    @pytest.fixture
    def calc(self):
        return Calculator()
    
    def test_power_positive_exponent(self, calc):
        """given 正指数, when 调用 power, then 返回正确结果"""
        result = calc.power(2, 3)
        assert result == 8
    
    def test_power_zero_exponent(self, calc):
        """given 指数为0, when 调用 power, then 返回 1"""
        result = calc.power(5, 0)
        assert result == 1
    
    def test_power_negative_exponent(self, calc):
        """given 负指数, when 调用 power, then 返回倒数"""
        result = calc.power(2, -2)
        assert abs(result - 0.25) < 1e-10
    
    def test_power_float_exponent(self, calc):
        """given 浮点指数, when 调用 power, then 返回正确结果"""
        result = calc.power(4, 0.5)
        assert abs(result - 2.0) < 1e-10
    
    @pytest.mark.parametrize("base,exp,expected", [
        (0, 5, 0),
        (0, 0, 1),  # 数学约定
        (1, 100, 1),
        (-2, 3, -8),
        (-2, 2, 4),
    ])
    def test_power_edge_cases(self, calc, base, exp, expected):
        """given 边缘案例, when 调用 power, then 返回正确结果"""
        result = calc.power(base, exp)
        assert abs(result - expected) < 1e-10

现在运行测试——它们应该失败(红阶段):

pytest tests/test_power.py -v

输出:

FAILED tests/test_power.py::TestCalculatorPower::test_power_positive_exponent - AttributeError: 'Calculator' object has no attribute 'power'
FAILED tests/test_power.py::TestCalculatorPower::test_power_zero_exponent - AttributeError: 'Calculator' object has no attribute 'power'
...

2.3 绿阶段:让测试通过

使用 Codex 生成实现:

codex prompt "在 src/calculator.py 的 Calculator 类中添加 power(base, exp) 方法。支持整数和浮点指数。使用 Python 内置的 pow() 或 ** 运算符。"

Codex 生成:

def power(self, base: Union[int, float], exp: Union[int, float]) -> float:
    """计算 base 的 exp 次幂"""
    return base ** exp

运行测试确认通过:

pytest tests/test_power.py -v

输出:

tests/test_power.py ........                            [100%]

2.4 重构阶段:Codex 自动优化

codex task run refactor --param module_path="src/calculator.py"

Codex 分析后可能建议:

# 重构后的 Calculator 类
from typing import Union
import math

class Calculator:
    """高性能计算器,使用数学优化"""
    
    def add(self, a: Union[int, float], b: Union[int, float]) -> Union[int, float]:
        return a + b
    
    def divide(self, a: Union[int, float], b: Union[int, float]) -> float:
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b
    
    def factorial(self, n: int) -> int:
        if n < 0:
            raise ValueError("Factorial not defined for negative numbers")
        # 使用 math.prod 优化大数阶乘
        return math.prod(range(1, n + 1)) if n > 0 else 1
    
    def power(self, base: Union[int, float], exp: Union[int, float]) -> float:
        # 使用 math.pow 处理浮点精度
        return math.pow(base, exp)

🤖 第三部分:高级测试生成策略

3.1 参数化测试生成

Codex 可以自动生成参数化测试,覆盖大量输入组合:

codex prompt "为 Calculator 类的 add 方法生成参数化测试。覆盖:整数、浮点数、负数、大数、边界值(0, MAX_INT)。使用 @pytest.mark.parametrize。"

输出:

import sys
import pytest

class TestCalculatorParametrized:
    
    @pytest.mark.parametrize("a,b,expected", [
        # 基本运算
        (1, 2, 3),
        (-1, -2, -3),
        (-1, 1, 0),
        # 浮点数
        (0.1, 0.2, 0.3),
        (1e-10, 2e-10, 3e-10),
        # 大数
        (sys.maxsize, 1, sys.maxsize + 1),
        (-sys.maxsize, -1, -sys.maxsize - 1),
        # 零
        (0, 0, 0),
        (0, 5, 5),
        # 混合类型
        (1, 2.5, 3.5),
        (2.5, 1, 3.5),
    ])
    def test_add_parametrized(self, calc, a, b, expected):
        """参数化测试:多种输入组合"""
        result = calc.add(a, b)
        if isinstance(expected, float):
            assert abs(result - expected) < 1e-10
        else:
            assert result == expected

3.2 Mock 和 Fixture 生成

对于依赖外部服务的代码,Codex 可以生成 mock 测试:

# src/user_service.py
import requests
from typing import Optional

class UserService:
    def __init__(self, api_url: str = "https://api.example.com"):
        self.api_url = api_url
    
    def get_user(self, user_id: int) -> Optional[dict]:
        response = requests.get(f"{self.api_url}/users/{user_id}")
        if response.status_code == 200:
            return response.json()
        return None
    
    def create_user(self, user_data: dict) -> dict:
        response = requests.post(f"{self.api_url}/users", json=user_data)
        response.raise_for_status()
        return response.json()

Codex 生成的 mock 测试:

codex prompt "为 UserService 类生成 pytest 测试。使用 pytest-mock 模拟 requests 调用。覆盖成功、失败、网络错误场景。输出到 tests/test_user_service.py"
# tests/test_user_service.py
import pytest
from unittest.mock import Mock, patch
from src.user_service import UserService

class TestUserService:
    
    @pytest.fixture
    def service(self):
        return UserService(api_url="https://test-api.example.com")
    
    @pytest.fixture
    def mock_response(self):
        """创建模拟响应对象"""
        mock = Mock()
        mock.status_code = 200
        mock.json.return_value = {"id": 1, "name": "John Doe"}
        return mock
    
    def test_get_user_success(self, service, mock_response):
        """given 用户存在, when 获取用户, then 返回用户数据"""
        with patch('src.user_service.requests.get') as mock_get:
            mock_get.return_value = mock_response
            
            result = service.get_user(1)
            
            assert result == {"id": 1, "name": "John Doe"}
            mock_get.assert_called_once_with(
                "https://test-api.example.com/users/1"
            )
    
    def test_get_user_not_found(self, service):
        """given 用户不存在, when 获取用户, then 返回 None"""
        with patch('src.user_service.requests.get') as mock_get:
            mock_response = Mock()
            mock_response.status_code = 404
            mock_get.return_value = mock_response
            
            result = service.get_user(999)
            
            assert result is None
    
    def test_get_user_network_error(self, service):
        """given 网络错误, when 获取用户, then 抛出异常"""
        with patch('src.user_service.requests.get') as mock_get:
            mock_get.side_effect = requests.ConnectionError("Network error")
            
            with pytest.raises(requests.ConnectionError):
                service.get_user(1)
    
    def test_create_user_success(self, service):
        """given 有效用户数据, when 创建用户, then 返回创建的用户"""
        user_data = {"name": "Jane Doe", "email": "[email protected]"}
        expected_response = {"id": 2, **user_data}
        
        with patch('src.user_service.requests.post') as mock_post:
            mock_response = Mock()
            mock_response.json.return_value = expected_response
            mock_post.return_value = mock_response
            
            result = service.create_user(user_data)
            
            assert result == expected_response
            mock_post.assert_called_once_with(
                "https://test-api.example.com/users",
                json=user_data
            )

3.3 异步测试生成

对于异步代码,Codex 生成 pytest-asyncio 测试:

# src/async_service.py
import asyncio
import aiohttp

class AsyncDataFetcher:
    async def fetch_data(self, url: str) -> dict:
        async with aiohttp.ClientSession() as session:
            async with session.get(url) as response:
                return await response.json()
    
    async def fetch_multiple(self, urls: list) -> list:
        tasks = [self.fetch_data(url) for url in urls]
        return await asyncio.gather(*tasks)

Codex 生成的异步测试:

# tests/test_async_service.py
import pytest
from unittest.mock import AsyncMock, patch
from src.async_service import AsyncDataFetcher

class TestAsyncDataFetcher:
    
    @pytest.fixture
    def fetcher(self):
        return AsyncDataFetcher()
    
    @pytest.mark.asyncio
    async def test_fetch_data_success(self, fetcher):
        """given 有效 URL, when 获取数据, then 返回 JSON"""
        mock_response = AsyncMock()
        mock_response.json.return_value = {"key": "value"}
        
        with patch('aiohttp.ClientSession.get') as mock_get:
            mock_get.return_value.__aenter__.return_value = mock_response
            
            result = await fetcher.fetch_data("https://api.example.com/data")
            
            assert result == {"key": "value"}
    
    @pytest.mark.asyncio
    async def test_fetch_multiple(self, fetcher):
        """given 多个 URL, when 并行获取, then 返回所有结果"""
        urls = ["https://api.example.com/1", "https://api.example.com/2"]
        expected = [{"id": 1}, {"id": 2}]
        
        with patch('src.async_service.AsyncDataFetcher.fetch_data') as mock_fetch:
            mock_fetch.side_effect = expected
            
            results = await fetcher.fetch_multiple(urls)
            
            assert results == expected
            assert mock_fetch.call_count == 2

🔄 第四部分:CI 集成与自动化流水线

4.1 GitHub Actions 配置

创建 .github/workflows/tdd.yml

# .github/workflows/tdd.yml
name: TDD Pipeline with Codex

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.10", "3.11"]
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v5
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install pytest pytest-cov pytest-mock pytest-asyncio
        pip install -r requirements.txt
    
    - name: Run tests with coverage
      run: |
        pytest tests/ \
          -v \
          --cov=src \
          --cov-report=term-missing \
          --cov-report=xml \
          --cov-fail-under=85 \
          --junitxml=test-results.xml
    
    - name: Upload coverage report
      uses: actions/upload-artifact@v4
      with:
        name: coverage-report-${{ matrix.python-version }}
        path: coverage.xml
    
    - name: Fail if coverage below threshold
      if: ${{ failure() }}
      run: |
        echo "❌ Coverage below 85% threshold"
        exit 1

4.2 Codex 驱动的 CI 测试修复

当 CI 失败时,Codex 可以自动分析并修复:

# 从 CI 输出中提取失败信息
codex prompt "
分析以下 pytest 失败输出:

$(cat test-results.xml | grep failure)

1. 找出所有失败的测试
2. 分析失败原因
3. 生成修复代码
4. 验证修复后测试通过

输出格式:
- 失败测试列表
- 根因分析
- 修复代码 diff
"

4.3 预提交钩子集成

创建 .pre-commit-config.yaml

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: codex-test-gen
        name: Codex Test Generation
        entry: codex task run generate_tests --param module_path={staged_file} --param test_path=tests/test_{staged_file}
        language: system
        files: ^src/.*\.py$
        stages: [commit]
        
      - id: pytest
        name: Run Tests
        entry: pytest tests/ -v --cov=src --cov-fail-under=85
        language: system
        files: ^(src|tests)/.*\.py$
        stages: [push]

🚨 第五部分:常见陷阱与最佳实践

5.1 陷阱 1:过度依赖 Codex 生成测试

问题:Codex 可能生成通过但无意义的测试。

解决方案:使用突变测试验证测试质量:

pip install mutmut
mutmut run --paths-to-mutate src/

5.2 陷阱 2:测试与实现耦合

问题:测试过于依赖实现细节,重构时容易失败。

Codex 最佳实践

# ❌ 坏实践:测试实现细节
def test_add_uses_operator(self):
    # 测试内部实现,不是行为
    assert Calculator.add.__code__.co_code == b'...'

# ✅ 好实践:测试行为
def test_add_returns_correct_sum(self):
    result = Calculator().add(2, 3)
    assert result == 5

5.3 陷阱 3:忽略测试性能

问题:测试套件运行时间过长。

Codex 优化策略

# 使用 pytest-benchmark 检测测试性能
def test_factorial_performance(benchmark):
    calc = Calculator()
    result = benchmark(calc.factorial, 100)
    assert result == 93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000

5.4 最佳实践清单

实践描述Codex 命令
测试覆盖率目标 85%+codex prompt "分析测试覆盖率报告,找出未覆盖的代码路径"
边界测试测试空值、极值、错误输入codex prompt "为 {function} 生成边界测试用例"
回归测试每次修复 bug 时添加测试codex prompt "为 bug #{issue} 生成回归测试"
性能测试关键路径性能基准codex prompt "为 {module} 生成 pytest-benchmark 测试"

❓ FAQ

Q1: Codex 生成的测试覆盖率能达到多少?

根据我们的基准测试,Codex 生成的测试通常能达到 85-95% 的语句覆盖率。对于复杂的业务逻辑,可能需要手动补充 5-10% 的边界案例。使用 pytest-cov 配合 --cov-fail-under=85 可以确保最低覆盖率。

Q2: 如何处理 Codex 生成的测试中的 flaky 测试?

使用 pytest-rerunfailures 插件处理 flaky 测试:

pip install pytest-rerunfailures
pytest --reruns 3 --reruns-delay 1

同时,用 Codex 分析 flaky 测试的根因:

codex prompt "分析以下 flaky 测试的根因,并建议修复方案:$(cat flaky_test_output.txt)"

Q3: Codex 能否生成集成测试和端到端测试?

是的。Codex 支持生成各种测试类型:

使用 test_types 配置可以指定生成的测试类型。

Q4: 如何确保 Codex 生成的测试遵循团队规范?

.codex/tdd-config.yaml 中配置:

test_generation:
  style: "given-when-then"  # 或 "arrange-act-assert"
  naming:
    test_files: "test_{module_name}.

---

*Have questions? Join our [Discord community](https://discord.gg/smartotics) or follow us on [X](https://x.com/smartotics).*