↓ 跳过正文

Python Django Pytest

pytest 加速:用 pytest-testmon 只跑受代码改动影响的测试

pytest-testmon 是一个 pytest 插件,只跑代码改动影响到的测试,也就是 Python 的测试影响分析,用来给 pytest 加速。它借助 coverage.py 记录每个测试执行了哪些代码块,下次只跑代码块有变化的测试,以及上次失败的测试。这篇写的是我为什么把它加进一套有 2,248 个测试、跑得很慢的 Django 测试套件(一个小功能需求不该等整套测试跑完),它怎么决定跑什么,一份分步教程(包括补上它看不到的那部分的包装脚本),在 Python 3.14+ 上少了它就会悄悄漏跑测试的那个 coverage.py 设置,它对编程 agent 有什么用,以及为什么 CI 用的是一个更粗的版本。

pytest 加速:用 pytest-testmon 只跑受代码改动影响的测试

pytest-testmon 是一个 pytest 插件,只跑你的改动可能影响到的测试。它借助 coverage.py 观察每个测试执行了哪些代码行,并为测试碰过的每个代码块存一个校验和。下次运行时,它把这些校验和与磁盘上的代码对比,只跑代码块有变化的测试,再加上上次失败的测试,所以它顺带也干了 pytest --lf 的活。这类技术统称测试影响分析(test impact analysis),而 testmon 是在 Python 里免费、开源地用上它的一种办法。它完全不依赖 git:它对比的是上次运行时看到的代码,提交没提交都一样。

这周我把它加进了我那个集市应用的 Python 测试里,原因并不是为了快而快。

为什么要加它
#

一个功能需求通常很小:一个界面、一个接口、一条要换个做法的规则。跑整个测试套件,让每个需求都变得比它本身更大。这个套件有 2,248 个 Django 测试,四个进程跑大约三分半,单进程要 11 分钟,而每次编辑都会把它整个跑一遍,尽管其中几乎全部检查的都是这个需求根本没碰过的东西。只要有不相干的测试失败,或者运行单纯慢了点,功能就得等着。拖住它的,是功能需求之外的情况,而那个拖住它的东西,正是我自己的测试套件。

另一条路也好不到哪儿去:不跑全量,猜哪个测试文件覆盖了这次改动,偶尔猜错。这样是快,但不精确,而我发出去的每一个未经测试的改动,都是一个要做两遍的功能需求。

我想要的是保持敏捷,又不变得马虎:接一个需求,改代码,确认它能用,发布,然后做下一个。要做到这一点,我等的测试就应该恰好是这次改动能触及的那些,不多也不少。testmon 给我的这组测试来自记录,而不是猜测。全量套件照样在跑,只不过是在 CI 里,不会拖住我接下来手上的任何事。

它怎么决定跑什么
#

testmon 把每个 Python 文件切成代码块:模块的顶层、每个类、每个函数,嵌套函数的函数体会从外层代码块里切出去。它为每个代码块存一个校验和,从不存代码本身。测试运行时,coverage.py 记录它执行的代码行,testmon 记下这些行属于哪些代码块。没有静态分析,也不靠 import 去猜。一个测试依赖的,恰好就是它跑过的那些代码块。

拿这样一个模块来说:

# pricing.py
def subtotal(items):
    return sum(item.price * item.quantity for item in items)

def discount(total, code):
    if code == "WELCOME10":
        return total * 0.9
    return total

一个只调用 subtotal() 的测试,依赖的是模块顶层和 subtotal 的函数体。discount() 随你怎么改写,testmon 都不会重跑这个测试,因为它从没执行过 discount 里的任何一行。而在 subtotal 里哪怕只改一个字符,它就会再跑一次,即使这个改动根本不可能改变结果。testmon 不会在副作用上耍聪明:你执行过的代码块变了,你就得跑。

它还会追踪代码周围的东西。记录里包括 Python 版本、已安装的包及其版本,以及 DJANGO_SETTINGS_MODULE 这类环境变量。升级一下 Django,所有碰过 Django 的东西都会再次受影响,而在一个 Django 应用里,那就是全部。

