【第4回】Python pytest 入門 — モックのよくある誤りとテストを壊さないための落とし穴Tips

プログラミング

この記事でわかること

  • モックを使うときによくハマるパターン(patch の場所、return_value と side_effect の使い分けなど)
  • GitHub Actions で pytest を自動実行するための最小構成
  • カバレッジレポートとカバレッジ閾値の設定方法
  • 複数 Python バージョンでの並列テスト(matrix)
  • ローカルで GitHub Actions を事前検証する方法(act)

前回のおさらいと今回のテーマ

前回(第3回)は fixture とモックを組み合わせた実践的なテスト設計を紹介しました。DB接続や外部 API を fixture でまとめつつ、mocker.patch で依存を差し替えるパターンが中心でしたね。

今回はシリーズ最終回。「書いたテストを GitHub Actions で自動的に回す」ところまで持っていきます。CI に乗せてはじめて、テストは「ちゃんと機能するもの」になると思っていて、実はここが一番やりたかった回だったりします。

あわせて、モックを使うときにやってしまいがちなミスも整理します。「テストは通ってるのに本番では動かない」みたいな状況、割と起きやすいので。

モックのよくある誤りと落とし穴

CI の話をする前に、前回の続きとしてモック周りのハマりポイントを先に片付けておきます。

① patch する場所を間違える

これが一番多いと思います。patch は「そのオブジェクトが look up(参照)される名前空間」にあてる必要があります。

# src/notifier.py
import requests

def notify(url, message):
    return requests.post(url, json={"text": message})
# tests/test_notifier.py
from unittest.mock import patch

# ここは「どこで参照されているか次第」なので注意
# notifier 側が参照する先が src.notifier.requests.post なら、そこをパッチするのが安全
@patch("requests.post")
def test_notify_ng(mock_post):
    ...

# OK: notifier が使っている名前空間をパッチする
@patch("src.notifier.requests.post")
def test_notify_ok(mock_post):
    mock_post.return_value.status_code = 200
    from src.notifier import notify
    result = notify("https://example.com", "hello")
    assert mock_post.called

patch は「どこに定義されているか」じゃなくて「どこから参照されるか」が重要です。基本は「使われている側(この例だと src.notifier)の名前空間」をパッチするようにしています。最初はここで詰まりました。

② return_value と side_effect の混同

from unittest.mock import MagicMock

mock = MagicMock()

# 毎回同じ値を返したい → return_value
mock.return_value = 42

# 呼び出し回数によって違う値を返したい → side_effect にリスト
mock.side_effect = [1, 2, 3]

# 例外を発生させたい → side_effect に例外クラス or インスタンス
mock.side_effect = ValueError("invalid input")

side_effect をリストにすると「1回目は1、2回目は2…」と順番に返してくれます。リトライのテストを書くときに地味に便利です。

③ モックの assert を使わずにスルーしてしまう

「モックが正しく呼ばれたかどうか」を確認し忘れるケースです。

# テストが通っているように見えるが、実際に notify が呼ばれたかわからない
def test_send_notification(mocker):
    mock_notify = mocker.patch("src.service.notify")
    run_job()
    # assert がないのでモックが呼ばれなくてもテストは通る

# ちゃんと呼ばれたことを確認する
def test_send_notification_correct(mocker):
    mock_notify = mocker.patch("src.service.notify")
    run_job()
    mock_notify.assert_called_once_with("https://hook.example.com", "完了しました")

モックを差し込むこと自体が目的になってしまって、「本当に呼ばれたか」を検証しないパターンですね。テストが緑になっても意味が薄いので注意が必要です。

④ テスト間でモックの状態が汚染される

グローバルなオブジェクトをモックしていて、後のテストに副作用が残るケースです。pytest-mockmocker フィクスチャはテスト終了時にパッチを元に戻してくれますが、手動で patch() を使う場合は with ブロックかデコレータで必ず範囲を限定するようにしています。

# 手動パッチは with ブロックで囲う
def test_something():
    with patch("src.module.some_func") as mock_func:
        mock_func.return_value = "ok"
        result = do_something()
    assert result == "ok"
    # ここではもう patch が外れている

GitHub Actions で pytest を自動化する

ここからが本題です。ローカルでテストを書いても、毎回手動で pytest を走らせるのは続きません。GitHub にプッシュするたびに自動でテストが回る仕組みを作ります。

最小構成のワークフロー

