Pythonテスト #7 CIで回す — 人が忘れても機械は忘れない
テストをどれだけうまく作っても、最後の質問が 1 つ残ります。このテストは誰が回すのでしょうか? 人が覚えていて回すテストは、結局回らなくなります。忙しい日に一度飛ばし、急ぎのホットフィックスでまた飛ばし、いつの間にか壊れたまま放置されます。シリーズ最終回では、テストの実行を人の記憶から切り離して機械に任せる方法、つまり CI 連携を扱います。
- #1 pytest をはじめる
- #2 フィクスチャ
- #3 parametrize とマーカー
- #4 mock と monkeypatch
- #5 外部世界のテスト
- #6 テスト設計とカバレッジ
- #7 CIで回す ← 今回
GitHub Actions の基本ワークフロー #
リポジトリに .github/workflows/test.yml というファイルを 1 つ追加すると、プッシュと PR のたびにテストが自動で回ります。
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- run: uv sync --all-extras
- run: uv run pytest4 ステップがすべてです。コードを取得し、uv をインストールし、依存関係を同期し、pytest を実行します。enable-cache: true の 1 行が uv のパッケージキャッシュを GitHub のキャッシュストレージに保管してくれるので、2 回目の実行からは依存関係のインストールが数秒で終わります。これで PR を上げるとテスト結果が緑のチェックまたは赤の X で表示され、リポジトリ設定で branch protection を有効にすれば、テストが壊れた PR はマージ自体がブロックされます。ここまで来ると、「テストを回す仕事」が人の意志から完全に切り離されます。
Pythonバージョンマトリックス #
ライブラリを作る場合や複数バージョンをサポートする必要がある場合は、同じテストをバージョンごとに回します。
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- run: uv sync --all-extras
- run: uv run pyteststrategy.matrix に書いたバージョンの数だけジョブが並列に生まれます。3.13 でだけ壊れるコードをローカルで捕まえるのは難しいですが、マトリックスは毎回 3 バージョンを全部確認してくれます。自分のサービスを 1 つ運用するだけなら、本番環境と同じバージョン 1 つで十分です。
カバレッジレポートをPRに付ける #
#6 で作ったカバレッジ測定を CI につなぎます。XML レポートを作って Codecov のようなサービスにアップロードすると、PR ごとにカバレッジの変化がコメントとして付きます。
- run: uv run pytest --cov=app --cov-report=xml
- uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}「この PR でカバレッジが 2% 下がりました」のようなコメントが自動で付けば、レビュアーがわざわざ指摘しなくても作成者が先に目にします。数字そのものより、変化の方向が見える点 に価値があります。#6 で整理したとおり、カバレッジは目標ではなく信号であり、CI はその信号をみんなが見る場所に掲げてくれます。
pre-commitでより早いフィードバック #
CI はプッシュした後にようやく結果が出ます。コミット直前に捕まえられる問題は、より早く捕まえるほうがよいです。pre-commit フックに ruff を仕掛けておくと、フォーマットとリントの問題がコミット段階でふるい落とされます。
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-formatuv tool install pre-commit でインストールし、リポジトリで pre-commit install を一度実行すれば、以後すべてのコミットで ruff が自動で回ります。ただし pre-commit に全体の pytest を仕掛けることはおすすめしません。コミットに数十秒かかるようになると結局 --no-verify で迂回するようになり、迂回が習慣になるとフック全体が無力化されます。コミットフックは 1〜2 秒で終わる検査だけ、全体テストは CI に任せるという分業が長続きします。
遅いテストの分離 #
テストが増えると CI の時間が伸び、CI が遅いと開発のリズムが崩れます。#3 で扱ったマーカーがここで力を発揮します。
import pytest
@pytest.mark.slow
def test_full_data_pipeline():
...ローカルでは速いテストだけ、CI では全部回す、という形で分けます。
# ローカル: 速いものだけ
uv run pytest -m "not slow"
# CI: 全部
uv run pytestより重いテスト、たとえば実際の外部 API を呼ぶ統合テストは、PR ごとには回さず、schedule トリガーで 1 日 1 回回る別のワークフローに置く方法もあります。速いフィードバックループと深い検証を分離することが肝心です。
失敗したテストだけ再実行する #
ローカルでテストを直している最中なら、毎回全体を回す必要はありません。
# last failed: 直前に失敗したテストだけ
uv run pytest --lfpytest は .pytest_cache ディレクトリに直前の実行結果を記録しておき、--lf はその記録を読んで失敗したテストだけを選んで回します。失敗したものを先に回して残りを後に回す --ff も同じキャッシュを使います。数百個のテストのうち壊れた 3 個だけを繰り返し回しながら直せるので、修正ループがずっと短くなります。直し終えたら全体を一度回して締めくくればよいです。
CI でテストが通れば、その次はデプロイです。同じワークフローの後ろに Docker ビルドとデプロイの段階をつなげる構成は モダンPython実践 #6 で扱ったので、テストとデプロイを 1 つのパイプラインにまとめたければ続けて読んでみてください。
シリーズを終えて #
7 本を一行ずつ振り返ります。
- #1 pytest をはじめる:
assert1 つから始まるテスト、pytest の基本動作を身につけました。 - #2 フィクスチャ: 準備と片付けをテストの外に切り出す fixture を扱いました。
- #3 parametrize とマーカー: 同じロジックを複数の入力で回し、テストをグループで管理する方法を見ました。
- #4 mock と monkeypatch: 制御できないものを偽物に置き換えて分離する方法を身につけました。
- #5 外部世界のテスト: 時間、ファイル、ネットワーク、DB のように外に触れるコードをテストしました。
- #6 テスト設計とカバレッジ: 何をテストするかを選ぶ基準と、カバレッジの読み方を整理しました。
- #7 CI 連携: そのすべてのテストが、人がいなくても回るようにしました。
テストは結局、未来の自分のための安全網です。3 ヶ月後の自分は、いまのこのコードの意図を覚えていません。そのときコードを直していて何かを壊せば、今日書いておいたテストが赤い文字で知らせてくれます。CI は、その安全網が常に張られていることを保証する装置です。人が忘れても機械は忘れません。ここまで一緒に積み上げたテストの習慣が、これから書くすべての Python コードの土台を支えてくれることを願っています。