Python のテストを 設計 → 実装 → 自動化 → 高速化 まで一気通貫で理解できる保存版です。
- unittest より圧倒的に使いやすい
- Java の JUnit 経験者なら「簡潔さ」に驚く
- 実務で必要な知識をすべて網羅
- pytest 9 の import 仕様変更にも対応
この記事の目的
- pytest を 本質から理解する
- 実務で使える テスト設計・fixture・モック を習得
- TDD(テスト駆動開発) を実践
- GitHub Actions で自動テスト
- 並列実行で高速化
- 良いテスト/悪いテストの基準を理解
- pytest 9 の import 罠を回避
- 最後に 実務テンプレ構成を提供
pytest とは何か?
Python のテストを “簡単・強力・高速” にする最強ツール
もっと短く言うと…
pytest = Python のテストを自動化する仕組み
unittest との違い
| 項目 | unittest | pytest |
|---|---|---|
| 記述量 | 多い | 少ない |
| テスト形式 | クラス必須 | 関数でOK |
| アサート | assertEqual など多数 | assert だけ |
| 拡張性 | 弱い | 強力(fixture, plugin) |
pytest の本質
「引数」と「期待結果」をセットで書く
Java Silver の「メソッドの挙動確認」を 自動化したもの と考えると理解しやすい。
結論
「引数と結果を書く」でOKだけど、それだけだと不十分
テストは 4 つの観点で考える(実務の本質)
| 観点 | 目的 | 例 |
|---|---|---|
| ✔ 正常系 | 普通に動くか | add(2,3)=5 |
| ✔ 異常系 | 変な入力でどうなるか | ZeroDivisionError |
| ✔ 境界値 | ギリギリの値 | 0, -1, max |
| ✔ 副作用 | 外部への影響 |
① 正常系(Normal Case)
def test_add_normal():
assert add(2, 3) == 5
② 異常系(Error Case)
import pytest
def test_div_zero():
with pytest.raises(ZeroDivisionError):
div(1, 0)
## ③ 境界値(Boundary)
python
def test_add_zero():
assert add(0, 0) == 0
④ 複数パターン(パラメータ化)
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add(a, b, expected):
assert add(a, b) == expected
つまり
「引数と結果を書く」は正しい
でも“いろんなパターンで書く”のが実務
実務での思考テンプレ
関数を作ったら毎回これを考える
1️⃣ 普通のケースは?
2️⃣ エラーになるケースは?
3️⃣ ギリギリの値は?
4️⃣ パターン増やせる?
どこまでテスト書けばいいのか?(過不足問題)
結論
壊れたら困るところだけ全部テストする
どうでもいいところは書かない
✔ テストを書くべき場所(重要度が高い)
① ビジネスロジック(必須)
def calc_price(price, tax):
return price * (1 + tax)
② 条件分岐がある
def discount(price, is_member):
if is_member:
return price * 0.9
return price
③ バグりやすい処理
日付
金額
計算
ステータス管理
④ 外部依存(モック必須)
DB
API
ファイル
書かなくていいテスト
① ただのラッパー
python
def get_user():
return repository.get_user()
② フレームワークの動き
(Django の標準機能など)
③ 単純すぎるコード
def add(a, b):
return a + b
黄金ルール
壊れたときに気づけないとヤバい処理だけテストする
良いテスト vs ダメなテスト(レビュー観点)
良いテスト
- 1テスト = 1目的
- 名前が仕様になっている
- 期待値が明確
- 外部依存をモック
- 速い(数秒で終わる)
ダメなテスト
- 何をテストしてるかわからない
- 複数のことを同時にテスト
- 外部APIを本当に叩く
- 内部実装に依存
- 期待値が雑(is not None など)
テストが壊れる原因(アンチパターン)
| アンチパターン | なぜダメ? | 対策 |
|---|---|---|
| モックしすぎ | 偽物の世界になる | 重要な依存だけモック |
| 重すぎる fixture | 遅い・壊れやすい | 小さく軽く |
| 状態共有 | 実行順で結果変わる | テストは独立 |
| ランダム・時間依存 | 不安定 | 固定値 or モック |
| カバレッジ信仰 | 100%でもバグる | 重要ロジック重視 |
最終まとめ
✔ pytest の基本は「引数 + 期待結果」
✔ 実務では「正常系・異常系・境界値・副作用」を書く
✔ テストは“壊れたら困るところ”だけ書く
✔ 良いテストは「壊れた原因が一発でわかる」
✔ ダメなテストは「落ちても意味不明」
pytest 基本文法(実務で使う部分だけ)
テスト関数
def test_add():
assert add(2, 3) == 5
例外テスト
def test_div_zero():
with pytest.raises(ZeroDivisionError):
div(1, 0)
パラメータ化
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
])
def test_add(a, b, expected):
assert add(a, b) == expected
fixture の高度な使い方(DB・API・依存 fixture)
DBトランザクション
@pytest.fixture
def db_session():
conn = connect_db()
tx = conn.begin()
yield conn
tx.rollback()
conn.close()
依存 fixture
@pytest.fixture
def user(db_session):
db_session.execute("INSERT INTO users VALUES (1, 'hiro')")
return {"id": 1, "name": "hiro"}
APIクライアント
@pytest.fixture(scope="module")
def api_client():
return APIClient()
scope の種類
scope 説明
function テストごと
module ファイルごと
session 全体で1回
モック(patch)の仕組み
本質:呼び出し元の名前空間を書き換える
関数モック
@patch("src.service.send_mail")
def test_notify(mock_send):
mock_send.return_value = True
クラスモック
@patch("src.service.UserService")
def test_user(mock_service):
instance = mock_service.return_value
instance.get_user.return_value = {"id": 1}
pytest 9 の import 仕様変更(重要)
pytest 9 から importlib モード が標準になり、
tests の構造が import の成否に直結するようになった。
結論
pytest 9 では tests のフォルダ構造が Python の import に強く影響する
例え話で理解する
■ pytest 8 まで
pytest が “いい感じに” import を補正してくれた
→ 多少雑でも動いた
■ pytest 9
Python 標準の importlib をそのまま使用
→ tests の構造がモロに影響
→ 名前衝突・init.py の有無で import が壊れる
よくあるトラブル
tests/adapter と src/adapter が同名
→ tests 側が優先される
→ 本来の src が import されない
tests/repository に init.py がある
→ tests がパッケージ扱い
→ src より優先される
→ テストが壊れる
対策
- tests 配下に init.py を置かない
- src と tests で 同名ディレクトリを作らない
- pytest.ini に pythonpath を書かない
- 必要なら sys.path を明示的に調整
テスト設計書テンプレ(汎用)
1. テスト対象
- モジュール:
- 関数:
2. テスト目的
- 何を保証するか
3. テスト観点
| 観点 | 内容 |
|---|---|
| 正常系 | 正しい入力 |
| 異常系 | エラー |
| 境界値 | ギリギリ値 |
| 副作用 | DB更新・API呼び出し |
4. 前提条件
- DB状態
- モック値
- 設定
5. テストケース
|No|観点|入力|期待結果|
6. 実装方針
- fixture
- モック
- パラメータ化
TDD 実践(BMI 計算)
Step1: テストを書く
@pytest.mark.parametrize("w,h,expected", [
(60, 1.7, 20.76),
(80, 1.8, 24.69),
])
def test_bmi(w, h, expected):
assert round(bmi(w, h), 2) == expected
def test_bmi_zero_height():
with pytest.raises(ZeroDivisionError):
bmi(60, 0)
def test_bmi_negative_weight():
with pytest.raises(ValueError):
bmi(-10, 1.7)
Step2: 実装
def bmi(weight, height):
if weight < 0:
raise ValueError
return weight / (height ** 2)
pytest 高速化(並列・キャッシュ・分散)
並列実行
pytest -n auto
### キャッシュ
pytest --lf
pytest --ff
CI 分散
strategy:
matrix:
shard: [1, 2]
GitHub Actions(自動テスト)
name: Python Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- run: pip install pytest pytest-cov
- run: |
coverage run -m pytest
coverage xml
- uses: codecov/codecov-action@v3
実務テンプレ構成(推奨)
project/
├── src/
│ └── app/
├── tests/
│ ├── unit/
│ ├── integration/
│ ├── e2e/
│ └── conftest.py
├── docs/
├── pytest.ini
└── .github/workflows/
まとめ
pytest でできること:
- 正常系・異常系・境界値のテスト
- 副作用(DB・API)の制御
- モックによる外部依存の排除
- CI による自動化
- 並列実行による高速化
- pytest 9 の import 罠を回避