Pythonテスト #1 pytest入門: assertひとつで十分な理由

コードを直したあとに「他の場所が壊れていないだろうか」という不安を感じたことがあるなら、このシリーズはその不安をなくす方法を扱います。モダンPython基礎 を終えた方を想定し、Python テストの標準ツールである pytest を土台から固める 7 本です。

  • #1 pytest 入門 ← 今回
  • #2 フィクスチャ (fixture)
  • #3 parametrize とマーカー
  • #4 mock と monkeypatch
  • #5 外部世界のテスト (HTTP、DB、Web フレームワーク)
  • #6 テスト設計とカバレッジ
  • #7 CI 連携 (まとめ)

FastAPI エンドポイントのテストは モダンPython実践 #6 ですでに 1 本にまとめて扱いました。あの記事が Web フレームワークの上でテストを回す方法だったとすれば、このシリーズはその土台になる pytest そのもの を最初から一歩ずつ積み上げていきます。

テストがないコードの本当のコスト #

テストがないプロジェクトで起きることは、だいたい似ています。最初はスピードが出ます。テストを書く時間で機能をもう 1 つ作れるからです。問題はコードが積み上がったあとに始まります。

ユーティリティ関数を 1 つ直したら、その関数を使っている別の画面が静かに壊れます。デプロイ後にユーザーからの報告で初めて気づきます。そんなことが 2、3 回繰り返されると、チーム全体が同じ結論にたどり着きます。「なるべく触らないようにしよう」。この瞬間からコードは老い始めます。構造が悪くても直せず、重複が見えても整理できません。修正が怖くなる瞬間 こそ、テストがないコードの本当のコストです。

テストは「このコードはこう動作する」という実行可能な証拠です。証拠があれば、コードを直したあとコマンド 1 回ですべて確認し直せて、修正はもう怖くありません。

インストール: uv add –dev pytest #

pytest は外部パッケージなのでインストールが必要です。uv プロジェクトなら 1 行です。

pytest のインストール
uv add --dev pytest

--dev は「プロダクションのデプロイには不要で、開発時だけ使う依存関係」という印です。テストツールが本番サーバーに載る理由はないので、dev グループが正確な置き場所です。uv を使っていない場合も pip install pytest で同じようにインストールできます。

最初のテスト: assert ひとつで十分です #

テストする関数を 1 つ作ります。

calc.py
def add(a: int, b: int) -> int:
    return a + b

テストはこう書きます。

test_calc.py
from calc import add


def test_add():
    assert add(2, 3) == 5

これで全部です。ルールは 2 つだけです。

  • テスト関数の名前は test_ で始めます
  • 確認したい条件を Python 組み込みの assert 文で書きます

専用のクラスも、専用の検証メソッドも要りません。実行もコマンド 1 つです。

実行
uv run pytest
出力
========================= test session starts =========================
collected 1 item

test_calc.py .                                                   [100%]

========================== 1 passed in 0.01s ==========================

. 1 つが通過したテスト 1 件です。テストが増えれば点が増え、失敗するとその位置に F が表示されます。

unittest との比較: クラスも assertEqual も要りません #

標準ライブラリにも unittest というテストフレームワークがあります。同じテストを unittest で書くとこうなります。

🚫 unittest 方式
import unittest

from calc import add


class TestAdd(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)


if __name__ == "__main__":
    unittest.main()

クラスを作り、TestCase を継承し、self.assertEqual を呼び出します。検証の種類ごとにメソッド名も違います。assertEqualassertTrueassertInassertIsNoneassertRaises のように数十個を区別して使わなければなりません。

pytest はこの全部を assert ひとつで置き換えます。

✅ pytest 方式: 全部 assert
def test_various():
    assert add(2, 3) == 5            # assertEqual
    assert add(1, 1) > 0             # assertGreater
    assert 3 in [1, 2, 3]            # assertIn
    assert add(0, 0) is not None     # assertIsNotNone

これが可能な理由は pytest の assert 書き換え (assert rewriting) です。pytest はテストファイルを import する時点で assert 文を内部的に書き直し、失敗したときに式の中間値まですべて見せるように変えます。普通の Python の assert は失敗しても AssertionError を 1 行投げるだけですが、pytest の中では「何がどう違ったのか」がそのまま出力されます。専用メソッドを覚える必要がない理由がこれです。

例外のテストだけは専用ツールを使います。unittest の assertRaises に当たる pytest.raises です。

