测试套件跑得太慢怎么优化?有哪些提速手段?
简化版
测试套件慢的代价是隐性但巨大的——它直接决定了开发者「改一行代码后多久能得到反馈」,一旦超过几分钟,大家就不再本地跑测试了,只能推到 CI 等结果,缺陷发现被推迟、迭代节奏被拖垮。优化的第一步永远是测量:pytest --durations=20 列出最慢的 20 个测试,通常你会发现 80% 的时间花在 20% 的测试上,先修那几个的性价比最高。六个提速手段按性价比排序:① 并行执行(pytest -n auto,多核机器上直接快几倍,是最省事的一招);② 提升 fixture 作用域(把「每个测试建一次数据库/启动一次容器」改成 session 级,这往往是最大的单点收益);③ 消灭真实 IO(网络调用 mock 掉、sleep 改成轮询、密码哈希在测试环境降低成本因子——bcrypt 从 12 轮降到 4 轮能快 200 倍);④ 分层执行(用标记把测试分成快/慢/集成,本地和快速 CI 只跑快的);⑤ 增量运行(--lf 只跑上次失败的、--sw stepwise,本地迭代时极大提速);⑥ 减少无谓开销(关掉不必要的插件、精简 conftest、避免 import 时的重活)。两个容易忽略的慢因:测试收集阶段本身可能很慢(大量参数化、conftest 里的重逻辑、import 一堆模块)、以及数据库表的反复建删(改成建库一次 + 每个测试事务回滚)。最后一条原则:测试金字塔就是性能设计——大量的快速单元测试 + 适量集成测试 + 少量端到端,如果你的套件里全是端到端测试,那再怎么调优也快不起来。核心记忆:先用 --durations 测量;-n auto 并行最省事;fixture 作用域是最大单点收益;分层执行 + --lf 做快速反馈。
详细版
提速手段与收益对照:
| 手段 | 典型收益 | 成本 |
|---|---|---|
-n auto 并行 | 快 3~8 倍 | 低(但要处理隔离) |
| 提升 fixture 作用域 | 数倍 | 低(注意状态污染) |
| mock 掉真实 IO | 数倍~数十倍 | 中 |
| 降低 bcrypt 轮数 | 单点 200 倍 | 极低(一行配置) |
| 分层 + 标记 | 快速反馈路径变短 | 低 |
--lf / --sw | 本地迭代极快 | 零 |
# ① ★★第一步:测量★★
# pytest --durations=20 ★列出最慢的 20 个测试★
# pytest --durations=0 --durations-min=1.0 ★所有超过 1 秒的★
# ★输出示例:★
# 12.34s call tests/test_import.py::test_bulk_import
# 8.21s setup tests/test_api.py::test_list ★← setup 慢说明 fixture 有问题★
# 5.02s call tests/test_auth.py::test_login ★← 大概率是 bcrypt★
# ★收集阶段的耗时★
# pytest --collect-only --durations=10
# ★如果收集就要几秒 → conftest 或 import 有重活★
# ② ★★并行:pytest-xdist★★
# pytest -n auto ★按 CPU 核数★
# pytest -n 4 --dist=loadfile ★★同文件的测试在同一 worker(fixture 复用)★★
# pytest -n 4 --dist=loadgroup ★按 xdist_group 标记分组★
@pytest.mark.xdist_group("db") # ★需要同一资源的测试放一起★
def test_x(): ...
# ★★worker 隔离(必做)★★
@pytest.fixture(scope="session")
def db_name(★worker_id★): # ★xdist 提供★
return "test" if worker_id == "master" else f"test_{worker_id}"
# ③ ★★fixture 作用域:最大的单点收益★★
# ✗ 每个测试都建表(100 个测试 = 建表 100 次)
@pytest.fixture
def engine():
e = create_engine(URL); Base.metadata.create_all(e)
yield e
Base.metadata.drop_all(e)
# ✓ ★session 建一次 + function 级事务回滚★
@pytest.fixture(★scope="session"★)
def engine():
e = create_engine(URL); Base.metadata.create_all(e)
yield e
Base.metadata.drop_all(e); e.dispose()
@pytest.fixture # ★function 级,但很轻★
def db(engine):
conn = engine.connect(); trans = conn.begin()
s = Session(bind=conn)
yield s
s.close(); ★trans.rollback()★; conn.close() # ★★毫秒级隔离★★
# ④ ★★测试环境降低加密成本(单点收益最大)★★
# ★bcrypt 默认 12 轮 ≈ 300ms,4 轮 ≈ 1.5ms → ★快 200 倍★★
# conftest.py
@pytest.fixture(autouse=True, scope="session")
def _fast_hashing():
settings.BCRYPT_ROUNDS = 4
# Django
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
# ★★任何"故意慢"的算法在测试里都该降级★★
# ⑤ ★分层与标记★
# pytest -m "not slow and not integration" ★快速反馈★
# pytest -m integration ★集成★
# pytest --runslow ★全量★
# ★按目录自动打标记(conftest.py)★
def pytest_collection_modifyitems(config, items):
for item in items:
p = str(item.fspath)
if "/unit/" in p: item.add_marker(pytest.mark.unit)
elif "/integration/" in p: item.add_marker(pytest.mark.integration)
# ⑥ ★增量运行(本地开发神器)★
# pytest ★--lf★ 只跑上次失败的
# pytest ★--ff★ 先跑上次失败的
# pytest ★--sw★ stepwise:失败就停,下次从这里继续
# pytest ★--nf★ 先跑新文件
# pytest-testmon ★★只跑受代码改动影响的测试★★
# ⑦ ★避免收集期的重活★
# ✗ conftest.py 顶部
★import tensorflow★ # ★★收集时就 import,几秒★★
★MODELS = load_all_models()★ # ★★收集时就加载★★
# ✓ 放进 fixture 里惰性加载
@pytest.fixture(scope="session")
def model():
import tensorflow as tf # ★用到才 import★
return tf.keras.models.load_model(...)
⚠️ 三个必须记住的点:① 优化前必须先测量,而且要看
setup和call的区分。pytest --durations=20的输出里,每行开头会标明这个时间花在setup、call还是teardown上——如果大量测试的setup很慢,说明是 fixture 的问题(作用域太窄、每次都重建资源),改一处就能让几百个测试同时提速;如果是call慢,那就是测试本身的逻辑或它触发的 IO。80/20 定律在这里非常明显:通常前 10 个最慢的测试占了总时间的一大半。② 提升 fixture 作用域是最大的单点收益,但要配合数据隔离。「每个测试都建表、建容器、启动服务」是最常见的慢因——把它们提升到session作用域,只建一次,然后用轻量的 function 级隔离(数据库事务回滚、Redis 用不同 db 号、临时目录用tmp_path)保证测试之间互不影响。注意 session 级 fixture 在 xdist 下每个 worker 会各执行一次,所以要么接受这个成本,要么用--dist=loadfile减少 worker 间的重复。③ 测试环境要把「故意慢」的算法降级。bcrypt、argon2、scrypt 这类密码哈希函数设计目标就是慢(抵抗暴力破解),默认参数下一次哈希要 100~300ms——如果你的测试里有几百次用户创建或登录,光这一项就是几十秒。测试环境把成本因子降到最低(bcrypt 4 轮、Django 换 MD5 hasher)能快 200 倍,而且是一行配置的事。同类的还有:故意的重试延迟、限流的等待、加密密钥派生。
完整版教学
一、慢的代价与测量
★ ★★反馈时长决定了开发方式★★:
┌────────────────────────────────────────────────────┐
│ ★< 10 秒★ → ★随时跑,改一行跑一次(最佳)★ │
│ ★< 1 分钟★ → 提交前跑一次 │
│ ★1~5 分钟★ → ★开始不耐烦,只跑相关的★ │
│ ★> 5 分钟★ → ★★本地不跑了,全靠 CI★★ │
│ ★> 20 分钟★ → ★CI 也成瓶颈,PR 排队★ │
└────────────────────────────────────────────────────┘
★ ★关键阈值是"本地还跑不跑"★
→ ★一旦不跑,缺陷发现推迟到 CI,修复成本上升★
★ ★★第一步:pytest --durations★★:
pytest --durations=20
# ★输出(注意 setup/call/teardown 的区分)★
★12.34s call★ tests/test_import.py::test_bulk_import
★8.21s setup★ tests/test_api.py::test_list_users
★5.02s call★ tests/test_auth.py::test_login
★3.11s teardown★ tests/test_db.py::test_migration
★ ★怎么读★:
- ★大量测试的 setup 慢 → fixture 作用域问题(改一处收益最大)★
- ★call 慢 → 测试逻辑本身或它触发的 IO★
- ★teardown 慢 → 清理逻辑(drop table?关容器?)★
★ ★常用的测量命令★:
pytest ★--durations=0 --durations-min=1.0★ # 所有超过 1 秒的
pytest ★--collect-only --durations=10★ # ★收集阶段的耗时★
pytest ★-p no:cacheprovider --durations=20★ # 排除缓存影响
# ★整体计时★
time pytest
# ★分目录看★
for d in tests/*/; do echo "$d"; time pytest "$d" -q; done
★ ★★80/20 定律在测试里非常明显★★:
典型分布:
★前 10 个最慢的测试 ≈ 总时间的 40%★
★前 50 个 ≈ 总时间的 70%★
→ ★★先修最慢的十几个,性价比最高★★
→ ★不要一上来就"全面优化"★
★ ★收集阶段(collection)也可能很慢★:
pytest --collect-only -q | tail -1
# ★如果收集就要 5 秒 → 问题在★:
✗ ★conftest.py 顶部 import 重模块★(tensorflow、pandas)
✗ ★模块级的初始化代码★(加载模型、连数据库、读大文件)
✗ ★参数化的数据在收集期计算★
✗ ★测试文件太多且每个都 import 大量东西★
✓ ★把重活挪进 fixture(惰性)★
✓ ★用 --ignore 排除不需要的目录★
★ ★CI 上的额外开销(★别只优化测试本身★)★:
┌────────────────────────────────────┬──────────┐
│ ★checkout 代码★ │ 5~30s │
│ ★★安装依赖(不缓存的话)★★ │ ★1~5 分钟★│
│ ★启动数据库/Redis 容器★ │ 10~60s │
│ ★构建镜像★ │ 1~10 分钟 │
│ ★测试本身★ │ ? │
└────────────────────────────────────┴──────────┘
✓ ★缓存依赖(uv/pip cache、poetry cache)★
✓ ★用预装好依赖的基础镜像★
✓ ★服务容器复用★
★ ★很多时候"CI 慢"的大头不是测试★
反馈时长决定了开发方式——关键阈值是「本地还跑不跑」:超过 5 分钟大家就只推 CI 等结果,缺陷发现被推迟。测量的第一个命令是 pytest --durations=20,读它时要注意 setup/call/teardown 的区分——大量测试的 setup 慢说明是 fixture 作用域问题,改一处收益最大。80/20 定律在测试里非常明显(前 10 个最慢的通常占总时间 40%),先修最慢的十几个而不是全面优化。收集阶段也可能很慢——conftest.py 顶部 import 重模块、模块级初始化、参数化数据在收集期计算,解法是把重活挪进 fixture 惰性加载。最后提醒:「CI 慢」的大头往往不是测试本身,而是安装依赖、构建镜像、启动容器——先看总时长的构成。
二、并行执行
★ ★★pytest-xdist:最省事的提速手段★★:
pip install pytest-xdist
pytest ★-n auto★ # 按 CPU 核数
pytest ★-n 4★
pytest ★-n auto --dist=loadfile★ # ★★同文件在同一 worker★★
★ ★典型收益:8 核机器上快 4~6 倍★(不是 8 倍,有调度和隔离开销)
★ ★--dist 的四种模式★:
┌──────────────────┬────────────────────────────────┐
│ ★load(默认)★ │ ★逐个分发,负载最均衡★ │
│ │ ✗ ★同文件的测试可能分散★ │
│ ★loadfile★ │ ★★同一文件的测试给同一 worker★★ │
│ │ ✓ ★module 级 fixture 能复用★ │
│ ★loadscope★ │ ★同 class/module 给同 worker★ │
│ ★loadgroup★ │ ★按 @pytest.mark.xdist_group 分★ │
│ ★no★ │ 不分发(只用 xdist 的其他功能) │
└──────────────────┴────────────────────────────────┘
★ ★经验:有 module/class 级 fixture 时用 loadfile 更快★
(默认的 load 会导致 fixture 反复重建)
★ ★★并行的三类问题★★:
① ★★session fixture 每个 worker 执行一遍★★
8 个 worker × 建表 3 秒 = ★24 秒的重复开销★
✓ ★用 worker_id 判断,只让一个 worker 建,其他等待★:
@pytest.fixture(scope="session")
def db(worker_id, tmp_path_factory):
if worker_id == "master":
return setup_db()
★root = tmp_path_factory.getbasetemp().parent★ # ★共享目录★
lock = root / "db.lock"
with FileLock(str(lock) + ".lock"):
if not (root / "db.ready").exists():
setup_db()
(root / "db.ready").touch()
return connect_db()
✓ ★或者干脆接受这个成本(如果建表很快)★
② ★★资源冲突★★
- ★数据库★:每 worker 一个库(test_gw0、test_gw1)
- ★Redis★:每 worker 一个 db 号
- ★端口★:bind 到 0 让系统分配
- ★文件★:用 tmp_path(pytest 自动隔离)
- ★外部服务★:mock 掉
③ ★★覆盖率统计★★
pytest -n auto ★--cov=src --cov-append★
# ★或用 coverage combine★
★ ✗ 不处理的话各 worker 的数据会互相覆盖
★ ★并行的收益边界★:
★ ✓ ★测试之间独立、CPU 核多 → 收益接近线性★
★ ✗ ★瓶颈是数据库时 → 并行反而更慢★(争抢同一个数据库)
→ ✓ 每 worker 独立数据库
★ ✗ ★测试很少(< 50 个)→ 启动开销占比高★
★ ✗ ★内存不够 → 每个 worker 是独立进程★
★ ★另一种并行:分片(CI 上更常用)★:
# ★把测试分给多个 CI job★
pytest ★--splits 4 --group 1★ # pytest-split
# 或按目录手动分
job1: pytest tests/unit
job2: pytest tests/integration
job3: pytest tests/api
★ ✓ ★突破单机核数限制★
★ ✓ pytest-split 能按★历史耗时★均衡分配
★ ✗ 每个 job 都要重新装依赖、起服务
★ ★组合使用★:
★ CI 上:★4 个 job × 每个 -n 4 = 16 路并行★
✓ 最快,但要注意★数据库连接数★和成本
pytest -n auto 是最省事的提速手段,8 核机器上典型收益是 4~6 倍(不是 8 倍,有调度开销)。--dist 模式要选对:默认的 load 逐个分发负载最均衡,但会让同文件的测试分散、导致 module 级 fixture 反复重建;有 module/class 级 fixture 时用 loadfile 更快。并行有三类问题:session fixture 每个 worker 执行一遍(8 个 worker × 建表 3 秒 = 24 秒重复开销,可以用文件锁让一个 worker 建、其他等待)、资源冲突(数据库、Redis db 号、端口、文件都要按 worker 隔离)、覆盖率统计要 --cov-append。收益边界要知道:瓶颈是数据库时并行反而更慢(争抢同一个库),测试少于 50 个时启动开销占比高。CI 上还可以用分片(pytest-split 按历史耗时均衡分配到多个 job),和 -n 组合能实现 16 路并行。
三、fixture 与资源复用
★ ★★作用域的成本对比★★:
┌──────────────┬──────────────────────────────────┐
│ ★function★ │ ★每个测试★——最安全,最慢 │
│ ★class★ │ 每个测试类 │
│ ★module★ │ 每个文件 │
│ ★package★ │ 每个包 │
│ ★session★ │ ★★整个运行一次——最快,要小心状态★★ │
└──────────────┴──────────────────────────────────┘
★ ★★标准组合:重资源 session + 轻隔离 function★★:
# ★重的:整个 session 一次★
@pytest.fixture(scope="session")
def engine():
e = create_engine(TEST_URL)
Base.metadata.create_all(e) # ★★建表:只做一次★★
yield e
Base.metadata.drop_all(e); e.dispose()
@pytest.fixture(scope="session")
def app(engine):
return create_app(engine) # ★★应用初始化:一次★★
# ★轻的:每个测试隔离★
@pytest.fixture
def db(engine):
conn = engine.connect()
trans = conn.begin() # ★★开事务★★
s = Session(bind=conn)
yield s
s.close(); ★trans.rollback()★; conn.close() # ★★毫秒级★★
★ ★效果:建表 3 秒 × 1 次 + 事务回滚 1ms × 1000 次★
★ 对比:建表 3 秒 × 1000 次 = 50 分钟★
★ ★★容器复用(testcontainers)★★:
@pytest.fixture(scope=★"session"★)
def postgres():
with PostgresContainer("postgres:16") as pg:
yield pg.get_connection_url()
★ ★启动容器 3~10 秒 → 一定要 session 级★
✓ ★本地开发用 docker-compose 长期运行的服务,别每次起容器★
✓ ★testcontainers 的 reuse 功能(需要配置)★
★ ★★autouse fixture 的隐性成本★★:
@pytest.fixture(autouse=True)
def _setup():
★expensive_setup()★ # ★★每个测试都跑!★★
yield
cleanup()
★ ✗ ★autouse + function 级 + 重逻辑 = 灾难★
✓ ★只对需要的测试用(显式声明)★
✓ ★或者提升作用域★
✓ ★检查:pytest --setup-show 看实际执行了什么★
★ ★惰性 fixture(用到才创建)★:
# ✗ 不管用不用都创建
@pytest.fixture(scope="session")
def ml_model():
return load_huge_model() # ★★30 秒★★
# ✓ ★只有依赖它的测试才会触发★(fixture 本来就是惰性的)
★ ★但要注意:autouse 会破坏惰性★
★ ★缓存昂贵的计算★:
@pytest.fixture(scope="session")
def compiled_templates():
return compile_all_templates() # ★一次★
# ★或者用 functools.lru_cache★
@lru_cache(maxsize=1)
def get_test_data():
return json.load(open("fixtures/big.json"))
★ ★★数据库测试的三种隔离(速度对比)★★:
┌──────────────────────┬────────────┬──────────────┐
│ ★事务回滚★ │ ★★~1ms★★ │ ★推荐★ │
│ ★TRUNCATE 所有表★ │ ~10~50ms │ 有 commit 时 │
│ ★重建数据库★ │ ★~1~5s★ │ ★★太慢★★ │
└──────────────────────┴────────────┴──────────────┘
★ ★被测代码有 commit 时,用 SAVEPOINT 嵌套事务保持回滚能力★
★ ★减少测试数据的准备成本★:
✗ 每个测试插 1000 条数据
✓ ★只插测试真正需要的最少数据★
✓ ★session 级准备"公共的只读数据"(字典表、配置)★
✓ ★用工厂按需创建,而不是加载大 fixture 文件★
标准组合是「重资源 session 级 + 轻隔离 function 级」——建表 3 秒做一次、每个测试用事务回滚(毫秒级)隔离;对比「每个测试都建表」,1000 个测试就是 50 分钟 vs 3 秒。容器(testcontainers)启动要 3~10 秒,一定要 session 级,本地开发更推荐用 docker-compose 长期运行的服务。autouse fixture 有隐性成本——autouse + function 级 + 重逻辑是灾难(每个测试都跑),要么只对需要的测试显式声明、要么提升作用域,用 pytest --setup-show 看实际执行了什么。数据库隔离的三种方式速度差距巨大:事务回滚约 1ms、TRUNCATE 约 1050ms、重建数据库要 15 秒——被测代码有 commit 时用 SAVEPOINT 嵌套事务保持回滚能力。
四、消灭慢操作
★ ★★① 密码哈希(单点收益最大)★★:
★ bcrypt/argon2/scrypt ★设计目标就是慢★(抗暴力破解)
★ 默认参数:bcrypt 12 轮 ≈ ★300ms★
★ 测试环境:4 轮 ≈ ★1.5ms★ → ★★快 200 倍★★
# ★配置化★
BCRYPT_ROUNDS = 4 if TESTING else 12
# Django
★PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]★
# passlib
★pwd_context = CryptContext(schemes=["bcrypt"], bcrypt__rounds=4)★
★ ★如果你的测试里有几百次登录/建用户,这一项能省几十秒★
★ ★★② sleep★★:
✗ ★time.sleep(2)★ × 50 个测试 = ★100 秒白等★
✓ ★轮询★:wait_until(cond, timeout=10, interval=0.05)
→ ★通常几十毫秒就满足★
✓ ★冻结时间★:freezegun 的 tick() 直接推进
✓ ★可注入的时钟★
★ ★③ 网络调用★:
✗ 真实调用第三方 API(★几百毫秒 + 不稳定★)
✓ ★respx / responses 拦截★
✓ ★pytest-socket 全局禁网★(漏掉的会立刻报错)
★ ★DNS 解析本身也可能几十毫秒★
★ ★④ 文件 IO★:
✗ 每个测试读写大文件
✓ ★用 tmp_path(在 tmpfs 上更快)★
✓ ★内存文件:io.BytesIO / io.StringIO★
✓ ★CI 上把临时目录指到 tmpfs★:
export ★TMPDIR=/dev/shm★ # Linux
★ ★★⑤ 数据库操作★★:
✗ ★每个测试插入大量数据★
✓ ★只准备必要的最少数据★
✓ ★批量插入代替循环 save★
✓ ★session 级准备只读的公共数据★
# ★关掉不必要的开销★
✓ ★测试库关掉 fsync★(PostgreSQL):
★postgres -c fsync=off -c synchronous_commit=off
-c full_page_writes=off★
★ ★测试库不需要崩溃恢复保证 → 能快好几倍★
✓ ★用 tmpfs 上的数据库★(数据全在内存)
✓ ★SQLite 内存库★(★但要注意行为差异★)
★ ★⑥ import 与模块初始化★:
✗ 每个测试文件 import tensorflow / pandas / 大型 SDK
✓ ★惰性 import(放进函数或 fixture)★
✓ ★检查:python -X importtime -c "import myapp" | sort -k2 -rn | head★
★ ★⑦ 日志和输出★:
✗ ★测试时开 DEBUG 级别 + 大量输出★
✓ ★测试环境 WARNING 级别★
✓ ★sqlalchemy echo=False★
✓ ★pytest 默认会捕获输出(-s 会关掉捕获,反而可能变慢)★
★ ★⑧ 覆盖率的开销★:
★ coverage 会给每行加钩子 → ★典型开销 10~30%★
✓ ★本地开发不开覆盖率★
✓ ★只在 CI 的特定 job 开★
✓ ★用 sysmon(Python 3.12+ 的新 API)★:
★COVERAGE_CORE=sysmon pytest --cov★ # ★★开销大幅降低★★
密码哈希是单点收益最大的优化——bcrypt 从 12 轮降到 4 轮快 200 倍,如果测试里有几百次登录就能省几十秒。sleep 要改成轮询(50 个测试各 sleep 2 秒就是 100 秒白等)。数据库有两个容易忽略的优化:测试库关掉 fsync 和 synchronous_commit(测试库不需要崩溃恢复保证,能快好几倍)、把数据目录放在 tmpfs 上。import 慢可以用 python -X importtime 定位。覆盖率本身有 10~30% 的开销——本地开发不开、只在 CI 特定 job 开,Python 3.12+ 可以用 COVERAGE_CORE=sysmon 大幅降低开销。
五、分层与增量
★ ★★测试金字塔就是性能设计★★:
┌──────────────────────────────────────────────────┐
│ ★E2E(少,慢)★ :几十个,每个几秒~几十秒 │
│ ★集成(适量,中)★ :几百个,每个几十~几百毫秒 │
│ ★单元(多,快)★ :几千个,★每个几毫秒★ │
└──────────────────────────────────────────────────┘
★ ★如果你的套件是"倒三角"(大量 E2E),再怎么调优也快不了★
→ ★★根本解法是把测试往下推★★
★ ★标记分层★:
# pyproject.toml
markers = ["slow", "integration", "e2e"]
# ★本地/快速 CI★
pytest ★-m "not slow and not integration and not e2e"★ -n auto
# ★PR 合并前★
pytest -m "not e2e"
# ★nightly★
pytest
★ ★★增量运行(本地开发神器)★★:
pytest ★--lf★ # ★只跑上次失败的★
pytest ★--ff★ # 先跑上次失败的,再跑其他
pytest ★--sw★ # ★★stepwise:失败就停,修好后从这里继续★★
pytest ★--sw --sw-skip★ # 跳过当前这个继续
pytest ★--nf★ # 先跑新增的文件
★ ★典型工作流★:
① pytest --sw # 跑到第一个失败停下
② 修代码
③ pytest --sw # ★★从刚才那个继续★★
★ ★★pytest-testmon:只跑受影响的测试★★:
pip install pytest-testmon
pytest ★--testmon★
★ ★原理:记录每个测试执行了哪些代码行★
→ ★改动某个文件时,只跑覆盖到那些行的测试★
★ ✓ ★大型项目本地开发提速极明显★
★ ✗ 首次要全量跑一遍建立映射
★ ✗ ★数据文件要维护★(.testmondata)
★ ✗ 和 xdist 配合有限制
★ ★按文件路径快速筛★:
pytest ★tests/unit★ # 只跑一个目录
pytest ★tests/test_user.py::TestAuth::test_login★
pytest ★-k "user and not admin"★
★ ★IDE 里点单个测试跑是最快的反馈方式★
★ ★CI 的分阶段策略★:
┌────────────────────────────────────────────────┐
│ ★阶段 1(每次 push,目标 < 2 分钟)★ │
│ lint + 类型检查 + 快速单元测试 │
│ ★阶段 2(PR,目标 < 10 分钟)★ │
│ 全部单元 + 集成测试(★并行 + 分片★) │
│ ★阶段 3(合并到主干)★ │
│ + E2E │
│ ★阶段 4(nightly)★ │
│ + 慢测试 + 属性测试高强度 + 兼容性矩阵 │
└────────────────────────────────────────────────┘
★ ★核心:让"最常发生的操作"最快★
★ ★fail fast★:
pytest ★-x★ # 第一个失败就停
pytest ★--maxfail=5★ # ★失败 5 个就停(CI 上常用)★
★ ✓ ★挂了就早点告诉我,别浪费 10 分钟跑完★
★ ✗ 但会看不到全部失败 → ★权衡★
★ ★CI 缓存(★常被忽略的大头★)★:
✓ ★依赖缓存★:pip/uv/poetry 的 cache 目录
✓ ★预构建镜像★:把依赖装进基础镜像
✓ ★.pytest_cache★(--lf 需要)
✓ ★.hypothesis★(属性测试的示例库)
✓ ★服务容器复用★(GitHub Actions 的 services)
★ ★装依赖 3 分钟 + 测试 1 分钟 = 优化测试没意义★
测试金字塔就是性能设计——如果套件是「倒三角」(大量 E2E),再怎么调优也快不了,根本解法是把测试往下推。增量运行是本地开发神器:--sw(stepwise)的工作流是「跑到第一个失败停下 → 修 → 再跑从刚才那个继续」,比每次全量快得多。pytest-testmon 更进一步——记录每个测试执行了哪些代码行,改动某个文件时只跑覆盖到那些行的测试,大型项目提速极明显。CI 要分阶段,核心原则是**「让最常发生的操作最快」**(每次 push 只跑 lint + 快速单元测试,目标 2 分钟)。最后提醒:CI 缓存是常被忽略的大头——装依赖 3 分钟而测试只要 1 分钟时,优化测试是没意义的。
六、实践清单
★ ★优化的正确顺序★:
① ★测量★(--durations=20,看 setup/call 的分布)
② ★修最慢的 10 个测试★(80/20)
③ ★检查 fixture 作用域★(setup 慢就是它)
④ ★降低测试环境的加密成本★(一行配置,收益巨大)
⑤ ★消灭 sleep 和真实网络★
⑥ ★开并行 -n auto★
⑦ ★分层 + 标记★
⑧ ★CI 缓存和分片★
⑨ ★把 E2E 往下推成单元测试★(根本but最慢的解法)
★ 检查清单:
【测量】
□ ★定期看 --durations=20★
□ ★检查收集阶段耗时(--collect-only)★
□ ★CI 总时长的构成(装依赖 vs 测试)★
【fixture】
□ ★重资源用 session 级★
□ ★数据隔离用事务回滚(不是重建库)★
□ ★autouse fixture 里没有重逻辑★
□ ★用 --setup-show 检查实际执行★
【慢操作】
□ ★测试环境 bcrypt rounds 降到 4★
□ ★没有 time.sleep(用轮询)★
□ ★网络全 mock(pytest-socket 兜底)★
□ ★测试数据库关 fsync★
□ ★日志级别 WARNING、sqlalchemy echo=False★
【并行】
□ ★-n auto 且资源按 worker 隔离★
□ ★有 module fixture 时用 --dist=loadfile★
□ ★覆盖率加 --cov-append★
【流程】
□ ★标记分层,本地只跑快的★
□ ★本地用 --lf / --sw★
□ ★CI 分阶段 + 缓存依赖★
★ ★一个真实的优化案例(典型形态)★:
★优化前:1200 个测试,18 分钟★
① --durations 发现:★400 个测试的 setup 各 1.5 秒★(每次建表)
→ ★改成 session 建表 + 事务回滚★ → ★★省 10 分钟★★
② 登录相关 200 个测试,★每个 300ms 花在 bcrypt★
→ ★rounds 12→4★ → ★★省 1 分钟★★
③ 30 个测试有 time.sleep(1)
→ ★改轮询★ → ★省 30 秒★
④ ★开 -n 4★ → ★★剩下的 6 分钟 → 1.8 分钟★★
★优化后:约 2 分钟★
★ 一句话总结:
★"先用 --durations=20 测量(注意 setup 慢=fixture 问题),
80% 的时间通常在 20% 的测试上;
最大的单点收益是『重资源提升到 session 级 + 事务回滚做隔离』
和『测试环境把 bcrypt 轮数降到 4』;
然后开 -n auto 并行、消灭 sleep 和真实网络、用标记分层;
本地用 --lf/--sw 做增量——但如果套件是倒三角(全是 E2E),
根本解法是把测试往下推。"★
优化的正确顺序是「测量 → 修最慢的 10 个 → 检查 fixture 作用域 → 降低加密成本 → 消灭 sleep 和网络 → 开并行 → 分层 → CI 缓存」。最后那个案例是典型形态:1200 个测试 18 分钟,靠「session 建表 + 事务回滚」省 10 分钟、「bcrypt 降轮数」省 1 分钟、「sleep 改轮询」省 30 秒、最后开 4 路并行,总共降到 2 分钟——收益最大的两项都是配置层面的改动,不需要重写测试。
记忆钩子:「★测试慢的代价是隐性但巨大的——关键阈值是『本地还跑不跑』★,超过 5 分钟大家就只推 CI 等结果,★缺陷发现被推迟、迭代节奏被拖垮★。★优化第一步永远是测量:pytest —durations=20★,读的时候★注意 setup/call/teardown 的区分——大量测试的 setup 慢就是 fixture 作用域问题,改一处能让几百个测试同时提速★;★80/20 定律非常明显(前 10 个最慢的通常占总时间 40%),先修最慢的十几个而不是全面优化★。★六个手段按性价比★:★① -n auto 并行(8 核快 4~6 倍,最省事)★——注意 ★—dist=loadfile 让同文件的测试在同一 worker 以复用 module 级 fixture★、★session fixture 每个 worker 会各执行一遍★、★资源要按 worker_id 隔离★、★覆盖率要 —cov-append★,而且★瓶颈是数据库时并行反而更慢★;★② 提升 fixture 作用域是最大的单点收益★——标准组合是★『重资源 session 级建一次 + function 级事务回滚做隔离』★,对比每个测试都建表就是★3 秒 vs 50 分钟★;数据库隔离三种方式★事务回滚 ~1ms、TRUNCATE ~10-50ms、重建库
1-5s★;★③ 消灭真实 IO★——★bcrypt 从 12 轮降到 4 轮快 200 倍(单点收益最大,一行配置)★、★sleep 改轮询★、网络用 respx 拦截并用 ★pytest-socket 兜底★、★测试库关 fsync 和 synchronous_commit 能快好几倍★;★④ 标记分层(本地和快速 CI 只跑快的)★;★⑤ 增量运行★——★—lf 只跑上次失败的、—sw stepwise(跑到失败停下、修好后从这里继续)★、★pytest-testmon 只跑受代码改动影响的测试★;★⑥ 减少无谓开销★——★收集阶段也可能很慢(conftest 顶部 import 重模块、模块级初始化),要把重活挪进 fixture 惰性加载★、★autouse+function 级+重逻辑是灾难★、★覆盖率有 1030% 开销(Python 3.12+ 可用 COVERAGE_CORE=sysmon 降低)★。★两个容易忽略的★:★CI 总时长的大头往往是装依赖和构建镜像而不是测试本身(要缓存)★、★测试金字塔就是性能设计——套件如果是倒三角(全是 E2E),再怎么调优也快不了,根本解法是把测试往下推★。」
七、常见误区与追问
- 误区:测试慢就慢点,反正 CI 会跑,不影响开发。 它直接改变了开发者的行为方式。当本地跑一次测试要 10 分钟,没人会在改完一行代码后跑测试——大家会攒一批改动、直接推到 CI 等结果。这带来三个连锁反应:① 缺陷发现被推迟(本来改完 10 秒就能发现的错误,变成推上去等 10 分钟);② 上下文切换成本(等 CI 的时候去干别的,回来要重新进入状态);③ 重构意愿下降(没有快速的安全网,大家不敢动老代码)。而且这是恶性循环:因为不常跑,测试失败会累积、flaky 增多,套件的可信度进一步下降。关键阈值是「本地还跑不跑」——把常用路径(改一个模块跑相关测试)压到 10 秒以内,收益远超优化本身花的时间。
- 误区:优化测试就是把慢的测试改快。 要先看时间花在
setup还是call上。pytest --durations=20的输出里每行都标了阶段,如果你看到大量测试的setup各花 1~2 秒,那问题不在测试本身而在 fixture——很可能是每个测试都在建表、启动容器、加载模型。这种情况下改一处 fixture 的作用域,几百个测试同时提速,比逐个优化测试逻辑高效得多。反过来如果call慢,才是测试本身的问题(可能它触发了真实的 IO、循环了太多次、或者数据量太大)。还有第三种情况是teardown慢(drop table、停容器)——同样是 fixture 设计问题。所以正确的顺序是:先看阶段分布,再决定优化方向。 - 误区:把 fixture 都改成
scope="session"就能大幅提速。 会引入严重的状态污染。session 级 fixture 在整个测试运行期间只创建一次,所有测试共享同一个实例——如果那是一个数据库 session、一个应用实例、或者任何可变对象,一个测试的修改会影响后面所有测试,立刻产生顺序依赖(典型症状:单跑过、全跑挂)。正确的模式是分离「资源」和「状态」:重的资源提升到 session 级(数据库引擎、建表、容器、编译好的模板、加载好的模型——这些是只读或幂等的),每个测试的数据隔离用轻量的 function 级机制(数据库事务回滚约 1ms、Redis 用不同 db 号、tmp_path临时目录)。另外要注意 session 级 fixture 在 xdist 下每个 worker 都会执行一次——8 个 worker 就是 8 次,如果那个 setup 要 5 秒,你就付出了 40 秒的重复开销。 - 误区:并行(
-n auto)总是能提速。 有三种情况会失效甚至更慢。① 瓶颈是共享资源——如果所有 worker 都在争抢同一个数据库,并行只会增加锁竞争和连接压力,总时间可能不降反升;解法是每个 worker 用独立的数据库(test_gw0、test_gw1)。② 测试太少——xdist 要启动 N 个进程、分发任务、收集结果,这些固定开销在只有几十个测试时占比很高,可能比串行还慢。③ session/module 级 fixture 被重复创建——默认的--dist=load是逐个分发测试,同一个文件的测试可能落到不同 worker 上,导致 module 级 fixture 反复重建;用--dist=loadfile让同文件的测试留在同一个 worker 能避免。此外还要注意内存(每个 worker 是独立进程,加载大模型时 8 个 worker 就是 8 份内存)和覆盖率统计(需要--cov-append或coverage combine)。 - 误区:
bcrypt是安全相关的,测试里也不该改参数。 测试环境降低成本因子不但安全,而且是标准做法。bcrypt、argon2、scrypt 这类密码哈希函数的设计目标就是「计算代价高」——默认 12 轮的 bcrypt 一次哈希要 200~300ms,这正是它抵抗暴力破解的机制。但在测试环境里,你验证的是「密码校验逻辑对不对」而不是「哈希强度够不够」,成本因子调到最低(bcrypt 4 轮、Django 直接换MD5PasswordHasher)完全不影响测试的有效性,却能带来 200 倍的单点提速——如果套件里有几百次用户创建和登录,光这一项就能省几十秒到几分钟。关键是通过配置区分环境(BCRYPT_ROUNDS = 4 if TESTING else 12),确保生产配置不受影响;同时保留少量专门验证「密码哈希使用了正确算法和参数」的测试。同理适用于:重试延迟、限流窗口、token 过期时间。 - 追问:
pytest-testmon的原理是什么?值得用吗? 它的原理是记录每个测试执行过哪些代码行(基于 coverage 的追踪机制),把这个映射存在.testmondata里;下次运行时对比代码的变化,只跑那些「覆盖到被修改代码」的测试。效果很显著:在一个几千测试的项目里改一个模块,可能只需要跑几十个相关测试,从几分钟降到几秒。适合本地开发的快速迭代。但有几个限制要知道:① 首次要全量跑一遍建立映射;②.testmondata需要维护(换分支、大重构后可能要重建);③ 和 xdist 配合有限制;④ 它只能追踪 Python 代码的变化——如果你改的是数据文件、模板、配置或者外部依赖,它可能判断「无需重跑」而漏掉真正受影响的测试;⑤ CI 上不该用(CI 应该跑全量,保证覆盖)。所以定位很清晰:本地开发的加速器,不是 CI 的替代方案。一个更保守的替代是--lf/--sw(基于上次失败,不需要额外数据)。 - 追问:CI 上除了优化测试还能做什么? 先看总时长的构成——很多时候测试本身不是大头。一次典型的 CI 运行包含:checkout(5
30 秒)、安装依赖(不缓存的话 1~5 分钟)、构建镜像(110 分钟)、启动服务容器(10~60 秒)、跑测试。如果装依赖要 3 分钟而测试只要 1 分钟,优化测试的收益就很有限。四个手段:① 缓存依赖——缓存 pip/uv/poetry 的下载目录和虚拟环境,用 lock 文件的 hash 作为 cache key;② 预构建基础镜像——把稳定的依赖装进一个自己维护的基础镜像,CI 里直接用,省掉每次安装;③ 分片并行——用pytest-split按历史耗时把测试均匀分到多个 job,突破单机核数限制(4 个 job × 每个-n 4= 16 路并行);④ 分阶段执行——每次 push 只跑 lint 加快速单元测试(2 分钟内给反馈),完整测试留给 PR 和合并。另外别忘了--maxfail=5:已经挂了 5 个的情况下,跑完剩下的 10 分钟意义不大。 - 追问:测试套件已经是「倒三角」(大量端到端测试)了,怎么改? 这是最根本但也最慢的优化,要分阶段做。首先要认清:端到端测试单个要几秒到几十秒(启动浏览器、走完整链路、等待异步操作),几百个 E2E 测试注定要跑几十分钟,任何调优手段都只能改善边际。改造路径:① 先止血——给现有 E2E 打标记分层,只有核心的「关键用户旅程」(登录、下单、支付)保留在每次 CI,其余移到 nightly;② 分析覆盖——找出那些「E2E 在验证的其实是纯业务逻辑」的测试(比如「折扣计算对不对」通过点击界面来验证),这些应该下沉成单元测试;③ 补中间层——很多 E2E 存在是因为「没有 API 层测试」,补上契约测试和集成测试后,E2E 可以只保留「界面能正确调用 API」的验证;④ 每次新增功能时,优先在低层写测试,逐步改变比例。关键心态是别指望一次性重构完——测试金字塔是长期演进的结果,先保证「新代码按金字塔写」,存量慢慢下沉。
八、加强记忆
测试慢的代价是隐性但巨大的——关键阈值是「本地还跑不跑」:超过 5 分钟大家就只推 CI 等结果,缺陷发现被推迟、迭代节奏被拖垮。优化的第一步永远是测量:pytest --durations=20,读的时候注意 setup/call/teardown 的区分——大量测试的 setup 慢就是 fixture 作用域问题,改一处能让几百个测试同时提速;80/20 定律非常明显(前 10 个最慢的通常占总时间 40%),先修最慢的十几个而不是全面优化。六个手段按性价比排序:① -n auto 并行(8 核快 46 倍,最省事)——注意 50ms、重建库 1~5 秒**;③ 消灭真实 IO——bcrypt 从 12 轮降到 4 轮快 200 倍(单点收益最大,一行配置)、--dist=loadfile 让同文件的测试留在同一 worker 以复用 module 级 fixture、session fixture 每个 worker 会各执行一遍、资源要按 worker_id 隔离、覆盖率要加 --cov-append,而且瓶颈是数据库时并行反而更慢;② 提升 fixture 作用域是最大的单点收益——标准组合是**「重资源 session 级建一次 + function 级事务回滚做隔离」,对比「每个测试都建表」就是 3 秒 vs 50 分钟;数据库隔离三种方式的速度是事务回滚约 1ms、TRUNCATE 约 10sleep 改轮询、网络用 respx 拦截并用 pytest-socket 兜底、测试库关掉 fsync 和 synchronous_commit 能快好几倍;④ 标记分层(本地和快速 CI 只跑快的);⑤ 增量运行——--lf 只跑上次失败的、--sw stepwise(跑到失败停下、修好后从这里继续)、pytest-testmon 只跑受代码改动影响的测试;⑥ 减少无谓开销——收集阶段也可能很慢(conftest 顶部 import 重模块、模块级初始化),要把重活挪进 fixture 惰性加载、autouse + function 级 + 重逻辑是灾难、覆盖率有 10~30% 的开销(Python 3.12+ 可用 COVERAGE_CORE=sysmon 降低)。两个容易忽略的点:CI 总时长的大头往往是装依赖和构建镜像而不是测试本身(要做缓存)、测试金字塔本身就是性能设计——套件如果是倒三角(全是 E2E),再怎么调优也快不了,根本解法是把测试往下推。