> ## Documentation Index
> Fetch the complete documentation index at: https://aicoding.cscitech.top/llms.txt
> Use this file to discover all available pages before exploring further.

# 05-重构与补测试

> 用行为基线、测试保护和小步提交完成可回滚的重构，覆盖接口兼容、边界场景、性能验证与审查方法。

## 用途

重构是在**外部行为不变**的前提下改善内部结构。补测试不是追求覆盖率数字，而是把调用方依赖的真实行为写成可重复的证据。本页适合拆分职责、替换依赖、消除重复、改善查询性能，以及让 Codex 协助修改但不把判断标准交给它猜。

主线始终是：

```text theme={null}
识别风险 -> 建立行为基线 -> 先补测试 -> 小步重构 -> 每步验证
-> 检查兼容性和性能 -> 审查 diff -> 预备回滚
```

完成的标准是：关键行为有证据，新增测试覆盖正常和边界路径，每个步骤可独立验证或回退，公开接口没有无意变化，性能没有超预算，失败时知道如何恢复。

> 示例使用 Python 和 pytest；其他语言沿用同样的判断顺序。命令、审批模式和 Codex 界面以本机 `codex --help`、项目脚本和官方文档为准。

## 一、先判断任务边界

| 任务     | 允许改变什么        | 首要证据         |
| ------ | ------------- | ------------ |
| 重构     | 内部结构、命名、依赖方向  | 改前改后行为一致     |
| 修复 Bug | 导致错误的实现和对应行为  | 回归测试由失败变为通过  |
| 新功能    | 明确新增的输入、输出或能力 | 新需求通过且旧行为不退化 |
| 性能优化   | 实现路径和资源使用     | 基准改善且结果一致    |

如果需求同时说“整理代码”和“改变返回结果”，先拆成两个任务。行为变化必须有新契约，不能藏在重构名义下。

### 开始前检查

```bash theme={null}
git status --short --branch
git branch --show-current
git log -5 --oneline
find . -maxdepth 2 -type f \( -name 'AGENTS.md' -o -name 'pyproject.toml' -o -name 'Makefile' \) -print
python --version
pytest --version
```

Windows 可用 PowerShell 的 `Get-ChildItem -Recurse -Depth 2 -File -Include AGENTS.md,pyproject.toml,Makefile`。先阅读适用的 `AGENTS.md`，确认测试、lint、类型检查、构建命令和隔离要求。长期约定写进 `AGENTS.md`，临时要求留在任务提示中。

给 Codex 的只读探索提示：

```text theme={null}
请只读检查 @src/order_service.py、所有调用方和现有测试。
先不要修改。输出入口、返回值、异常、副作用和边界缺口，
区分事实与推测，最后给出分步计划和每步回滚方式。
```

## 二、行为基线

### 什么必须记录

行为基线回答“当前代码实际上做了什么”，不是回答“理想设计应该是什么”。至少记录：

* 函数签名、默认值、返回类型、字段名称和排序。
* `None`、空集合、重复值、非法值和极端输入的处理。
* 异常类型、消息中稳定的部分和抛出时机。
* 查询次数、写入顺序、事务、日志、指标和外部请求。
* 缓存命中、失效、全局状态和多次调用的差异。
* 超时、重试、取消、并发下的表现。
* 典型输入下的耗时、内存和网络请求数。

“并不理想但已被依赖”的行为也要记录。例如旧接口对 `None` 返回 `None`，不能因为更喜欢抛异常就直接改变它。

### 从外到内调查

1. 搜索公共入口、导入路径、路由和所有调用方。
2. 阅读现有测试、夹具、模拟对象和测试数据。
3. 用代表性输入运行入口，保存结果和异常。
4. 观察数据库、HTTP、日志、事件等副作用。
5. 找出没有测试但被调用方依赖的分支。
6. 将已确认事实、假设和待用户决定的行为分开。

```bash theme={null}
rg "get_order_summary|OrderService|order_service" src tests scripts
rg "status|total|items|None|TimeoutError" src tests
```

### 基线表

| 场景   | 输入             | 当前结果        | 是否保持 | 证据    |
| ---- | -------------- | ----------- | ---- | ----- |
| 正常订单 | 两个有效订单         | 按 ID 排序返回   | 是    | 现有调用方 |
| 空列表  | `[]`           | 返回 `[]`     | 是    | 手工运行  |
| 缺少用户 | `None`         | 返回 `None`   | 需确认  | 条件分支  |
| 重复订单 | 相同 ID 两次       | 保留两条        | 需确认  | 输出记录  |
| 仓库超时 | `TimeoutError` | 当前可能返回 `[]` | 需确认  | 运行记录  |
| 大输入  | 10,000 条       | 记录耗时和查询数    | 预算   | 基准脚本  |

