基礎

Python pytest入門|テストを書いてコードの品質を保つ

Python pytest テスト

Python pytest入門
テストを書いてコードの品質を保つ

pytestを使ったPythonのテスト方法を解説。基本的なテスト、フィクスチャ、パラメータ化、モック、カバレッジまで学べます。

こんな人向けの記事です

  • Pythonのテストを書き始めたい
  • pytestの基本的な使い方を学びたい
  • フィクスチャやモックを活用したい

Step 1テストの必要性とpytestの概要

ソフトウェア開発において、テストコードはコードの品質を保つための最も重要な手段の一つです。テストがあることで、機能追加やリファクタリングの際に「既存の動作が壊れていないか」を自動的に確認できます。

なぜテストを書くのか

メリット 説明
バグの早期発見 コード変更のたびに自動で検証でき、デグレードを防止
リファクタリングの安心感 テストが通れば動作が保証されるため、積極的にコード改善できる
ドキュメントとしての役割 テストコードを読めば、関数やクラスの使い方がわかる
チーム開発の効率化 他のメンバーの変更が既存機能に影響しないことをCIで確認できる

pytestとは

pytestはPython標準のunittestに代わる、最も人気のあるテストフレームワークです。シンプルな構文、豊富なプラグイン、わかりやすいエラー出力が特徴です。

pytestが選ばれる理由
1. テスト関数に assert 文を書くだけでOK(特別なAPIを覚える必要がない)
2. テスト失敗時の差分表示が非常にわかりやすい
3. フィクスチャ(fixture)による柔軟な前準備・後片付け
4. プラグインが豊富(カバレッジ、並列実行、Django対応など)
pytestとunittestの比較
# unittest(標準ライブラリ)
import unittest

class TestCalc(unittest.TestCase):
    def test_add(self):
        self.assertEqual(1 + 1, 2)

# pytest(シンプル!)
def test_add():
    assert 1 + 1 == 2

pytestではクラスの継承も特別なメソッドも不要です。関数名を test_ で始めるだけでテストとして認識されます。

Step 2pytestのインストールと基本的なテスト

インストール

ターミナル
# pytestのインストール
pip install pytest

# バージョン確認
pytest --version

プロジェクト構成

pytestはデフォルトで test_ で始まるファイルや _test.py で終わるファイルを自動検出します。

推奨するプロジェクト構成
my_project/
├── src/
│   └── calculator.py      # テスト対象のコード
├── tests/
│   ├── __init__.py
│   ├── test_calculator.py # テストコード
│   └── conftest.py        # 共通フィクスチャ
├── pytest.ini             # pytest設定ファイル
└── requirements.txt

最初のテストを書く

src/calculator.py
def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def multiply(a, b):
    return a * b

def divide(a, b):
    if b == 0:
        raise ValueError("0で割ることはできません")
    return a / b
tests/test_calculator.py
from src.calculator import add, subtract, multiply, divide
import pytest

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

def test_subtract():
    assert subtract(10, 4) == 6

def test_multiply():
    assert multiply(3, 7) == 21

def test_divide():
    assert divide(10, 2) == 5.0

def test_divide_by_zero():
    # 例外が発生することをテスト
    with pytest.raises(ValueError, match="0で割ることはできません"):
        divide(10, 0)

テストの実行

ターミナル
# 全テストを実行
pytest

# 詳細な出力(-v: verbose)
pytest -v

# 特定のファイルだけ実行
pytest tests/test_calculator.py

# 特定のテスト関数だけ実行
pytest tests/test_calculator.py::test_add

# 失敗したテストだけ再実行
pytest --lf
実行結果の例
$ pytest -v
========================= test session starts =========================
collected 5 items

tests/test_calculator.py::test_add PASSED          [ 20%]
tests/test_calculator.py::test_subtract PASSED     [ 40%]
tests/test_calculator.py::test_multiply PASSED     [ 60%]
tests/test_calculator.py::test_divide PASSED       [ 80%]
tests/test_calculator.py::test_divide_by_zero PASSED [100%]

========================== 5 passed in 0.02s ==========================
pytest.iniで設定をまとめる
プロジェクトルートに pytest.ini を作成すると、毎回オプションを指定する手間を省けます。
[pytest]
testpaths = tests
addopts = -v --tb=short
pyproject.toml[tool.pytest.ini_options] セクションでも同様に設定できます。

