【第4回】Python pytest 入門 — テスト設計の落とし穴と本番品質に近づけるための実践Tips

プログラミング

前回は pytest の fixture と conftest.py を使ったテストの共通化を紹介しました。今回はシリーズ最終回ということで、「テストを書いた後に何をすべきか」というところを掘り下げます。具体的には カバレッジ計測(pytest-cov)GitHub Actions への組み込み です。

「テスト書いたけど、どのくらいコードが通ってるかわからない」という状態、自分もずっとそのままにしてたんですが、カバレッジを可視化してみたら意外と穴だらけで少し凹みました。

この記事でわかること

  • pytest-cov を使ったカバレッジ計測の基本
  • pyproject.toml での設定方法
  • GitHub Actions での自動カバレッジチェック
  • FastAPI テストの実装パターン
  • テスト設計でよくある落とし穴と対策

pytest-cov とは?まず入れるだけ

pytest-cov は coverage.py を pytest から使えるようにしたプラグインです。インストールは一行。

pip install pytest-cov

あとは --cov オプションをつけて実行するだけで、計測が始まります。

# app/ ディレクトリのカバレッジをターミナルに表示
pytest --cov=app tests/

実行するとこんな感じの出力が出ます。

---------- coverage: platform linux, python 3.12 -----------
Name                    Stmts   Miss  Cover
-------------------------------------------
app/main.py                30      4    87%
app/routers/items.py       45     12    73%
app/crud.py                28      0   100%
-------------------------------------------
TOTAL                     103     16    84%

Miss が「テストが通っていない行数」、Cover が「カバレッジ率」です。100% を目指すのが正解というわけでもないんですが、とりあえず数字が見えるようになるだけでも違います。

HTML レポートを出すと圧倒的に見やすい

ターミナル出力だけだとどの「行」が未テストかわかりにくいので、HTML レポートも一緒に出すのをおすすめします。

pytest --cov=app --cov-report=term-missing --cov-report=html tests/

HTML レポートが生成されるので、ブラウザで開くとファイルごとに赤くハイライトされた未テスト行が見えます。これが結構わかりやすくて、「あ、エラーハンドリングのここ全然テストしてなかった」という発見があります。

設定は pyproject.toml にまとめる

毎回長いオプションを打つのはつらいので、pyproject.toml に設定を書いておきます。

[tool.pytest.ini_options]
addopts = "--cov=app --cov-report=term-missing --cov-report=xml"
testpaths = ["tests"]

[tool.coverage.run]
omit = [
    "app/main.py",      # エントリポイントは除外するケースもある
    "tests/*",
    "*/__init__.py",
]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if __name__ == .__main__.:",
    "raise NotImplementedError",
]

omit で対象外にするファイルを指定できます。マイグレーション系や __init__.py は除外するのが無難です。exclude_lines は「このパターンの行はカバレッジ対象外」にする設定で、raise NotImplementedError みたいな「実質テストできない行」を外せます。

余談ですが、pragma: no cover コメントを使って一行単位での除外もできます。自分は最初これを知らなくて、全体の数字を上げるためだけに無意味なテストを書くという本末転倒なことをやってました。

CI で「カバレッジが落ちたら落とす」を実現する

ローカルで見るだけなら上記で十分ですが、PR 時にカバレッジが一定を下回ったら CI を失敗させる仕組みにしておくのが有効です。

# カバレッジが 80% を下回ったら exit code 1 で終了する
pytest --cov=app --cov-fail-under=80 tests/

--cov-fail-under に閾値を指定するだけです。GitHub Actions では exit code が非ゼロなら CI が失敗扱いになるので、これを組み込むだけで自動的にガード線になります。

いきなり 80% に設定すると既存コードが全部引っかかることがあるので、最初は現状より少し低い値にしておいて、少しずつ上げていく方が現実的です。

GitHub Actions に組み込む

実際のワークフローファイルを書いてみます。FastAPI アプリを想定した構成です。