快照只能捕捉整体输出，不能代替异常和副作用断言。推荐使用“小快照 + 关键字段精确断言 + 查询或事件断言”。

## 三、先补测试

### 测试优先级

先覆盖公共入口，再决定是否测试内部函数：

* 正常路径、空输入、单项输入和最大合理输入。
* 缺字段、额外字段、错误类型、非法值。
* 重复、乱序、负数、零值、极端字符串。
* 依赖返回空结果、抛异常、超时和部分失败。
* 多次调用、重试、取消、缓存和状态残留。
* 输出顺序、字段类型和可变性等调用方可能依赖的细节。

不要为了提高覆盖率大量锁死私有实现。测试替身也要诚实：内存仓库不能证明真实数据库的事务和排序，简单 Mock 不能证明真实 HTTP 契约。

### 先让回归测试失败

修已知 Bug 时，先让测试在旧代码上失败，再修实现。测试一开始就通过，常见原因是没有走到缺陷分支、断言太弱、替身绕开了错误条件，或运行错了文件。

```bash theme={null}
python -m pytest tests/test_orders.py -q
python -m pytest tests/test_orders.py -q -x
```

`-x` 用于快速暴露首个失败。确认失败确实对应目标问题后再动实现。

参数化适合表达清楚的边界：

```python theme={null}
@pytest.mark.parametrize(
    ("raw", "expected"),
    [([], []), ([{"id": "a"}], [{"id": "a"}])],
)
def test_sort_orders_keeps_contract(raw, expected):
    assert sort_orders(raw) == expected


def test_timeout_is_not_silently_converted():
    with pytest.raises(TimeoutError, match="orders"):
        load_orders("u-1", repository=TimeoutRepository())
```

测试全局缓存或单例时必须清理：

```python theme={null}
@pytest.fixture(autouse=True)
def reset_state():
    clear_order_cache()
    yield
    clear_order_cache()
```

如果测试依赖执行顺序，先修复状态隔离，不要调整顺序掩盖问题。

## 四、完整演练：重构坏味道订单模块

这个例子同时展示长函数、全局缓存、异常吞掉、N+1 查询、字段语义不一致和隐式默认值。目标是小步改善，不是一次重写。

### 1. 旧模块

```python theme={null}
# order_service.py
_orders_cache = {}

def get_order_summary(user_id, repository, currency="CNY"):
    if user_id is None:
        return None
    if user_id in _orders_cache:
        orders = _orders_cache[user_id]
    else:
        try:
            orders = repository.find_by_user(user_id)
        except Exception:
            return []
        _orders_cache[user_id] = orders
    result = []
    for order in orders:
        if order.get("cancelled"):
            continue
        items = repository.find_items(order["id"])
        total = 0
        for item in items:
            if item.get("deleted"):
                continue
            total += item.get("price", 0) * item.get("quantity", 1)
        if currency == "CNY":
            total = round(total, 2)
        result.append({"id": order["id"], "total": total,
                       "item_count": len(items)})
    result.sort(key=lambda row: row["id"])
    return result
```

先列出坏味道：

1. 全局缓存让数据过期、测试污染和不同仓库实例互相影响。
2. 一个函数处理入口兼容、缓存、异常、过滤、查询、金额和格式化。
3. `except Exception` 把超时、连接失败和程序错误都伪装成空列表。
4. 每个订单查一次明细，输入变大时产生 N+1 查询。
5. 已删除商品不计金额，却计入 `item_count`，形成旧接口语义。
6. `currency` 只有 CNY 分支，其他值静默返回原始金额。
7. ID 排序可能已被调用方依赖，但代码没有说明。

### 2. 基线测试

先准备可观察调用次数的仓库替身：

```python theme={null}
class Repository:
    def __init__(self, orders=None, items=None, error=None):
        self.orders = orders or []
        self.items = items or {}
        self.error = error
        self.calls = []

    def find_by_user(self, user_id):
        self.calls.append(("find_by_user", user_id))
        if self.error:
            raise self.error
        return self.orders

    def find_items(self, order_id):
        self.calls.append(("find_items", order_id))
        return self.items.get(order_id, [])
```

基线至少包含：