例外のテスト
import pytest


def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0

失敗出力の読み方 #

テストは失敗するときに価値が現れます。わざと間違ったテストを回してみます。

失敗するテスト
def test_add_wrong():
    assert add(2, 3) == 6
失敗出力
________________________ test_add_wrong ________________________

    def test_add_wrong():
>       assert add(2, 3) == 6
E       assert 5 == 6
E        +  where 5 = add(2, 3)

test_calc.py:8: AssertionError

読み方は単純です。

  • > の行が失敗したコードの位置です
  • E の行が実際の値です。add(2, 3)5 を返し、期待値の 6 と違うという事実がそのまま見えます

辞書やリストを比較すると、どこが違うのかも指摘してくれます。

コレクション比較の失敗出力 (一部)
E       AssertionError: assert {'id': 1, 'role': 'admin'} == {'id': 1, 'role': 'member'}
E         Differing items:
E         {'role': 'admin'} != {'role': 'member'}

出力が省略されてもどかしいときは uv run pytest -vv で比較結果の全体を見られます。失敗出力をきちんと読めるだけでもデバッグ時間の半分が減ります。

テストディスカバリー: pytest がテストを見つけるルール #

uv run pytest と入力しただけでテストファイルが実行された理由は、pytest が決まったルールでテストを自動収集するからです。これを テストディスカバリー (test discovery) と呼びます。

  • ファイル: test_*.py または *_test.py
  • 関数: test_ で始まる名前
  • クラス: Test で始まり __init__ を持たないクラスの中の test_ メソッド

このルールに合わないテストは静かに無視されます。tset_calc.py のようにタイプミスしたファイルは実行されないので、「通った」ではなく「収集すらされていない」可能性を常に疑わなければなりません。出力 1 行目の collected N items の数字を確認する習慣がそれで重要になります。

プロジェクトが大きくなったら、テストを tests/ ディレクトリに分離する構成が標準です。

推奨構成
myproject/
├── src/
│   └── calc.py
├── tests/
│   └── test_calc.py
└── pyproject.toml

pytest が tests/ だけを見るように設定も書いておきます。

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]

src レイアウトで tests/ からの import が動作するには、プロジェクト自身がパッケージとしてインストールされている必要がありますが、uv プロジェクトで uv run pytest を使えばこの部分は自動で処理されます。

何をテストするか #

最初の記事なので、原則を 2 つだけ軽く押さえます。

第一に、公開された動作 をテストします。関数に何を入れたら何が出てくるかを検証し、内部でどんな変数を経由するかは検証しません。内部実装はリファクタリングで変わりますが、公開された動作は維持されるべきで、テストはその維持を守る装置だからです。

第二に、境界値 をテストします。バグは平凡な入力よりも端っこに隠れていることが多いです。

src/textutil.py
def truncate(text: str, limit: int) -> str:
    if len(text) <= limit:
        return text
    return text[:limit] + "..."
tests/test_textutil.py
from textutil import truncate


def test_short_text_unchanged():
    assert truncate("hi", 10) == "hi"


def test_long_text_truncated():
    assert truncate("hello world", 5) == "hello..."


def test_exact_limit():
    assert truncate("hello", 5) == "hello"


def test_empty_string():
    assert truncate("", 5) == ""

長さが limit とちょうど同じ場合、空文字列のような境界が核心です。「この関数が受け取りうる一番変な入力は何だろう」と一度考えて、その入力をテストとして書いておけば、テスト 4 つでも関数の動作仕様が完成します。

まとめ #

今回扱った内容です。

  • テストがないコードの本当のコストは、修正が怖くなる瞬間に現れます
  • インストールは uv add --dev pytest、実行は uv run pytest です
  • テストは test_ で始まる関数に assert をひとつ書けば終わりです
  • unittest のクラスと assertEqual 系のメソッドは要りません。assert 書き換えのおかげで、組み込みの assert だけで豊富な失敗情報が出ます
  • 失敗出力の > 行は位置、E 行は実際の値です
  • ディスカバリーのルールは test_*.py ファイルと test_ 関数で、collected N items の数字を確認する習慣が重要です
  • テストの対象は内部実装ではなく、公開された動作と境界値です

次回 (#2 フィクスチャ) では、テストごとに繰り返される準備と後片付けを一ヶ所に集める フィクスチャ (fixture) を扱います。pytest を pytest らしくする機能の始まりです。

X