pytest-testmonは、変更が影響しうるテストだけを実行するpytestのプラグインです。各テストがどの行を実行したかをcoverage.pyで見ていて、テストが触れたコードのブロックごとにチェックサムを保存します。次の実行ではそのチェックサムをディスク上のコードと比べ、ブロックが変わったテストと、前回失敗したテストだけを実行します。つまりpytest --lfの役目も兼ねています。この手法は一般にテスト影響分析(test impact analysis)と呼ばれ、testmonはそれをPythonで無料で使える、オープンソースの手段です。gitにはまったく依存しません。比べる相手は前回の実行で見たコードで、コミット済みかどうかは関係ありません。
今週、私が作っているマーケットプレイスアプリのPythonのテストにこれを入れました。理由は、速さそのものではありません。
なぜ入れたのか#
機能のリクエストは、たいてい小さなものです。画面が一つ、エンドポイントが一つ、挙動を変えたいルールが一つ。ところがテストスイートを丸ごと実行すると、そのどれもが実際より大きくなっていました。スイートはDjangoのテストが2,248件で、4プロセスで約3分半、1プロセスだと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 totalsubtotal()だけを呼ぶテストは、モジュールのトップレベルとsubtotalの本体に依存します。discount()をどう書き換えても、testmonはそのテストを再実行しません。そのテストはdiscountの行を一行も実行していないからです。逆にsubtotalの中を一文字でも変えれば、結果が変わりようのない変更でも再実行されます。testmonは副作用について賢く立ち回ろうとはしません。実行したブロックが変わったら、実行する。それだけです。
コードの周りにあるものも追跡します。記録にはPythonのバージョン、インストール済みのパッケージとそのバージョン、それにDJANGO_SETTINGS_MODULEのような環境変数が含まれます。Djangoをアップグレードすれば、Djangoに触れたものはすべてまた影響を受けます。Djangoアプリなら、それはつまり全部です。
見えないもの#
testmonが追跡するのは、テストが実行するPythonのコードです。テストが読むそれ以外のファイル、つまりテンプレートやJSON、テキストなど、ディスク上にあるほかのものは追跡しません。ネットワーク越しに呼び出すサービスも追跡しません。そして、テストが始まる前に一度だけ実行されるものを、特定のテストに結びつけることもできません。Djangoのプロジェクトでいえば、次のようなものです。
- マイグレーション。 テスト用データベースを作るときに一度だけ実行されるので、どのテストの記録にも含まれません。後で紹介するラッパーは、新しいマイグレーションがあるとテスト用データベースを作り直します。
- 設定と起動時のコード。 pytestの起動中に実行されるので、どのテストにも属しません。
- テンプレートとデータファイル。 テンプレートはPythonではないので、編集しても、それを描画するテストは一つも再実行されません。
私のスイートには、ほかにもいくつかあります。モバイルアプリのテストと共有しているフィクスチャのJSONファイルを読むテスト。ビルドしたWebのバンドルを確認するテスト。同じリポジトリで生成している静的サイトの出力を確認するテスト。どれを変えても、testmonは「何も実行する必要はありません」と平然と答えます。だから以下の手順では、testmonをスクリプトで包んで、こうしたファイルに気づき、どれかが変わっていたら全部を実行するようにしています。
Djangoのプロジェクトに入れる方法#
ここではpytestとpytest-djangoを使い、依存関係はuvで管理している前提で書きます。pipを使っているなら、uv add --devをpip installに置き換え、uv runを外してください。
1. インストールする。
uv add --dev pytest-testmon2. pytestでスイートを実行できるようにする。 testmonはpytestのプラグインなので、python manage.py testでは何もしません。pytest-djangoには、settingsモジュールと、テストが使っているファイル名のパターンを教える必要があります。
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings
python_files = tests.py test_*.py *_tests.py
python_classes = Test* *Tests *TestCaseこれまでDjangoのランナーでしかスイートを実行したことがないなら、testmonを入れる前に一度pytestで実行し、違いが出たところを直しておきましょう。そうしないと、二つの問題を同時にデバッグする羽目になります。
3. 記録をgitに入れない。 testmonはpytestのルートディレクトリに.testmondataを書き出します。中身はSQLiteのデータベースなので、隣に-shmと-walのファイルもでき、さらに後で出てくるスクリプトがスタンプファイルを足します。パターン一つで全部まとめて除外できます。
# .gitignore
.testmondata*4. 一度スイート全体を記録する。 testmonは、すべてのテストの実行を一度見るまで何も選べません。
uv run pytest --testmonまっさらなチェックアウトでは、この最初の実行は1プロセスでのスイート全体の実行になります。私の環境では、2,248件のテストに約12分かかります。それ以降は、同じコマンドで影響を受けるテストだけが実行されます。何も変えていなければ、何も実行されません。
落とし穴が二つあります。一つ目に、testmonにはpytestのcacheプラグインが必要です。最初に試したときは-p no:cacheproviderを付けて実行していて、KeyError: 'lf'で落ちました。二つ目に、Python 3.14以降では、記録する前にCOVERAGE_CORE=ctraceを設定してください。理由は、この後の「成功に見えたバグ」で説明します。以下のスクリプトで、いちばん大事な一行です。
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を足します。そのときも--reuse-dbは外さないでください。--create-dbだけだと、pytest-djangoは実行の終わりにデータベースを削除してしまい、次の実行でまた一から作ることになります。もう一つは終了コードです。testmonがすべてのテストを選択から外したとき、実行は0で終わるので、「何も変わっていない」は成功として扱われます。終了コード5はpytestの「テストが一つも収集されなかった」という意味で、スクリプトはこれも問題なしとして扱います。
リストに入っていないものにも注目してください。pyproject.tomlとロックファイルです。最初のバージョンはロックファイルが変わるたびに全部を実行していて、そのせいで遅いフォールバックがほぼ毎日発動し、入れる前より悪くなっていました。testmonはインストール済みのパッケージのバージョンを自分で追跡しているので、手助けはいりません。
6. 専用のデータベースとキャッシュを持たせる。 同じマシンでほかにもテストを実行するもの、たとえば二つ目のターミナルやコーディングエージェント、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が、ドキュメントに書いてあるとおりのことをしているだけです。毎回違うサブセットを実行すると、前に何が実行されたかのおかげで通っていたにすぎないテストがあぶり出されます。直すべきはテストのセットアップで、testmonではありません。
8. 選択が間違っているときのためのフラグを知っておく。
--testmon-noselectはすべてのテストを実行しますが、落ちそうなものを先に回します。-k、-m、--lfやテストのnode IDを一緒に渡すと、testmonは自動的にこれに切り替えます。--testmon-forceselectは、影響を受けるテストのうち、-kや-mにも一致するものだけを実行します。--testmon-nocollectは既存の記録を使いますが、記録には何も追加しません。デバッガの下で動かしているときや、coverageがすでに動いているときは、強制的にこれになります。--no-testmonは、一回の実行だけtestmonを無効にします。addoptsに入れているときに使います。--testmon-env=<name>は、一つのファイルの中に記録を別々に持ちます。二つのsettingsモジュールで実行するスイート向けです。
プロジェクトにJavaScriptの側もあるなら、Jestには同じような機能が最初から入っています。jest --changedSince=mainは、main以降に変わったものをimportしているテストを実行します。たどるのはcoverageではなくimportです。私はアプリのpackage.jsonにnpm run test:changedとして追加して、両側で同じ習慣が使えるようにしました。ユーティリティモジュールを一つ編集したときは、アプリの121個のテストファイルのうち7個が選ばれました。
どれだけ節約できるか#
ラッパーを入れた後、次のセクションで書くバグを直してから、起動時間込みで計ってみました。
| 何を変えたか | 実行したテスト | 時間 |
|---|---|---|
| 何も変えない | 0 | 1.0 s |
| ホーム画面のバナーの裏にあるコードを一か所編集 | 17 | 8.5 s |
| その編集を元に戻す | 17 | 8.7 s |
| 比較用に、スイート全体(4プロセス) | 2,248 | 約3½分 |
ふだんの編集なら並列実行のスイートより約25倍速く、何も変わっていなければ約200倍速くなります。記録も、もっと広い範囲で同じことを示しています。スイートが実行する、テスト以外のPythonファイル307個のうち、中央値にあたるファイルを変えたときに届くのはテスト22件、テスト時間にして約8秒です。反対の端にはいくつかのコアモジュールがあります。ほぼすべてのテストがこれらを実行するので、変更すると約1,500件のテストに届き、testmonはスイートの大半を再実行します。これは失敗ではなく、正しい答えです。これらの数字はファイルごとの上限です。testmonはブロック単位で動くので、関数を一つ変えれば、その関数を実行したテストだけが選ばれます。
コストが表に出るのは、ラッパーがフォールバックしたときです。全体の実行は最初と同じ1プロセスでの記録なので約12分かかり、置き換える前の並列実行のスイートより遅くなります。だからこそ、フォールバックのきっかけになるファイルのリストは、正直でいられる範囲でできるだけ短くしておくべきです。
成功に見えたバグ#
最初に出た数字は、これよりも良いものでした。一か所の編集でテスト6件、3.3秒。でもそれは間違っていて、画面のどこにもそうは書いてありませんでした。
Python 3.14から、coverage.pyはデフォルトで新しいコアを使います。インタプリタのsys.monitoringの上に作られたものです。このコアがある行を報告するのは、プロセスがその行を初めて実行したときだけです。testmonはどのテストがどの行を実行したかの記録をcoverageから作るので、各行は、たまたま最初にそれを実行したテスト一つだけのものとして記録され、ほかのどのテストにも結びつきませんでした。8件のテストが共有している行を編集しても、testmonはそのうち1件しか選びません。
気づいたのは、この仕組みをそのまま真似した、私の別の小さなプロジェクトでした。そちらに導入する作業の一環として、わざと一行を壊して結果を比べたところ、数字が合わなかったのです。こちらのスイートに戻って、同じ確認をしました。ホーム画面のバナーのコードで、スライドのタイトルの一行を壊します。素のpytestでは8件のテストが失敗しました。testmonが選んだのは4件です。壊れた残りの4件は一度も実行されず、出力のどこにも、それらが外されたことは書いてありませんでした。
直すのは一行で、export COVERAGE_CORE=ctraceです。これでcoverageは昔ながらのCトレーサーに戻ります。トレーサーが違えば記録も違うので、記録し直しました。2,248件のテストで12分3秒です。その後、同じように壊す編集をしたら、壊れた11件のテストがすべて実行されました。
この記事で何よりも先に伝えたい教訓はこれです。選ぶテストが少なすぎるテストセレクタは、成功とまったく同じに見えます。テストは少なく、実行は速く、すべて緑。だから導入するときは、共有されている何かをわざと壊し、素のスイートとセレクタの両方を実行して、同じテストが失敗するかを確かめてください。そして、Pythonやcoverage.pyのバージョンが変わるたびに、もう一度やってください。
コーディングエージェントの出番#
そのリポジトリの変更を、もう全部自分でしているわけではありません。小さな変更の多くはコーディングエージェント(私の場合はClaude Code)がしていて、良いエージェントは編集のたびにテストを実行します。11分かかるスイートを前にすると、エージェントにも私と同じ二つの悪い選択肢しかありません。待つか、どのテストファイルが関係するかを当て推量するかです。
そこで、リポジトリのエージェント向けの指示に、作業中のテストはこのラッパースクリプトで実行するのを基本とし、全体を実行するときは--allを付ける、と書きました。エージェントも私と同じ正確なテストの集合を、編集のたびに数秒で得られます。ステップ6でスクリプトに専用のデータベースとキャッシュを持たせているのも、これが理由です。忙しい日には同じマシンで複数のセッションがテストを実行していて、使い回しているデータベースが他の誰かの実行と鉢合わせすることは、絶対にあってはなりません。
どれも、testmonにAI固有の何かを求めているわけではありません。同じ記録、同じ選択です。ただ、変更を加えているのが何であれ、その変更の数がとても多いと、それだけ効き目も大きくなります。
CIにはもっと粗いものを入れた理由#
testmonにはCIモードがありません。ランナーの上でもノートPCの上と同じように動きます。作者は、CIのランナー間で.testmondataを共有したりキャッシュしたりするのは、自分たちのホスティングサービスであるtestmon.netに比べてずっと効果が薄いと書いています。キャッシュした記録は上で挙げた点でどれも見えないままですし、私のCIはDjangoのランナーを4プロセスで動かしてスイートを実行しています。なので、testmonは入れませんでした。
代わりにCIに入れたのは、同じ考え方をファイル単位にしたものです。チェックのワークフローはまず小さなジョブでpushの差分を取り、リポジトリのどちら側に触れたかを判断します。PythonのAPIか、モバイルアプリか、両方か、どちらでもないか。これまでCIは、ドキュメントだけのものも含めて、pushのたびにPythonとアプリの両方のジョブを実行していました。今は、ドキュメントだけのpushなら何も実行せず、アプリだけへのpushならDjangoのスイートを飛ばします。実際のコミット5件に対してこのジョブを試しに走らせてみたところ、ドキュメントだけのコミットでは何も実行されず、アプリだけのコミットではPythonのジョブが、バックエンドだけのコミットではアプリのジョブが飛ばされました。削って例にしたものがこちらです。
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ファイルが見えませんが、パスのフィルタも、リストに書かない限り見えません。そこでパターンには共有のフィクスチャ、ビルドしたバンドル、生成したサイトを明示的に書き、編集する人にはリストを実態に合わせておくよう頼むコメントを添えています。ファイル単位のやり方は大雑把ですが、失敗するときは安全な方向に失敗します。Python側で何かが変わっていれば、スイート全体が実行されます。
入れる価値はあるか#
実行に数分かかるスイートなら、あります。肝心なのは、最初から数分そのものではありませんでした。変更がそれの影響しうるテストで判断されるので、小さなリクエストが小さいままでいられる、ということです。依存関係が一つ、フラグが一つ、環境変数が一つで、テストの書き方は何も変わりません。Cトレーサーを設定してあれば、起こりうる最悪のことは、すべてがimportしているものを変えた後に、多めに実行してしまうことくらいです。設定していないと、Python 3.14以降では実行するテストが少なすぎるのに緑のまま、ということが起こりえます。わざと壊して確かめる手順が大事なのは、そのためです。本当のコストは見えないものの方で、ラッパースクリプトはそのためにあります。それでも、testmonが活きるのはノートPCでの開発ループです。CIはPython側が変わるたびに今もスイート全体を実行していて、デプロイの前に私が信頼しているのはその実行です。