```python theme={null}
def test_summary_filters_and_sorts():
    repo = Repository(
        orders=[{"id": "o-2"}, {"id": "o-1", "cancelled": True}],
        items={"o-2": [{"price": 3, "quantity": 2}]},
    )
    assert get_order_summary("u-1", repo) == [
        {"id": "o-2", "total": 6, "item_count": 1}
    ]


def test_none_user_is_legacy_none():
    repo = Repository()
    assert get_order_summary(None, repo) is None
    assert repo.calls == []


def test_deleted_item_is_not_in_total_but_is_in_legacy_count():
    repo = Repository(
        orders=[{"id": "o-1"}],
        items={"o-1": [
            {"price": 10, "quantity": 1, "deleted": True},
            {"price": 2, "quantity": 3},
        ]},
    )
    assert get_order_summary("u-1", repo) == [
        {"id": "o-1", "total": 6, "item_count": 2}
    ]


def test_repository_timeout_is_currently_hidden():
    assert get_order_summary("u-1", Repository(error=TimeoutError())) == []
```

还应记录缺价格按 0、缺数量按 1、非 CNY 不四舍五入、重复 ID 保留等现状。运行：

```bash theme={null}
python -m pytest -q
```

此时全绿只代表测试描述了旧实现，并不代表设计良好。可以保存基线提交或补丁：

```bash theme={null}
git add order_service.py test_order_service.py
git commit -m "test: record order summary behavior"
```

不允许中途提交时可用 `git diff > baseline.patch` 保存证据。

### 3. 第一步：抽出纯逻辑

只抽出金额计算，保留默认值、异常、缓存、排序和字段语义：

```python theme={null}
def _calculate_total(items, currency):
    total = 0
    for item in items:
        if item.get("deleted"):
            continue
        total += item.get("price", 0) * item.get("quantity", 1)
    return round(total, 2) if currency == "CNY" else total
```

将旧循环替换为 `_calculate_total(items, currency)`，不要同时做接口和性能变化。

```bash theme={null}
python -m pytest tests/test_order_service.py -q
python -m pytest -q
```

### 4. 第二步：拆分读取和组装

```python theme={null}
def _load_orders(user_id, repository):
    try:
        return repository.find_by_user(user_id)
    except Exception:
        return []


def _build_summary(orders, repository, currency):
    result = []
    for order in orders:
        if order.get("cancelled"):
            continue
        items = repository.find_items(order["id"])
        result.append({
            "id": order["id"],
            "total": _calculate_total(items, currency),
            "item_count": len(items),
        })
    return sorted(result, key=lambda row: row["id"])
```

公共入口暂时只负责兼容入口和缓存：

```python theme={null}
def get_order_summary(user_id, repository, currency="CNY"):
    if user_id is None:
        return None
    if user_id not in _orders_cache:
        _orders_cache[user_id] = _load_orders(user_id, repository)
    return _build_summary(_orders_cache[user_id], repository, currency)
```

运行全套测试，并额外检查查询次数。结果一样不代表副作用一样。

### 5. 第三步：引入显式服务和兼容适配器

```python theme={null}
class OrderService:
    def __init__(self, repository, cache=None):
        self.repository = repository
        self.cache = cache if cache is not None else {}

    def get_summary(self, user_id, currency="CNY"):
        if user_id is None:
            return None
        if user_id not in self.cache:
            self.cache[user_id] = _load_orders(user_id, self.repository)
        return _build_summary(self.cache[user_id], self.repository, currency)


def get_order_summary(user_id, repository, currency="CNY"):
    return OrderService(repository, cache=_orders_cache).get_summary(
        user_id, currency
    )
```

旧入口的签名、字段、排序和 `None` 语义暂时保留。新增测试证明服务实例拥有自己的依赖和缓存。之后再决定是否删除缓存；缓存删除是行为和性能变化，需说明失效策略、命中率和预算。

### 6. 第四步：处理边界和接口兼容

| 场景             | 旧行为    | 重构阶段       |
| -------------- | ------ | ---------- |
| `user_id=None` | `None` | 先保留        |
| 超时             | `[]`   | 先锁定，修复另立任务 |
| 缺价格            | 按 0    | 先保留并记录风险   |
| 缺数量            | 按 1    | 先保留并记录风险   |
| 负数价格           | 参与计算   | 先测试，业务另决   |
| 重复 ID          | 保留重复   | 先测试，勿暗中去重  |
| 空结果            | `[]`   | 保留         |

公共接口兼容至少涵盖位置参数、关键字参数、默认值、返回字段、异常类型、导入路径、HTTP 状态码和日志字段。推荐迁移顺序：

1. 新增内部实现，不动旧入口。
2. 旧入口调用新实现，保留签名和结果。
3. 新旧入口共用兼容测试。
4. 逐个迁移调用方，观察日志和指标。
5. 标记旧入口弃用，写明期限和替代入口。
6. 另一个变更再删除旧入口。