它看不到什么
#

testmon 追踪的是测试执行的 Python 代码。测试读取的其他文件它不追踪:模板、JSON、文本,磁盘上的任何别的东西。经网络访问的服务它也不追踪。而在测试开始前只跑一次的东西,它没法归到某个具体测试头上。放在 Django 项目里,就是:

  • 迁移。 它们只在创建测试数据库时跑一次,所以没有哪个测试的记录里包含它们。有新迁移时,下面的包装脚本会重建测试数据库。
  • 设置和启动代码。 它们在 pytest 启动时运行,不属于任何一个测试。
  • 模板和数据文件。 模板不是 Python,所以改了模板,渲染它的测试一个都不会重跑。

我的测试套件还有几样自己特有的。有些测试读取一个 JSON fixture 文件,移动应用的测试也共用它。有些检查构建出来的 Web bundle,还有些检查同一个仓库里生成的静态网站的输出。改了其中任何一个,testmon 都会高高兴兴地告诉你什么都不用跑。所以下面的教程会用一个脚本把 testmon 包起来,这个脚本会留意这些文件,只要其中一个改了,就全部跑一遍。

怎么把它加进 Django 项目
#

以下步骤假定你用 pytest 和 pytest-django,依赖用 uv 管理。用 pip 的话,把 uv add --dev 换成 pip install,去掉 uv run。

1. 安装。

uv add --dev pytest-testmon

2. 确保 pytest 能跑这套测试。 testmon 是 pytest 插件,所以在 python manage.py test 下它什么都不做。pytest-django 需要知道设置模块,以及你的测试所用的文件命名模式:

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings
python_files = tests.py test_*.py *_tests.py
python_classes = Test* *Tests *TestCase

如果这套测试一直只在 Django 自带的 runner 下跑过,先在 pytest 下跑一遍,把不一致的地方修好,再加 testmon。不然你就得同时调试两件事。

3. 别把记录放进 git。 testmon 会在 pytest 根目录写入 .testmondata。它是一个 SQLite 数据库,所以旁边还会出现 -shm 和 -wal 文件,下面的脚本还会再加一个时间戳文件。一个模式就能把它们全部覆盖:

# .gitignore
.testmondata*

4. 先把整套测试记录一遍。 testmon 在看过每个测试运行之前,什么都选不出来:

uv run pytest --testmon

在全新的检出里,第一次运行就是单进程跑完整个套件。在我这里,2,248 个测试大约要 12 分钟。从那以后,同一条命令就只跑受影响的测试。什么都没改的话,它什么都不跑。

有两个坑。testmon 需要 pytest 的 cache 插件:我第一次尝试时带着 -p no:cacheprovider,结果以 KeyError: 'lf' 崩掉了。另外,在 Python 3.14 或更高版本上,记录之前要先设置 COVERAGE_CORE=ctrace。原因在后面的“看起来全绿的那个 bug”一节里解释。这是下面脚本里最重要的一行。

5. 为它看不到的文件包一层。 正是这一步,让它可以放心地天天用。脚本保留一个时间戳文件,记的是上次运行开始的时间。只要有任何 testmon 追踪不到的文件比这个时间戳新,就全部跑一遍并重新记录。新的迁移只会让它重建测试数据库。其余情况就让 testmon 来选。删减后的示例:

#!/usr/bin/env bash
# scripts/test-changed.sh: the tests a change can affect, or all when in doubt.
#   scripts/test-changed.sh            # affected tests
#   scripts/test-changed.sh --all      # everything, re-recorded
#   scripts/test-changed.sh -k orders  # anything else goes to pytest
set -euo pipefail
cd "$(dirname "$0")/.."

# coverage's C tracer: from Python 3.14 coverage defaults to sys.monitoring,
# which reports a line only the first time a process runs it, so testmon
# would credit each line to one test. Changing this means --all.
export COVERAGE_CORE=ctrace