Step 3assert文とフィクスチャ(fixture)

assert文の豊富な使い方

pytestでは標準の assert 文をそのまま使います。失敗時には値の差分を自動で表示してくれるため、原因の特定が簡単です。

さまざまなassertパターン
# 等値チェック
assert result == expected

# 真偽チェック
assert is_valid is True
assert error_message is None

# 包含チェック
assert "hello" in greeting
assert 42 in numbers_list

# 比較チェック
assert len(items) > 0
assert score >= 80

# 型チェック
assert isinstance(result, dict)

# 近似値チェック(浮動小数点)
assert result == pytest.approx(3.14, abs=0.01)

# 例外チェック
with pytest.raises(TypeError):
    int("abc")
浮動小数点の比較に注意
assert 0.1 + 0.2 == 0.3 は浮動小数点の誤差でFailになります。
pytest.approx() を使って近似値で比較しましょう:
assert 0.1 + 0.2 == pytest.approx(0.3)

フィクスチャ(fixture)とは

フィクスチャは、テストの前準備(セットアップ)と後片付け(ティアダウン)を行う仕組みです。テスト関数の引数にフィクスチャ名を指定するだけで、自動的に呼び出されます。

tests/test_user.py
import pytest

# フィクスチャの定義
@pytest.fixture
def sample_user():
    """テスト用のユーザーデータを作成"""
    return {
        "name": "田中太郎",
        "email": "tanaka@example.com",
        "age": 30,
    }

@pytest.fixture
def user_list():
    """テスト用のユーザーリスト"""
    return [
        {"name": "田中太郎", "age": 30},
        {"name": "鈴木花子", "age": 25},
        {"name": "佐藤次郎", "age": 35},
    ]

# テスト関数の引数にフィクスチャ名を指定
def test_user_name(sample_user):
    assert sample_user["name"] == "田中太郎"

def test_user_email(sample_user):
    assert "@" in sample_user["email"]

def test_user_list_count(user_list):
    assert len(user_list) == 3

yieldを使ったセットアップ/ティアダウン

yield を使うと、テスト終了後の後片付け処理を書けます。データベース接続や一時ファイルの削除に便利です。

tests/conftest.py
import pytest
import tempfile
import os

@pytest.fixture
def temp_file():
    """一時ファイルを作成し、テスト後に削除"""
    # セットアップ
    fd, path = tempfile.mkstemp(suffix=".txt")
    os.write(fd, b"test data")
    os.close(fd)

    yield path  # テスト関数にpathを渡す

    # ティアダウン(テスト終了後に実行)
    if os.path.exists(path):
        os.remove(path)

def test_temp_file_exists(temp_file):
    assert os.path.exists(temp_file)

def test_temp_file_content(temp_file):
    with open(temp_file, "rb") as f:
        assert f.read() == b"test data"

フィクスチャのスコープ

フィクスチャにはスコープを設定でき、実行頻度を制御できます。

スコープ 実行タイミング 用途
function(デフォルト) 各テスト関数ごとに実行 軽量なデータ作成
class テストクラスごとに1回 クラス内で共有するデータ
module テストファイルごとに1回 DB接続など中程度のコスト
session テストセッション全体で1回 DBマイグレーション等の重い処理
スコープの指定例
@pytest.fixture(scope="session")
def db_connection():
    """テストセッション全体で1回だけDB接続を作成"""
    conn = create_connection()
    yield conn
    conn.close()

@pytest.fixture(scope="module")
def test_data(db_connection):
    """テストファイルごとにテストデータを投入"""
    db_connection.execute("INSERT INTO ...")
    yield
    db_connection.execute("DELETE FROM ...")

Step 4パラメータ化テスト(@pytest.mark.parametrize)

同じテストロジックを異なる入力値で繰り返し実行したい場合、パラメータ化テストを使います。テスト関数をコピペする必要がなくなり、テストケースの追加も簡単です。

基本的なパラメータ化

tests/test_calculator_parametrize.py
import pytest
from src.calculator import add, divide

# 複数の入力パターンをまとめてテスト
@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
    (-5, -3, -8),
])
def test_add(a, b, expected):
    assert add(a, b) == expected