不要用宽泛 `**kwargs` 隐藏拼写错误。兼容层应明确接受的参数和未知参数的错误。

### 7. 第五步：替换 N+1 查询

只有仓库契约清楚后才做批量替换。先明确空 ID、重复 ID、缺失明细、顺序、部分失败和参数数量限制。批量路径可采用：

```python theme={null}
def _build_summary_with_batch(orders, repository, currency):
    visible = [o for o in orders if not o.get("cancelled")]
    ids = [o["id"] for o in visible]
    grouped = repository.find_items_by_order_ids(ids) if ids else {}
    result = []
    for order in visible:
        items = grouped.get(order["id"], [])
        result.append({
            "id": order["id"],
            "total": _calculate_total(items, currency),
            "item_count": len(items),
        })
    return sorted(result, key=lambda row: row["id"])
```

先写单条和批量结果等价测试，再观察查询数和错误率。可以用特性开关逐步启用，不能因为“批量一定更快”就跳过测量。

## 五、性能与回滚

### 性能基线

性能也是行为的一部分：关注 p50/p95、查询次数、扫描行数、内存峰值、外部请求、超时和重试。固定输入规模、环境、预热和重复次数，记录改前改后原始数据。

```bash theme={null}
python -m timeit -s "from benchmark import run" "run(1000)"
python -m cProfile -s cumulative benchmark.py
python -m pytest tests/benchmark -q
```

至少测空输入、小输入和接近线上上限的大输入：

| 指标        | 基线     | 预算     | 超标处理    |
| --------- | ------ | ------ | ------- |
| 1,000 条耗时 | 180 ms | 220 ms | 停止合并并定位 |
| 查询次数      | 1,001  | 不超过 3  | 检查批量路径  |
| 内存峰值      | 64 MB  | 80 MB  | 检查缓存和复制 |
| p95       | 420 ms | 500 ms | 用同类输入复测 |

### 代码、数据和发布回滚

每个提交只做一件事：

```text theme={null}
1. test: record current behavior
2. refactor: extract total calculation
3. refactor: introduce service adapter
4. perf: add batch loading behind flag
5. chore: remove deprecated entry point
```

提交前查看差异；确认没有同事改动后，未提交的明确单文件才可恢复：

```bash theme={null}
git diff -- order_service.py test_order_service.py
git restore --source=HEAD -- order_service.py
```

共享分支不要改写历史，已提交错误用 `git revert <commit>`，然后重新跑测试。涉及数据库、缓存或消息 schema 时，代码回滚还不够：

* 迁移要有向前和向后脚本，并在隔离副本演练。
* 新字段要确保旧版本可忽略或读取。
* 消息升级使用兼容的生产者和消费者顺序。
* 缓存键变化要有旧键读取窗口或清理策略。
* 特性开关默认关闭，关闭后回到已验证旧路径。

发布前确认旧版本能启动并读取当前数据，指标和错误日志有基线，回滚命令已经演练，备份时间点和恢复责任人明确，并能从原用户路径验证恢复。

## 六、失败分类

不要让 Codex 连续修改直到“变绿”。先保留命令、首个错误、提交号和影响范围，再分类：

| 类别   | 特征             | 处理             |
| ---- | -------------- | -------------- |
| 实现回归 | 锁定结果被新代码改变     | 回退当前步骤，缩小 diff |
| 测试错误 | 断言了未约定行为       | 回到基线，确认契约      |
| 测试缺口 | 旧测试通过，新场景失败    | 补稳定复现测试        |
| 环境问题 | 依赖、端口、凭据或版本缺失  | 不改业务代码         |
| 数据问题 | fixture 不符合约定  | 修正并脱敏数据        |
| 时序问题 | 并发、重试、缓存导致偶现   | 增加隔离和可重复日志     |
| 性能回归 | 结果正确但超预算       | 用基准定位热点        |
| 兼容回归 | 下游无法导入、解析或捕获异常 | 恢复适配器并盘点调用方    |
| 工具误判 | lint 或类型规则变化   | 核对配置和版本        |

失败报告应能被别人复跑：

```text theme={null}
命令：python -m pytest tests/test_order_service.py -q
分类：实现回归
首个失败：重复 ID 的结果数量改变
事实：批量路径按 ID 去重，旧路径返回两条
影响：公共返回列表长度变化
下一步：回退批量替换，确认重复 ID 契约
```

分类前不要同时改实现、测试和配置，否则根因无法定位。

