1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルで「555 passed」でも安心しない。GitHub Actionsで研究コードの手元依存を見つける

1
Posted at

はじめに

研究用のPythonコードにpytestを導入したところ、ローカル環境では次のように全テストが成功するようになりました。

================ 555 passed in 18.24s ================

しかし、テスト数が増えても、次の問題は残ります。

  • 手元にだけインストールされたパッケージを使っている
  • Git管理されていない設定ファイルを参照している
  • 特定の環境変数やPATHに依存している
  • 自分が使っているPythonバージョンでしか動かない

そこでGitHub Actionsを使い、コードをpushするたびに、まっさらな環境でpytestを実行するようにしました。

最小構成

リポジトリに次のファイルを追加します。

.github/
└── workflows/
    └── pytest.yml

内容は次のとおりです。

.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にもマーカーを登録します。

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でまっさらな環境からテストすることで、「自分の環境では動く」から「リポジトリだけで動く」へ、一段進められました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?