実行結果
$ pytest -v tests/test_calculator_parametrize.py
tests/test_calculator_parametrize.py::test_add[1-2-3] PASSED
tests/test_calculator_parametrize.py::test_add[0-0-0] PASSED
tests/test_calculator_parametrize.py::test_add[-1-1-0] PASSED
tests/test_calculator_parametrize.py::test_add[100-200-300] PASSED
tests/test_calculator_parametrize.py::test_add[-5--3--8] PASSED

IDを付けて可読性を上げる

テストケースにIDを付与
@pytest.mark.parametrize("a, b, expected", [
    pytest.param(1, 2, 3, id="positive"),
    pytest.param(0, 0, 0, id="zero"),
    pytest.param(-1, 1, 0, id="negative_positive"),
    pytest.param(-5, -3, -8, id="both_negative"),
])
def test_add_with_ids(a, b, expected):
    assert add(a, b) == expected

# 実行結果:
# test_add_with_ids[positive] PASSED
# test_add_with_ids[zero] PASSED
# test_add_with_ids[negative_positive] PASSED
# test_add_with_ids[both_negative] PASSED

例外のパラメータ化テスト

例外を期待するパラメータ化テスト
@pytest.mark.parametrize("a, b", [
    (10, 0),
    (0, 0),
    (-5, 0),
])
def test_divide_by_zero(a, b):
    with pytest.raises(ValueError):
        divide(a, b)

複数のパラメータを組み合わせる

複数のparametrizeデコレータ — 直積(全組み合わせ)
# 3 x 3 = 9パターンのテストが自動生成される
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20, 30])
def test_multiply_combinations(x, y):
    result = x * y
    assert result == x * y
パラメータ化テストのベストプラクティス
1. 正常系と異常系を分けてパラメータ化する
2. 境界値(0、空文字、None、最大値)を必ず含める
3. id を付けてテスト結果を読みやすくする
4. テストケースが多すぎる場合は、代表的なパターンに絞る

Step 5モックとパッチ(unittest.mock)

テスト対象のコードが外部APIやデータベースに依存している場合、モック(mock)を使って依存を差し替えます。これにより、外部に依存しない安定したテストが書けます。

モックの基本概念

用語 説明
モック(Mock) 本物のオブジェクトの代わりに使う偽のオブジェクト
パッチ(Patch) テスト中だけ特定の関数やクラスをモックに差し替える仕組み
戻り値の設定 return_value でモックの戻り値を指定
呼び出しの検証 assert_called_once_with() で正しく呼ばれたか確認

テスト対象のコード

src/weather.py
import requests

def get_weather(city):
    """外部APIから天気情報を取得"""
    url = f"https://api.weather.example.com/{city}"
    response = requests.get(url)
    response.raise_for_status()
    data = response.json()
    return {
        "city": city,
        "temperature": data["temp"],
        "description": data["description"],
    }

def get_weather_message(city):
    """天気情報をもとにメッセージを生成"""
    weather = get_weather(city)
    temp = weather["temperature"]
    if temp >= 30:
        return f"{city}は{temp}度です。熱中症に注意!"
    elif temp <= 5:
        return f"{city}は{temp}度です。防寒対策を!"
    return f"{city}は{temp}度です。快適な気温です。"

unittest.mockによるモック

tests/test_weather.py
from unittest.mock import patch, MagicMock
from src.weather import get_weather, get_weather_message

# @patchデコレータでrequests.getをモックに差し替え
@patch("src.weather.requests.get")
def test_get_weather(mock_get):
    # モックのレスポンスを設定
    mock_response = MagicMock()
    mock_response.json.return_value = {
        "temp": 25,
        "description": "晴れ"
    }
    mock_response.raise_for_status.return_value = None
    mock_get.return_value = mock_response

    # テスト実行
    result = get_weather("Tokyo")

    # 結果を検証
    assert result["city"] == "Tokyo"
    assert result["temperature"] == 25
    assert result["description"] == "晴れ"

    # requests.getが正しいURLで呼ばれたか検証
    mock_get.assert_called_once_with(
        "https://api.weather.example.com/Tokyo"
    )

関数を丸ごとモックする

tests/test_weather_message.py
from unittest.mock import patch
from src.weather import get_weather_message