STAMP=.testmondata.started
full=false
rebuild=false
if [ "${1:-}" = "--all" ]; then
    full=true; shift
elif [ ! -f .testmondata ] || [ ! -f "$STAMP" ]; then
    echo "No record yet: running everything once."
    full=true
else
    # Changed since the last run started, and not traceable per test:
    # anything that isn't Python, plus settings and conftest.
    newer="$(find myapp myproject fixtures pytest.ini \
        -type f -newer "$STAMP" ! -path '*/migrations/*' \
        \( ! -name '*.py' -o -path 'myproject/settings*' -o -name 'conftest.py' \) \
        ! -path '*/__pycache__/*' | head -5)"
    if [ -n "$newer" ]; then
        echo "Changed, and not traceable per test:"; printf '  %s\n' $newer
        full=true
    fi
    # A new migration needs a fresh test database, not a full run.
    [ -n "$(find myapp -path '*/migrations/*.py' -newer "$STAMP" | head -1)" ] && rebuild=true
fi

# Stamp the start, so an edit made while the tests run counts next time.
started="$(mktemp .testmondata.run.XXXXXX)"
args=(--testmon --reuse-db)
# --create-db alone deletes the database when the run ends; with
# --reuse-db as well, the rebuilt one is kept for next time.
[ "$rebuild" = true ] && args=(--testmon --reuse-db --create-db)
[ "$full" = true ] && args=(--testmon-noselect --reuse-db --create-db)

status=0
uv run pytest "${args[@]}" -q "$@" || status=$?
# 0: passed, or nothing selected. 1: some failed (still recorded).
# 5: nothing collected at all.
case "$status" in 0|1|5) mv "$started" "$STAMP" ;; *) rm -f "$started" ;; esac
[ "$status" = 5 ] && exit 0
exit "$status"

这里面有两个细节值得解释。--reuse-db 是 pytest-django 的参数,用来在两次运行之间保留测试数据库。一旦一次运行只要几秒,这一点就很要紧:每次重新创建数据库,花的时间可能比测试本身还长。全量运行,或者有新迁移之后的那次运行,会再加上 --create-db,让数据库用上当前的 schema。--reuse-db 要和它一起留着:单独用 --create-db 时,pytest-django 会在运行结束时删掉数据库,下一次运行就得从头再建一遍。还有,当 testmon 把所有测试都取消选中时,运行的退出码是 0,所以“什么都没改”算作通过。退出码 5 是 pytest 的 “no tests collected”(没有收集到测试),脚本把它也当作没问题。

注意清单里没有什么:pyproject.toml 和 lock 文件。我的第一版只要 lock 文件一变就全部跑一遍,结果这个慢吞吞的兜底大多数日子都会触发,整件事比以前还糟。testmon 本身就会追踪已安装包的版本,用不着帮这个忙。

6. 给它单独的数据库和缓存。 如果同一台机器上还有别的东西在跑测试,比如第二个终端、一个编程 agent 或者 manage.py test,就让脚本指向它自己的测试数据库和它自己的缓存编号,这样保留下来的数据库永远不会撞上别人的运行:

export DATABASE_URL="postgresql://myapp:myapp@localhost:5432/myapp_changed"
export REDIS_URL="redis://localhost:6379/8"

7. 每次运行前清空那个缓存。 这一条我是吃了亏才学到的。子集运行开始出现 KeyError,全量运行却没有。有些分类标签的缓存没有过期时间,于是一次运行保留下来的测试数据库,碰上了更早一次运行留下、还热着的缓存。在脚本开头清掉缓存,问题就解决了:

uv run python -c "import os, redis; redis.Redis.from_url(os.environ['REDIS_URL']).flushdb()"

这正是 testmon 在做它文档里说过会做的事:每次跑不同的子集,会暴露出那些只是因为之前跑过什么才通过的测试。修复应该放在测试的 setup 里,而不是 testmon 里。

