🧪 测试驱动开发:Codex + pytest 的自动化测试工作流
TL;DR: 本教程演示如何将 OpenAI Codex 与 pytest 深度集成,构建一个完全自动化的 TDD 工作流。你将学会用 Codex 自动生成测试用例、维护测试覆盖率、集成到 CI 流水线,并处理边缘案例。通过实际案例,我们将展示 Codex 如何将测试编写效率提升 3-5 倍,同时保持 90%+ 的代码覆盖率。
📚 学习目标
完成本教程后,你将能够:
- 用 Codex 自动生成 pytest 测试套件,包括单元测试、集成测试和性能测试
- 实现 TDD 循环自动化:红-绿-重构,全部由 Codex 驱动
- 将 Codex 生成的测试集成到 CI/CD 流水线(GitHub Actions + pytest)
- 处理复杂测试场景:mock、fixture、参数化测试、异步测试
- 维护测试质量:覆盖率报告、突变测试、回归测试
🔄 前置知识回顾
在 Part 3 中,我们学习了如何用 Codex 进行大规模代码重构。现在,我们将把测试作为重构的保障——这正是 TDD 的核心思想。
如果你还没看过前几部分,快速回顾关键概念:
- Codex 任务文件:
.codex/tasks.yaml定义自动化工作流 - Agent 上下文:Codex 通过
--context参数理解代码库 - pytest 基础:我们假设你熟悉 fixture、mock、参数化
🏗️ 第一部分:设置 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 支持生成各种测试类型:
- 单元测试:
pytest+ mock - 集成测试:
pytest+docker-compose+testcontainers - 端到端测试:
playwright或selenium - API 测试:
requests+pytest
使用 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).*