はじめに
研究用のPythonコードにpytestを導入したところ、ローカル環境では次のように全テストが成功するようになりました。
================ 555 passed in 18.24s ================
しかし、テスト数が増えても、次の問題は残ります。
- 手元にだけインストールされたパッケージを使っている
- Git管理されていない設定ファイルを参照している
- 特定の環境変数やPATHに依存している
- 自分が使っているPythonバージョンでしか動かない
そこでGitHub Actionsを使い、コードをpushするたびに、まっさらな環境でpytestを実行するようにしました。
最小構成
リポジトリに次のファイルを追加します。
.github/
└── workflows/
└── pytest.yml
内容は次のとおりです。
name: pytest
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements-dev.txt
- name: Run tests
run: python -m pytest -q
これをpushすると、3つのPythonバージョンで同じテストが実行されます。
Python 3.11 → pytest
Python 3.12 → pytest
Python 3.13 → pytest
なぜpytestではなくpython -m pytestなのか
Workflowでは、次のように書いています。
python -m pytest
単にpytestと書くこともできます。
pytest
しかし、python -m pytestなら、setup-pythonで選択したPythonからpytestを起動できます。
同様に、パッケージのインストールにも次を使います。
python -m pip install -r requirements-dev.txt
これにより、次のような環境の食い違いを避けやすくなります。
python → Python 3.13
pip → 別のPython環境
pytest → 別の仮想環境
ローカルでもCIでも、基本的にpython -m pipとpython -m pytestを使うようにしました。
CIで見つかりやすい手元依存
GitHub Actionsを導入すると、テストコードの間違いだけでなく、開発環境への依存も見つけられます。
requirementsに書き忘れたパッケージ
ローカル環境には、過去にインストールした大量のパッケージが残っています。
そのため、コードでyamlをimportしているのに、依存関係へPyYAMLを書き忘れていても動いてしまいます。
import yaml
GitHub Actionsでは依存関係を最初からインストールするため、書き忘れているとすぐに失敗します。
ModuleNotFoundError: No module named 'yaml'
「自分のMacでは動く」を検出する、かなり単純ですが強力な方法です。
Git管理していないファイル
例えば、コードが次のファイルを参照しているとします。
config/local.yaml
手元には存在していても、.gitignoreに入っていればGitHub Actions上には存在しません。
FileNotFoundError: config/local.yaml
この場合、設定ファイルのサンプルをGit管理するか、テスト内で一時ファイルを作る必要があります。
def test_load_config(tmp_path):
config_path = tmp_path / "config.yaml"
config_path.write_text(
"bin_size: 1000\n",
encoding="utf-8",
)
config = load_config(config_path)
assert config["bin_size"] == 1000
テストが自分で必要なファイルを用意すれば、実行環境に依存しなくなります。
環境変数への依存
手元のシェルにだけ設定されている環境変数も、CIでは存在しません。
import os
data_dir = os.environ["DATA_DIR"]
単体テストではmonkeypatchを使い、必要な環境変数を明示します。
def test_data_dir(monkeypatch):
monkeypatch.setenv(
"DATA_DIR",
"/tmp/test-data",
)
assert get_data_dir() == "/tmp/test-data"
実際の解析環境では環境変数を使い、テストでは値を固定することで再現性を確保できます。
外部ツールが必要なテストは分離する
研究コードでは、Pythonだけで完結せず、外部コマンドを呼び出すことがあります。
subprocess.run(
["external-command", "input.fits"],
check=True,
)
GitHub Actionsの標準環境に、その外部ツールが入っているとは限りません。
だからといって、すべてのテストをCIから除外すると、自動テストの効果が小さくなります。
そこで、外部ツールが必要なテストにマーカーを付けます。
import pytest
@pytest.mark.external
def test_run_external_command():
result = run_external_command("input.fits")
assert result.returncode == 0
pyproject.tomlにもマーカーを登録します。
[tool.pytest.ini_options]
markers = [
"external: requires external software",
]
GitHub Actionsでは、外部ツールが不要なテストだけを実行します。
- name: Run unit tests
run: python -m pytest -m "not external" -q
役割分担は次のようになります。
GitHub Actions
└── Pythonだけで完結する単体テスト
専用の解析環境
└── 外部ツールを実際に動かす結合テスト
外部コマンドもテストできる
外部ツール本体をCIで動かせなくても、その手前まではテストできます。
例えば、コマンド生成部分を関数として分離します。
def build_command(
input_file: str,
output_file: str,
) -> list[str]:
return [
"external-command",
f"infile={input_file}",
f"outfile={output_file}",
]
この関数なら、外部ツールなしでテストできます。
def test_build_command():
command = build_command(
"input.fits",
"output.fits",
)
assert command == [
"external-command",
"infile=input.fits",
"outfile=output.fits",
]
CIで確認したいのは、必ずしも外部ツールの動作そのものではありません。
- 正しい引数を渡しているか
- 入出力ファイルを取り違えていないか
- 禁止したオプションが含まれていないか
- エラーを正しく分類できるか
このような、自分のコード側の責任範囲は外部環境がなくてもテストできます。
Pythonのバージョン差も見つける
matrixを使うと、複数のPythonバージョンで同じテストを実行できます。
strategy:
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
例えば、手元のPython 3.13では動くものの、Python 3.11では使えない文法を書いている場合、その場で検出できます。
逆に、すべてのバージョンをサポートする必要がないなら、無理に増やす必要はありません。
重要なのは、対応するPythonバージョンを暗黙にせず、Workflow上で明示することです。
キャッシュは環境を使い回す機能ではない
今回、setup-pythonに次の設定を追加しています。
with:
cache: pip
これは、インストール済みの仮想環境全体を使い回す設定ではありません。
pipがダウンロードしたパッケージをキャッシュし、次回以降のインストールを速くするための設定です。
依存関係のインストール自体は毎回実行します。
python -m pip install -r requirements-dev.txt
そのため、CIの再現性を保ちながら、実行時間を短縮できます。
CIが通っても科学的に正しいとは限らない
GitHub Actionsでテストが成功しても、解析結果が科学的に正しいことまでは保証できません。
CIで確認できるのは、主に次の範囲です。
入力値の検証
設定ファイルの読み込み
コマンドの生成
データ形式の検査
既知の入力に対する出力
意図しないファイル変更
過去に修正したバグの再発
一方、次の判断は別途必要です。
物理モデルが妥当か
統計手法の選択が正しいか
較正データが適切か
結果の解釈が妥当か
自動テストは科学的なレビューの代わりではありません。
それでも、単純な実装ミスや環境差を自動で除去できれば、人間は解析内容の確認に集中できます。
まとめ
GitHub Actionsを導入した目的は、単にクラウド上でもpytestを動かすことではありません。
ローカル環境に隠れた依存関係を見つける
複数のPythonバージョンで確認する
外部ツール依存のテストを分離する
pushするたびに同じ条件で検証する
ローカルで大量のテストが通っていても、その環境でしか動かなければ再現可能なコードとはいえません。
GitHub Actionsでまっさらな環境からテストすることで、「自分の環境では動く」から「リポジトリだけで動く」へ、一段進められました。