8. 记住选择出错时用的参数。

  • --testmon-noselect 跑所有测试,但把可能失败的排在前面。当你同时传了 -k、-m、--lf 或某个测试的 node ID 时,testmon 会自动切换到这个模式。
  • --testmon-forceselect 只跑既受影响、又匹配你的 -k 或 -m 的测试。
  • --testmon-nocollect 使用已有的记录,但不往里添加。在调试器下,或者 coverage 已经在运行时,会强制使用这个模式。
  • --no-testmon 在它写进了 addopts 时,用来关掉一次运行。
  • --testmon-env=<name> 在一个文件里保存多份独立的记录,适合要在两个设置模块下跑的测试套件。

如果项目还有 JavaScript 那一侧,Jest 自带了同类功能。jest --changedSince=main 会跑那些 import 了自 main 以来改动过的任何东西的测试,它跟的是 import,而不是覆盖率。我把它加进了应用的 package.json,叫 npm run test:changed,这样两边是同一个习惯。改了一个工具模块,它从应用的 121 个测试文件里选中了 7 个。

它能省下多少
#

包装脚本就位、并修好下一节说的那个 bug 之后,我计了一下时,包括启动时间:

改了什么跑的测试数耗时
什么都没改01.0 s
改了一处首页横幅背后的代码178.5 s
撤销那处改动178.7 s
整个套件,作为对照(4 个进程)2,248约 3½ min

日常的一次编辑,比并行跑整个套件快大约 25 倍;什么都没改的时候,快大约两百倍。这份记录从更宽的角度说明了同一件事。在测试套件跑的 307 个非测试 Python 文件中,改动中位数的那个文件会波及 22 个测试,测试时间大约 8 秒。另一头是几个核心模块:几乎每个测试都会跑到它们,所以改了那里会波及 1,500 个左右的测试,testmon 会重跑套件的大部分。这才是正确的答案,不是失败。这些数字是按文件算的上限:testmon 按代码块工作,所以改了一个函数,只会选中跑过那个函数的测试。

代价出现在包装脚本退回全量运行的时候。一次全量运行和第一次一样,是单进程的记录,大约 12 分钟,比它取代的那个并行套件还慢。所以触发全量运行的文件清单,应该在不自欺的前提下尽量短。

看起来全绿的那个 bug
#

我最早的数字比这些好看:改一处,6 个测试,3.3 秒。它们是错的,而屏幕上没有任何东西提示这一点。

从 Python 3.14 开始,coverage.py 默认使用一个新的核心,建立在解释器的 sys.monitoring 之上。这个核心只在一个进程第一次运行某一行时报告它。testmon 关于哪个测试跑了哪一行的记录,是从 coverage 得来的,所以每一行都只记在碰巧第一个跑到它的测试头上,别的测试一概没有。改一行八个测试共用的代码,testmon 只会选中其中一个。

发现它,是在我另一个更小的项目上,那个项目照搬了这套配置。在那里引入它的步骤之一,就是故意弄坏一行代码、对比结果,而数字对不上。回到这个套件,我做了同样的检查。我弄坏了首页横幅代码里的一行,也就是一张幻灯片的标题。直接跑 pytest,报告了 8 个失败的测试。testmon 选中了 4 个。另外 4 个坏掉的测试根本没跑,输出里也没有任何地方说它们被漏掉了。

修复只要一行,export COVERAGE_CORE=ctrace,让 coverage 回到经典的 C tracer。换了 tracer,记录也就不一样了,所以我重新记录了一遍:2,248 个测试,12 分 3 秒。之后,同样那种弄坏代码的改动,把它弄坏的 11 个测试全都跑了。

这是我最想先传下去的一条教训。一个选得太少的测试选择器,看起来和成功一模一样:测试更少,跑得更快,全是绿的。所以搭一个这样的东西时,要故意弄坏某个共用的东西,分别跑原本的完整套件和选择器,确认它们失败的是同一批测试。之后每当 Python 或 coverage.py 升级版本,再做一遍。