## 七、给 Codex 的分步提示

规划阶段：

```text theme={null}
任务：重构 @src/order_service.py，保持 get_order_summary 兼容。
先不要修改。读取调用方、测试和项目规则，输出当前行为基线、坏味道、
先补哪些测试、每步修改文件、测试命令、性能检查和回滚方式。
把事实、推测和需要我决定的行为变化分开。
```

补测试阶段：

```text theme={null}
只执行里程碑 1：为当前行为补测试。
不要重构实现、不要添加依赖。先让回归测试在旧实现上运行，
报告失败是否对应目标问题，完成后展示 diff 和测试命令。
```

纯重构阶段：

```text theme={null}
只执行里程碑 2：抽出纯金额计算。
保持签名、默认值、异常、输出字段和排序不变；不要处理缓存、批量查询
或接口改名。运行目标测试和完整测试，逐条报告结果。
```

兼容迁移阶段：

```text theme={null}
只执行里程碑 3：新增 OrderService，让旧入口作为适配器调用它。
保持参数、返回字段、None 语义、异常语义和导入路径。
先列出调用方，增加兼容测试，不要删除旧入口。
```

性能阶段：

```text theme={null}
只执行里程碑 4：评估批量读取明细。
先记录单条路径耗时、查询数和结果。批量路径必须通过结果等价、空输入、
重复 ID、缺失明细和部分失败测试；契约不清楚就停止并列问题。
```

## 八、审查与验收

### 行为和兼容性

* 公共签名、导入路径和默认值是否保留？
* `None`、空集合、重复项、排序和字段类型是否改变？
* 异常是否被吞掉、换类型或延迟抛出？
* 日志、事件、事务和外部请求顺序是否变化？
* 兼容适配器是否有迁移范围、负责人和退出条件？

### 测试

* 测试是否在旧实现上验证过基线？
* 是否覆盖正常、空值、重复、非法、超时和部分失败？
* 断言是否具体但没有锁死不稳定时间戳和路径？
* 测试是否独立运行并清理缓存、临时数据？
* 是否错误地只测试私有实现来追求覆盖率？

### 重构与性能

* 每个提交是否单一目的，是否混入无关格式化和依赖升级？
* 新抽象是否真的减少复杂度？
* 是否有改前改后的耗时、查询数和内存数据？
* 批量查询是否处理空输入、重复 ID、顺序和参数上限？
* 缓存是否有生命周期、失效和容量策略？

### 安全和交付

* 测试数据和日志是否脱敏，没有密钥和真实用户数据？
* 数据迁移、写操作和外部请求是否在隔离环境演练？
* 特性开关、回滚脚本和恢复验证是否可执行？
* 是否只修改任务允许的文件？

典型 Python 项目验收命令：

```bash theme={null}
git diff --check
python -m pytest tests/test_order_service.py -q
python -m pytest -q
python -m compileall src tests
ruff check .
mypy src
python -m pytest tests/benchmark -q
git status --short
git diff --stat
git diff --name-only
```

优先使用项目已有的 `make test`、`make lint` 和 `make typecheck`。记录每条命令和退出码，不要只写“测试通过”。

## 九、工作清单与小结

```text theme={null}
[ ] 确认仓库、分支、工作区和项目规则
[ ] 找到入口、调用方、现有测试和运行命令
[ ] 记录返回值、异常、副作用、性能和状态基线
[ ] 先补正常、边界和失败场景测试，并验证回归测试会失败
[ ] 按单一目的拆分里程碑，每步限制文件范围并运行测试
[ ] 检查签名、字段、排序、异常和导入路径兼容
[ ] 单独验证缓存、批量查询、性能和特性开关
[ ] 记录失败分类、未验证假设和回滚方式
[ ] 运行测试、lint、类型检查、构建和 diff 检查
[ ] 确认没有无关文件、敏感数据和临时产物
```

重构的安全感来自证据链：行为基线告诉你改前真实发生什么；测试锁定调用方依赖；小步变更让审查和回退变便宜；兼容设计保护下游；边界和性能测试捕捉正常路径之外的风险；失败分类避免在错误方向上连续修改；回滚准备则覆盖代码、数据、配置和发布路径。

提交前问自己：我能否用测试证明改前改后的关键行为？能否解释每处 diff 为什么存在以及如何回退？如果明天出现兼容或性能回归，是否知道先关闭哪个开关、回哪个提交、用什么命令验证恢复？

参考资料：`参考/codex/14-workflows.md`、`参考/codex/11-agents-md.md`、`参考/codex/36-best-practices.md`。