# get_weather関数をモックして、APIを呼ばずにテスト
@patch("src.weather.get_weather")
def test_hot_weather_message(mock_get_weather):
    mock_get_weather.return_value = {
        "city": "Tokyo",
        "temperature": 35,
        "description": "晴れ",
    }
    msg = get_weather_message("Tokyo")
    assert "熱中症に注意" in msg

@patch("src.weather.get_weather")
def test_cold_weather_message(mock_get_weather):
    mock_get_weather.return_value = {
        "city": "Sapporo",
        "temperature": 2,
        "description": "雪",
    }
    msg = get_weather_message("Sapporo")
    assert "防寒対策" in msg

@patch("src.weather.get_weather")
def test_comfortable_weather_message(mock_get_weather):
    mock_get_weather.return_value = {
        "city": "Osaka",
        "temperature": 22,
        "description": "曇り",
    }
    msg = get_weather_message("Osaka")
    assert "快適な気温" in msg

pytestのmonkeypatchフィクスチャ

pytestには独自のmonkeypatchフィクスチャがあり、unittest.mockの代わりに使えます。

monkeypatchを使ったテスト
import os

def get_database_url():
    return os.environ.get("DATABASE_URL", "sqlite:///default.db")

# monkeypatchで環境変数を差し替え
def test_get_database_url(monkeypatch):
    monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test")
    assert get_database_url() == "postgresql://localhost/test"

def test_get_database_url_default(monkeypatch):
    monkeypatch.delenv("DATABASE_URL", raising=False)
    assert get_database_url() == "sqlite:///default.db"
パッチする場所に注意
@patchテスト対象モジュールから見たインポート先をパッチします。
例えば src/weather.pyimport requests しているなら、パッチ先は "src.weather.requests.get" であり、"requests.get" ではありません。

Step 6カバレッジの計測(pytest-cov)

カバレッジとは、テストがコード全体のどれだけの割合を実行しているかの指標です。pytest-covプラグインを使えば、pytestの実行と同時にカバレッジを計測できます。

インストール

ターミナル
pip install pytest-cov

カバレッジの計測

ターミナル
# srcディレクトリのカバレッジを計測
pytest --cov=src tests/

# 行ごとの詳細(カバーされていない行を表示)
pytest --cov=src --cov-report=term-missing tests/

# HTML形式でレポートを出力(ブラウザで見やすい)
pytest --cov=src --cov-report=html tests/
カバレッジレポートの例
$ pytest --cov=src --cov-report=term-missing tests/
---------- coverage: platform darwin, python 3.13.0 ----------
Name                  Stmts   Miss  Cover   Missing
-----------------------------------------------------
src/calculator.py         8      0   100%
src/weather.py           14      2    86%   18-19
-----------------------------------------------------
TOTAL                    22      2    91%

Missing 列に表示される行番号が、テストで実行されていない行です。この情報をもとにテストケースを追加します。

カバレッジの最低ラインを設定

pytest.ini
[pytest]
addopts = --cov=src --cov-report=term-missing --cov-fail-under=80

--cov-fail-under=80 を設定すると、カバレッジが80%未満の場合にテストが失敗扱いになります。CIに組み込むことで、カバレッジの低下を防げます。

HTMLレポートの活用

HTMLレポートを生成して確認
# HTMLレポートを生成
pytest --cov=src --cov-report=html tests/

# htmlcov/index.html をブラウザで開く
open htmlcov/index.html
カバレッジの目安
80%以上を目標にするのが一般的です。ただし、100%にこだわる必要はありません。
重要なのはビジネスロジックや条件分岐のテストをしっかり書くことです。
設定ファイルの読み込みや単純なgetter/setterのテストは優先度を下げてもOKです。

pyproject.tomlでの一括設定

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short --cov=src --cov-report=term-missing"

[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/migrations/*"]

[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = [
    "pragma: no cover",
    "if __name__ == .__main__.",
    "if TYPE_CHECKING:",
]

この記事のまとめ

  • pytestはシンプルなassert文だけでテストが書ける
  • フィクスチャでテストの前準備・後片付けを効率化
  • パラメータ化で同じロジックを複数パターンでテスト
  • モックで外部依存を排除し、安定したテストを実現
  • カバレッジでテストの網羅率を可視化し、品質を維持
  • CIに組み込むことで、チーム全体のコード品質を保つ