Python pytest入門
テストを書いてコードの品質を保つ
pytestを使ったPythonのテスト方法を解説。基本的なテスト、フィクスチャ、パラメータ化、モック、カバレッジまで学べます。
こんな人向けの記事です
- Pythonのテストを書き始めたい
- pytestの基本的な使い方を学びたい
- フィクスチャやモックを活用したい
Step 1テストの必要性とpytestの概要
ソフトウェア開発において、テストコードはコードの品質を保つための最も重要な手段の一つです。テストがあることで、機能追加やリファクタリングの際に「既存の動作が壊れていないか」を自動的に確認できます。
なぜテストを書くのか
| メリット | 説明 |
|---|---|
| バグの早期発見 | コード変更のたびに自動で検証でき、デグレードを防止 |
| リファクタリングの安心感 | テストが通れば動作が保証されるため、積極的にコード改善できる |
| ドキュメントとしての役割 | テストコードを読めば、関数やクラスの使い方がわかる |
| チーム開発の効率化 | 他のメンバーの変更が既存機能に影響しないことをCIで確認できる |
pytestとは
pytestはPython標準のunittestに代わる、最も人気のあるテストフレームワークです。シンプルな構文、豊富なプラグイン、わかりやすいエラー出力が特徴です。
assert 文を書くだけでOK(特別なAPIを覚える必要がない)2. テスト失敗時の差分表示が非常にわかりやすい
3. フィクスチャ(fixture)による柔軟な前準備・後片付け
4. プラグインが豊富(カバレッジ、並列実行、Django対応など)
# 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
最初のテストを書く
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
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]
testpaths = tests
addopts = -v --tb=short
pyproject.toml の [tool.pytest.ini_options] セクションでも同様に設定できます。
Step 3assert文とフィクスチャ(fixture)
assert文の豊富な使い方
pytestでは標準の 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)とは
フィクスチャは、テストの前準備(セットアップ)と後片付け(ティアダウン)を行う仕組みです。テスト関数の引数にフィクスチャ名を指定するだけで、自動的に呼び出されます。
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 を使うと、テスト終了後の後片付け処理を書けます。データベース接続や一時ファイルの削除に便利です。
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)
同じテストロジックを異なる入力値で繰り返し実行したい場合、パラメータ化テストを使います。テスト関数をコピペする必要がなくなり、テストケースの追加も簡単です。
基本的なパラメータ化
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を付けて可読性を上げる
@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)
複数のパラメータを組み合わせる
# 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
2. 境界値(0、空文字、None、最大値)を必ず含める
3.
id を付けてテスト結果を読みやすくする4. テストケースが多すぎる場合は、代表的なパターンに絞る
Step 5モックとパッチ(unittest.mock)
テスト対象のコードが外部APIやデータベースに依存している場合、モック(mock)を使って依存を差し替えます。これにより、外部に依存しない安定したテストが書けます。
モックの基本概念
| 用語 | 説明 |
|---|---|
| モック(Mock) | 本物のオブジェクトの代わりに使う偽のオブジェクト |
| パッチ(Patch) | テスト中だけ特定の関数やクラスをモックに差し替える仕組み |
| 戻り値の設定 | return_value でモックの戻り値を指定 |
| 呼び出しの検証 | assert_called_once_with() で正しく呼ばれたか確認 |
テスト対象のコード
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によるモック
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"
)
関数を丸ごとモックする
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の代わりに使えます。
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.py が import 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]
addopts = --cov=src --cov-report=term-missing --cov-fail-under=80
--cov-fail-under=80 を設定すると、カバレッジが80%未満の場合にテストが失敗扱いになります。CIに組み込むことで、カバレッジの低下を防げます。
HTMLレポートの活用
# HTMLレポートを生成
pytest --cov=src --cov-report=html tests/
# htmlcov/index.html をブラウザで開く
open htmlcov/index.html
重要なのはビジネスロジックや条件分岐のテストをしっかり書くことです。
設定ファイルの読み込みや単純なgetter/setterのテストは優先度を下げてもOKです。
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に組み込むことで、チーム全体のコード品質を保つ