まず動く形から始めます。

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"  # 依存ファイル(例: requirements.txt 等)のハッシュを使ってキャッシュキーを作る

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        run: pytest tests/

actions/setup-pythoncache: "pip" を付けると、依存関係ファイル(例: requirements.txt など)が変わっていない限り、パッケージのインストールがキャッシュから復元されるようです。地味ですが、CI の実行時間が結構変わります。

ファイル構成はこんな感じを想定しています。

my-project/
├── .github/
│   └── workflows/
│       └── ci.yml
├── src/
│   └── calculator.py
├── tests/
│   └── test_calculator.py
├── requirements.txt
└── pyproject.toml  # あれば

カバレッジレポートを追加する

テストが通るだけでなく、どのくらいのコードがテストされているかを確認する仕組みも入れておきます。

# requirements.txt に追加
pytest-cov
# .github/workflows/ci.yml(抜粋)
      - name: Run tests with coverage
        run: |
          pytest tests/ \
            --cov=src \
            --cov-report=xml \
            --cov-report=term-missing

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml
          if-no-files-found: warn

--cov-report=term-missing をつけると、カバレッジが不足している行がターミナル出力に表示されます。GitHub Actions のログで直接確認できるので便利です。coverage.xml は Artifact として保存しておくとダウンロードして後から確認できます。

カバレッジの閾値を設定してテスト失敗にする

「カバレッジが一定を下回ったら CI を落とす」設定も入れておくと、テストの質が自然と維持されます。

pytest tests/ --cov=src --cov-fail-under=80

80% という数字は目安です。個人プロジェクトではまず60〜70%あれば十分な気もしていて、「100% じゃないとダメ」みたいな空気には少し懐疑的です。重要なロジックがカバーされているかが本質なので。

複数 Python バージョンでテストする(matrix)

ライブラリを作っている場合や、チームで Python バージョンが混在している場合は matrix を使うと複数バージョンで並列テストできます。

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12", "3.13"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: "pip"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest tests/

個人プロジェクトだと正直そこまでやらないことが多いんですが、OSS として公開するときやチーム開発では入れておいて損はないです。3バージョン並列で走るので、直列より速く終わることもあります。

完成形のワークフロー全体像

ここまでの内容をまとめた、実用的な構成です。

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run pytest with coverage
        run: |
          pytest tests/ \
            --cov=src \
            --cov-report=xml \
            --cov-report=term-missing \
            --cov-fail-under=70 \
            --junitxml=test-results.xml

      - name: Upload coverage report
        if: always()  # テストが失敗してもアップロードする
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: |
            coverage.xml
            test-results.xml

if: always() をアップロードステップに入れているのがポイントで、テストが失敗したときでも Artifact は保存されます。失敗した原因を後から調べたいときに役立ちます。

ローカルでワークフローを事前確認する(act)

「YAML を push してみないと動くかわからない」問題、地味にストレスです。act というツールを使うと、GitHub Actions のワークフローをローカルで擬似実行できます。

# macOS の場合
brew install act

# ワークフローを実行
act push

Docker が必要で、初回は環境イメージのダウンロードに少し時間がかかります。完全に同じ動作保証はないですが、「YAML の書き方が間違ってないか」を確認するには十分です。これを知ってから不要なコミットが減りました。


※この記事にはプロモーションが含まれます

ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー

まとめ

このシリーズを通じて、テストの書き方から CI への乗せ方まで一通り触れてきました。

  • 第1回・第2回は pytest の基本と fixture
  • 第3回はモックを使った実践的なテスト設計
  • 第4回(今回)はモックのよくある誤りと GitHub Actions での自動化

正直なところ、テストを書く習慣をつけるまでが一番大変です。でも一度スタートすると「自動テストが走ってるおかげで、リファクタリングで何かを壊してないか安心できる」という感覚が得られます。そこまでいくと、テストを書くことが苦ではなくなっていくと思います。

CI に乗せることで、チームメンバーが PR を出すたびに自動的に品質チェックが走る仕組みも作れます。小さなことですが、継続的な改善の文化につながる気がしています。

📚 シリーズ「Python pytest 入門」(第4回 / 全4回)

← 前回の記事: 前回の記事はこちら

🎉 このシリーズは今回で完結です!

参考になったらクリックしてもらえると嬉しいです!

Blogmura ProgrammingProgramming Ranking
タイトルとURLをコピーしました