CI 里跑全量测试太慢了,我们用分片加依赖分析把时间从半小时压到十分钟以内
大部分 CI 全量测试慢,不是因为机器不够,而是因为你把不相关的用例塞进了同一个 Job。
我们团队在今年 Q1 做了一次 CI 重构,把全量测试从平均 32 分钟压到了 8 分钟以内。核心手段就两个:动态分片和基于依赖分析的用例裁剪。下面我直接拆解具体怎么做,以及踩过的坑。
为什么常规的并行分片解决不了问题
先看一个典型的 CI 测试阶段:你用 pytest-xdist 开了 4 个 worker,或者用 CI Runner 的 parallel: 4 把用例均匀切成 4 份跑。表面上看是并行了,但实际瓶颈往往在某一两个 Job 上。
我们项目有 3400 多个测试用例,其中有一个文件 test_billing.py 里面 200 多个用例,涉及大量数据库读写和外部 API Mock,单这一个文件就跑 9 分多钟。均匀切分的情况下,这个文件会落在某一个分片上,导致那个分片变成拖后腿的长尾任务。其他分片 3 分钟跑完在那儿空等,整体耗时就是最慢那个分片的耗时。
所以问题不在并行度不够,而在分片策略太 naive。按文件数量均分、按用例数量均分,都没考虑到单个用例的执行时间差异。
用测试执行历史做动态分片
我们放弃了静态均分的做法,改成了基于历史执行时间的动态分片。思路很简单:每次测试跑完,把每个用例的执行时长记录到一个 JSON 文件里,下次分片时按这个历史数据做加权分配。
具体实现分三步。
第一步,收集历史执行数据。
在 pytest 里用 --durations 参数可以拿到每个用例的执行时间,但那个输出格式不好解析。我们直接写了个小插件,在 conftest.py 的 pytest_runtest_makereport 钩子里记录:
# conftest.py
import json
import time
from pathlib import Path
HISTORY_FILE = Path(".test_durations.json")
def pytest_runtest_makereport(item, call):
if call.when == "call":
duration = call.stop - call.start
durations = {}
if HISTORY_FILE.exists():
durations = json.loads(HISTORY_FILE.read_text())
durations[item.nodeid] = round(duration, 3)
HISTORY_FILE.write_text(json.dumps(durations, indent=2))
每次 CI 跑完,.test_durations.json 会跟着代码仓库走,提交到 Git。新分支第一次跑没有历史数据时,退化为均分策略,跑完一次就有了。
第二步,按加权分配做分片。
分片逻辑写在一个独立的 Python 脚本里,CI 在执行测试之前先调这个脚本,生成每个分片的用例列表:
# split_tests.py
import json
import sys
from pathlib import Path
durations = json.loads(Path(".test_durations.json").read_text())
all_tests = sys.stdin.read().strip().splitlines()
# 补齐没有历史数据的用例,给一个中位数默认值
default_duration = sorted(durations.values())[len(durations) // 2] if durations else 1.0
weighted = [(t, durations.get(t, default_duration)) for t in all_tests]
# 按总权重均分到 N 个桶(贪心算法,从大到小放)
weighted.sort(key=lambda x: x[1], reverse=True)
num_shards = int(sys.argv[1])
shards = [[] for _ in range(num_shards)]
shard_loads = [0.0] * num_shards
for test, weight in weighted:
min_idx = shard_loads.index(min(shard_loads))
shards[min_idx].append(test)
shard_loads[min_idx] += weight
for i, shard in enumerate(shards):
Path(f"shard_{i}.txt").write_text("\n".join(shard))
这个贪心算法不是最优解,但对于 3000+ 用例分 4-6 个分片的场景足够用了,单次分片耗时不到 0.3 秒。实际跑下来,各分片之间的时间差从之前的 6 分钟缩小到了 30 秒以内。
第三步,CI 配置里接上分片。
以 GitLab CI 为例:
test:
parallel: 4
script:
- python split_tests.py $CI_NODE_TOTAL
- pytest $(cat shard_$CI_NODE_INDEX.txt) --durations=20 -q
artifacts:
paths:
- .test_durations.json
这里有个细节:$CI_NODE_TOTAL 是总分片数,$CI_NODE_INDEX 从 1 开始,所以上面读的是 shard_1.txt 到 shard_4.txt。每个 Job 只跑分配到的用例,互不干扰。
动态分片做完后,全量测试从 32 分钟降到了 18 分钟左右。但离 10 分钟以内还有距离,因为还有大量用例本身就不该跑。
依赖分析:只跑受变更影响的用例
全量测试最大的浪费在于:你改了 src/auth/ 下的一个文件,却把 test_payment.py、test_notification.py 全跑了一遍。这些模块跟你的改动毫无关系,纯粹是时间黑洞。
我们做了一套基于 import 链路的依赖分析,只挑出受影响的测试用例来跑。这套方案不依赖语言特性,Python、TypeScript、Go 都能用,核心是静态分析。
第一步,构建模块依赖图。
用 pydeps 或者自己写个 AST 遍历脚本,把项目里每个源文件 import 了哪些模块抽出来,生成一个依赖图。我们项目是 Python 的,直接用了 importlab:
# build_dep_graph.py
from importlab import Environment, parsepy
env = Environment()
# 遍历 src 目录下所有 .py 文件
for py_file in Path("src").rglob("*.py"):
tree = parsepy.parse(py_file.read_text(), str(py_file))
env.add_module(str(py_file), tree)
# 输出依赖关系
for module in env.modules:
deps = [d.name for d in env.get_dependencies(module)]
print(f"{module} -> {deps}")
这个图每次 CI 跑之前重新构建,耗时大概 2-3 秒,完全可接受。构建结果缓存下来,除非 src/ 目录的文件结构变了才会重新算。
第二步,根据 Git diff 找出变更文件,反向查找受影响模块。
拿到当前分支相对于目标分支(一般是 main 或 master)的 diff,找出所有变更的源文件。然后在依赖图里做反向 BFS:从变更文件出发,找出所有直接或间接依赖它的模块。最后,在测试目录里匹配对应的测试文件。
# find_affected_tests.py
import subprocess
from pathlib import Path
# diff 出变更文件
diff_output = subprocess.check_output(
["git", "diff", "--name-only", "origin/main...HEAD", "--", "src/"]
).decode().strip().splitlines()
changed_files = set(diff_output)
# 加载依赖图
dep_graph = load_dep_graph() # 省略具体加载逻辑
# 反向 BFS:谁依赖了变更文件
affected = set(changed_files)
queue = list(changed_files)
while queue:
node = queue.pop(0)
for dependent in dep_graph.get_dependents(node): # 谁 import 了 node
if dependent not in affected:
affected.add(dependent)
queue.append(dependent)
# 映射到测试文件:约定 test_<module>.py 或 tests/<module>/
test_files = set()
for module in affected:
test_path = Path("tests") / module.relative_to("src")
if test_path.with_name(f"test_{test_path.name}").exists():
test_files.add(str(test_path.with_name(f"test_{test_path.name}")))
这个逻辑看起来简单,但实际落地时有几个坑。最大的问题是隐式依赖——比如通过字符串反射、动态 import、或者配置文件里的类名引用。这些静态分析抓不到,会导致漏跑用例。
我们的补救措施是加一个安全兜底:如果 diff 涉及 requirements.txt、pyproject.toml、Dockerfile、或者 CI 配置文件本身,直接触发全量测试。另外,在每个模块的测试里加了一个显式的 __init__.py import 检查,如果发现某个模块的依赖链上新增了文件,也会扩大测试范围。这套机制运行了两个月,只出现了一次漏测,是一个通过 importlib.import_module 动态加载的插件,后来我们把插件注册表也纳入了依赖分析。
第三步,和动态分片组合。
最终 CI 流程变成了这样:
git diff找出变更文件- 依赖分析算出受影响的测试用例
- 如果受影响用例数量超过总用例的 60%,直接跑全量(阈值可调)
- 对受影响用例做动态分片,跑并行测试
- 如果受影响用例少于 60%,只跑受影响用例 + 一个最小冒烟集合(我们挑了 120 个核心用例兜底)
这套组合拳下来,日常 MR 的测试耗时分布变成了:小改动(改一两个文件)平均 3-4 分钟,中等改动(改 5-10 个文件)平均 6-8 分钟,大改动或依赖变更触发全量测试时 12-14 分钟。整体平均压到了 8 分钟以内。
这套方案的成本与取舍
说几个实际落地时容易忽略的点。
历史数据冷启动。 新分支没有 .test_durations.json 时,第一次跑会退化为均分策略,时间会长一些。我们选择在 main 分支上维护这份文件,其他分支 merge 时自动合并。如果团队有多个活跃分支频繁 rebase,可以考虑把 durations 文件放到一个独立的 S3 桶里,按分支名做 key。
依赖图的维护成本。 依赖图构建脚本需要跟着项目结构调整。我们项目 src/ 和 tests/ 的目录结构是对称的,映射规则简单。如果你们的目录结构不规则,可能需要维护一个显式的映射表。这块的投入大概是一次性写 200 行脚本 + 每次目录调整时顺手改一两行,不算大。
假阳性依赖。 有些文件被很多模块 import,但实际改动只影响其中一小部分功能。比如 src/utils/helpers.py 里有 50 个工具函数,你改了一个,依赖分析会把所有 import 了 helpers 的模块都标记为受影响。这种情况会导致测试范围偏大,但不会漏测,属于安全侧的误差。我们暂时没做函数级别的细粒度分析,因为收益不大——从 8 分钟再往下压的边际成本太高了。
CI 配置的复杂度。 相比原来一个 pytest 命令跑完,现在的 CI 配置明显复杂了。多了一个分片脚本、一个依赖分析脚本、一个冒烟测试集合的维护。团队里如果有人不熟悉这套机制,排查 CI 失败时会多花一些时间。我们的对策是把这些逻辑封装成一个内部 CLI 工具,本地也能跑同样的命令,降低认知负担。
常见问题
为什么不直接用 pytest-split 或者 CircleCI 的 test splitting?
pytest-split 的算法和我们做的加权分片本质一样,但它的 durations 数据存在 pytest cache 里,跨 CI Job 共享不方便。CircleCI 的 test splitting 依赖他们的 circleci tests split 命令,绑定了平台。我们这套方案不依赖特定 CI 平台,GitLab、GitHub Actions、Jenkins 都能用,自己控制数据格式也更灵活。
依赖分析漏测了怎么办?
我们设了三层兜底:第一,涉及配置文件、依赖清单、CI 配置的改动强制全量;第二,每次合并到 main 分支时跑一次全量(夜间定时任务也跑一次),漏掉的用例最多在 main 上多存留一个工作日;第三,监控测试覆盖率趋势,如果某次合并后覆盖率突然下降,说明有漏测,触发告警。运行两个月,只触发过一次告警,就是前面提到的动态 import 插件的问题。
小团队值得搞这套吗?
看你的全量测试跑多久。如果已经在 10 分钟以内,没必要。如果在 15-20 分钟,可以先只上动态分片,改动量很小,收益明显。依赖分析那套适合测试用例 1000+、模块边界清晰的项目,小项目强行上反而增加维护负担。我们上这套的时候团队 6 个人,测试用例 3400+,属于刚好值得做的节点。