编程 agent 在哪里用得上
#

那个仓库里的改动,已经不全是我自己做的了。一个编程 agent,我用的是 Claude Code,做了很多小改动,而一个好的 agent 每次编辑后都会跑测试。面对一个 11 分钟的测试套件,它的两个坏选择和我当初一样:等,或者猜哪个测试文件要紧。

所以仓库里给 agent 的说明现在写着:工作时跑测试,默认就用这个包装脚本,要全量运行就加 --all。每次编辑后,agent 都能在几秒内拿到和我一样精确的那组测试。这也是第 6 步要给脚本单独的数据库和缓存的原因:忙的时候,同一台机器上不止一个会话在跑测试,而保留下来的数据库绝不能撞上别人的运行。

这些都不需要 testmon 提供任何 AI 专属的东西。还是同一份记录、同一套选择。只不过,当做改动的那一方改得非常多时,它就更重要了。

为什么 CI 用的是更粗的版本
#

testmon 没有 CI 模式。它在 runner 上和在笔记本上表现一样,而它的作者写道,在 CI runner 之间共享或缓存 .testmondata,效果远不如他们的托管服务 testmon.net。缓存下来的记录还会带着上面说的所有盲区,再加上我的 CI 是用 Django 自带的 runner、四个进程跑这套测试的,所以我没在 CI 里用 testmon。

CI 拿到的,是同一个思路的文件级版本。检查工作流现在先跑一个小 job,对这次推送做 diff,判断它碰到了仓库的哪一半:Python API、移动应用、两者都有,或者都没有。在这之前,每次推送 CI 都会把 Python 和应用两个 job 都跑一遍,只改文档的推送也不例外。现在只改文档的推送什么都不跑,只改应用的推送会跳过 Django 测试套件。我拿五个真实的提交试跑了这个 job:只改文档的那个什么都没跑,只改应用的那个跳过了 Python job,只改后端的那个跳过了应用 job。删减后的示例:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      python: ${{ steps.diff.outputs.python }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: diff
        env:
          BEFORE: ${{ github.event.before }}
          HEAD: ${{ github.sha }}
        run: |
          FILES="$(git diff --name-only "$BEFORE" "$HEAD")"
          # Python's inputs include the non-Python files its tests read.
          PY='^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)'
          if printf '%s\n' "$FILES" | grep -qE "$PY"; then
            echo "python=true" >> "$GITHUB_OUTPUT"
          else
            echo "python=false" >> "$GITHUB_OUTPUT"
          fi

  python:
    needs: changes
    if: needs.changes.outputs.python == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: uv run python manage.py test --parallel 4 --noinput

真实的版本在没有可比较的范围时,也会全部跑一遍:新分支、强制推送(force push),或者手动运行。

盲区还是同一个,只是换了个形式。testmon 看不到 JSON 文件,路径过滤器也看不到,除非你把它列出来。所以这个模式里明确写出了共享的 fixture、构建出来的 bundle 和生成的网站,还加了一条注释,请改它的人让这份清单保持如实。文件级的版本更粗糙,但它出错时偏向安全的一边:Python 那边只要有任何改动,就跑整个套件。

值得吗?
#

对一个要跑好几分钟的测试套件来说,值得。重点从来不是那几分钟本身,而是让一次改动由它可能影响到的测试来评判,这样小需求就能一直是小需求。它只是一个依赖、一个参数加一个环境变量,完全不改变测试的写法;只要设置了 C tracer,它最坏也不过是在你改了某个人人都 import 的东西之后跑得太多。不设置的话,在 Python 3.14 或更高版本上,它可能跑得太少却依然全绿,这正是为什么要故意弄坏代码来检查一遍。真正的代价在于它看不到的东西,而那正是包装脚本的用处。即便如此,testmon 该待的地方是笔记本上的开发循环。只要 Python 那边有改动,CI 照样跑整个套件,部署前我信任的是那一次运行。

评论

评论经审核后显示。