# .github/workflows/test.yml
name: Test & Coverage

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"

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install pytest pytest-cov httpx

      - name: Run tests with coverage
        run: pytest --cov=app --cov-report=xml --cov-fail-under=80

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml

ポイントは httpx を別途入れている点です。FastAPI(正確には土台の Starlette)の TestClient は裏側で httpx を使っているので、入れ忘れると「ModuleNotFoundError: No module named ‘httpx’」が出ることがあります。ローカルでは動くのに CI だけ落ちる原因の定番なので、一応書いておきました。

coverage.xml を Codecov に送る(任意)

カバレッジ結果を可視化・履歴管理したいなら Codecov を使う方法があります。無料で始められて、オープンソース向けの無償利用枠も案内されています。

      - name: Upload to Codecov
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.xml
          fail_ci_if_error: true

PR ごとにカバレッジの変化がコメントで通知されるので、「このPR でカバレッジが 2% 落ちた」みたいなことがすぐわかります。個人開発だと少しオーバースペックかもしれないですが、雰囲気が出ます(重要)。

テスト設計の落とし穴:自分が引っかかったパターン

最後に、シリーズを通じて実際に詰まったポイントをまとめておきます。

fixture の scope を間違えてテストが干渉する

前回紹介した fixture の scope ですが、scope="session" にしているとデータベースの状態がテスト間で共有されます。「単体では通るのに複数テストを走らせると落ちる」という現象はたいていこれです。

# NG: session スコープの DB をテスト間で汚染してしまうパターン
@pytest.fixture(scope="session")
def db():
    # セッション全体で1つのDB接続を使い回す
    ...

# OK: function スコープにしてテストごとにリセット
@pytest.fixture(scope="function")
def db():
    # テストごとに新しいトランザクションを張ってロールバック
    ...

速度は scope="session" の方が速いんですが、テストの独立性を保つためには scope="function" にしておく方が無難です。どうしても速度を優先したい場合は、テスト内でデータを明示的にクリーンアップする設計にする必要があります。

FastAPI の依存関係(Depends)を差し替えるのを忘れる

FastAPI は Depends で DB セッションや認証情報を注入するパターンが多いです。テスト時にこれをモック(差し替え)しないと、本物の DB に接続しようとして落ちます。

from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db
from tests.conftest import override_get_db

# 依存関係をテスト用に差し替える
app.dependency_overrides[get_db] = override_get_db

client = TestClient(app)

dependency_overrides に差し替え関数を渡すだけなんですが、これを知らずにハマり続けた時間がわりと長かったです。公式ドキュメントに書いてはあるんですが、最初は目に入らないんですよね。

カバレッジ 100% = 品質が高い、ではない

これは落とし穴というよりメンタルモデルの話ですが、カバレッジの数字だけを追いすぎると、「assert しない、ただ実行するだけのテスト」を書きがちです。行が通れば数字が上がるので。

カバレッジはあくまで「テストが到達していない場所を見つけるための指標」で、テストの質はまた別の話です。正直、自分もこのバランスはまだうまく取れてない気がします。80% を超えたからといって安心してはいけない、というのが今のところの感想です。

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

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

まとめ

全4回で pytest の基礎から fixture・conftest・カバレッジ・CI 連携まで一通り触ってみました。テストを書くこと自体はそれほど難しくないんですが、「どこまでテストするか」「どう設計するか」あたりは経験を積まないとわからない部分が多い印象です。

このシリーズで紹介した内容があれば、少なくとも「テストの基本を押さえながら、本番品質に近づけるフロー」は作れるはずです。pytest-mock や respx(外部 HTTP リクエストのモック)といった周辺ツールも、ここからの応用として学ぶと理解しやすいと思います。

📚 シリーズ「Python pytest 入門 — FastAPIアプリを題材にテスト設計をゼロから学ぶ」(第4回 / 全4回)

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

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

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

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