- 技術 💻
pytest 使い方|導入からfixture・mock・カバレッジまで実行結果つき
pytestとは、Pythonのテストを assert 文だけで書ける、事実上の標準のテストフレームワークです。 test_ で始まるファイルと関数を置いて pytest を実行するだけで、テストを自動で見つけて実行し、失敗した箇所の値の違いまで表示してくれます。
この記事は、導入 → assert → 例外 → fixture → parametrize → mark → 組み込みfixture → mock → カバレッジの順に、1つの小さな注文アプリを育てながら進めます。各ステップに pytest 9.1.1(Python 3.14.7)の実際の出力を載せ、CLIオプション早見表・pyproject.toml の設定例・よくあるエラーの再現と直し方までまとめました。テストの考え方(単体と結合の違いなど)は別記事に譲り、ここでは使い方の網羅に絞ります。
pytest 使い方の早見表
| やりたいこと | 書き方・コマンド |
|---|---|
| 導入(uv) | uv add --dev pytest → uv run pytest |
| 導入(pip) | pip install pytest → pytest |
| 値を確かめる | assert 実際 == 期待 |
| 例外を確かめる | with pytest.raises(ValueError, match="正規表現"): |
| 準備・後片付けを共通化 | @pytest.fixture + yield |
| 複数ファイルで共有 | tests/conftest.py に fixture を書く |
| 同じテストを値を変えて回す | @pytest.mark.parametrize("a,b", [...], ids=[...]) |
| スキップ/条件付き/既知の失敗 | @pytest.mark.skip / skipif / xfail |
| 一時ディレクトリ | 引数に tmp_path |
| 環境変数・属性の差し替え | 引数に monkeypatch |
| print・ログの確認 | 引数に capsys / caplog |
| 外部呼び出しの差し替え | unittest.mock.patch / mocker.patch(pytest-mock) |
| カバレッジ | pytest --cov=パッケージ --cov-report=term-missing |
| 名前で絞る/失敗だけ再実行 | -k "式" / --lf |
| 並列実行 | -n auto(pytest-xdist) |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「pytest 使い方|導入からfixture・mock・カバレッジまで実行結果つき」
1. 導入とディレクトリ構成
uv なら、プロジェクトを作って開発用の依存に pytest を足すだけです。 この記事では検証のため pytest-mock・pytest-cov・pytest-xdist も入れています。
uv init --lib shop
cd shop
uv add --dev pytest pytest-mock pytest-cov pytest-xdist
mkdir tests
構成は次のとおりです(src レイアウト)。uv init --lib はプロジェクト自身を仮想環境にインストールするため、テストから from shop.cart import ... でそのまま import できます。
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── cart.py # テスト対象
│ └── stock.py # 外部APIを呼ぶ部分
└── tests/
├── conftest.py # 共有fixture
└── test_cart.py # test_*.py が自動で集められる
テスト対象の src/shop/cart.py です。
import json
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
TAX_RATE = 0.10
def calc_total(items: list[dict]) -> int:
"""税込み合計(円未満切り捨て)"""
subtotal = 0
for item in items:
if item["qty"] <= 0:
raise ValueError(f"数量は1以上にしてください: {item['name']}")
subtotal += item["price"] * item["qty"]
return int(subtotal * (1 + TAX_RATE))
def free_shipping(total: int) -> bool:
"""送料無料ライン(環境変数で変更可)"""
line = int(os.environ.get("FREE_SHIPPING_LINE", "5000"))
return total >= line
def save_order(order: dict, dir: Path) -> Path:
path = dir / f"{order['id']}.json"
path.write_text(json.dumps(order, ensure_ascii=False), encoding="utf-8")
logger.info("注文を保存しました: %s", order["id"])
print(f"saved {path.name}")
return path
pytest が自動で集めるのは、test_*.py(または *_test.py)の中の test で始まる関数と、Test で始まるクラスの test メソッドです。
2. assert で値を確かめる
# tests/test_cart.py
from shop.cart import calc_total
def test_calc_total():
items = [{"name": "ペン", "price": 100, "qty": 3}]
assert calc_total(items) == 330
uv run pytest -v の出力(パスは一部省略):
platform linux -- Python 3.14.7, pytest-9.1.1, pluggy-1.6.0 -- …/shop/.venv/bin/python
rootdir: …/shop
configfile: pyproject.toml
plugins: xdist-3.8.0, mock-3.16.0, cov-7.1.0
collecting ... collected 1 item
tests/test_cart.py::test_calc_total PASSED [100%]
失敗すると、pytest は assert の左右の値を分解して違いを表示します。 dict を比べて失敗させた例です。
def test_dict():
expected = {"id": "A-001", "qty": 3, "status": "new", "tags": ["急ぎ"]}
actual = {"id": "A-001", "qty": 2, "status": "new", "tags": ["急ぎ", "ギフト"]}
assert actual == expected
> assert actual == expected
E AssertionError: assert {'id': 'A-001...['急ぎ', 'ギフト']} == {'id': 'A-001...tags': ['急ぎ']}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'qty': 2} != {'qty': 3}
E {'tags': ['急ぎ', 'ギフト']} != {'tags': ['急ぎ']}
E Use -v to get more diff
-vv を付けると全体の差分が行単位で出ます(抜粋)。
E Full diff:
E {
E 'id': 'A-001',
E - 'qty': 3,
E ? ^
E + 'qty': 2,
E ? ^
E 'status': 'new',
E 'tags': [
E '急ぎ',
E + 'ギフト',
E ],
E }
- が期待値、+ が実際の値です。小数の比較には assert 0.1 + 0.2 == pytest.approx(0.3) を使います。
3. 例外を確かめる(pytest.raises と match)
pytest.raises のブロック内で例外が出れば成功、出なければ失敗です。 match にはメッセージに対する正規表現を書きます(re.search で判定)。
import pytest
from shop.cart import calc_total
def test_qty_zero_raises():
with pytest.raises(ValueError, match="数量は1以上"):
calc_total([{"name": "ノート", "price": 200, "qty": 0}])
期待どおりにならなかったときの表示も確認しました。
| 状況 | 出力 |
|---|---|
| メッセージが違う | AssertionError: Regex pattern did not match. / Expected regex: '在庫' / Actual message: '数量は1以上にしてください: ノート' |
| 例外が出なかった | Failed: DID NOT RAISE ValueError |
match を付けておくと、別の理由で出た同じ型の例外を成功と見なすことを防げます。 例外オブジェクトを調べたいときは with pytest.raises(ValueError) as exc: として exc.value を使います。
4. fixture で準備と後片付けを共通化する
fixture は、テストに必要なデータや接続を用意する関数です。 テスト関数の引数に fixture 名を書くと、pytest が実行して結果を渡します。複数ファイルで使うものは tests/conftest.py に置くと、import なしで使えます。
# tests/conftest.py
import pytest
@pytest.fixture
def cart_items():
"""テストごとに新しいカートを作る(既定の scope="function")"""
return [{"name": "ペン", "price": 100, "qty": 3},
{"name": "ノート", "price": 200, "qty": 1}]
@pytest.fixture(scope="session")
def db():
"""テスト全体で1回だけ準備し、最後に後片付けする"""
conn = {"orders": []} # 本物ならDB接続を作る
print("\n[db] 接続")
yield conn # ここでテストが実行される
print("\n[db] 切断") # yield の後ろが後処理
@pytest.fixture(autouse=True)
def fixed_env(monkeypatch):
"""全テストで自動的に使われる(引数に書かなくてよい)"""
monkeypatch.setenv("FREE_SHIPPING_LINE", "5000")
# tests/test_fixture.py
from shop.cart import calc_total
def test_total(cart_items):
assert calc_total(cart_items) == 550
def test_save(db, cart_items):
db["orders"].append(cart_items)
assert len(db["orders"]) == 1
def test_db_shared(db):
# scope="session" なので前のテストの追加が残っている
assert len(db["orders"]) == 1
uv run pytest tests/test_fixture.py -v -s(-s は print を表示):
tests/test_fixture.py::test_total PASSED
tests/test_fixture.py::test_save
[db] 接続
PASSED
tests/test_fixture.py::test_db_shared PASSED
[db] 切断
--setup-show を付けると、fixture の準備(SETUP)と後片付け(TEARDOWN)の順番が見えます(抜粋。F は function、S は session)。
SETUP S db
SETUP F monkeypatch
SETUP F fixed_env (fixtures used: monkeypatch)
SETUP F cart_items
tests/test_fixture.py::test_save (fixtures used: cart_items, db, fixed_env, monkeypatch) .
TEARDOWN F cart_items
TEARDOWN F fixed_env
TEARDOWN F monkeypatch
...
TEARDOWN S db
| scope | 作られる単位 | 向いているもの |
|---|---|---|
function(既定) |
テスト関数ごと | 書き換えるデータ(テスト同士が影響しない) |
class |
テストクラスごと | クラス内で共有する準備 |
module |
ファイルごと | ファイル単位のサンプルデータ |
package |
パッケージごと | |
session |
実行全体で1回 | DB接続、コンテナ起動など重い準備 |
scope を広げるほど速くなりますが、テスト間で状態が共有されます(上の test_db_shared のように前のテストの変更が残る)。書き換えるデータは function のままにするのが安全です。
5. parametrize で値を変えて同じテストを回す
@pytest.mark.parametrize(
("price", "qty", "expected"),
[
(100, 1, 110),
(100, 3, 330),
(99, 1, 108), # 108.9 → 切り捨て
],
ids=["one", "three", "round-down"],
)
def test_calc_total_param(price, qty, expected):
assert calc_total([{"name": "x", "price": price, "qty": qty}]) == expected
uv run pytest -v tests/test_cart.py:
tests/test_cart.py::test_calc_total PASSED [ 20%]
tests/test_cart.py::test_qty_zero_raises PASSED [ 40%]
tests/test_cart.py::test_calc_total_param[one] PASSED [ 60%]
tests/test_cart.py::test_calc_total_param[three] PASSED [ 80%]
tests/test_cart.py::test_calc_total_param[round-down] PASSED [100%]
ids を付けると、どのケースが失敗したか一目でわかり、-k round-down のように個別に実行できます。 ids に日本語を使うと、表示が英数字のエスケープ表記になるため、英数字にしておくと読みやすくなります。1ケースだけ印を付けたいときは pytest.param(99, 1, 108, marks=pytest.mark.xfail) と書けます。
6. mark でスキップ・既知の失敗・独自の分類をする
import sys
import pytest
@pytest.mark.skip(reason="仕様確定待ち")
def test_coupon():
...
@pytest.mark.skipif(sys.platform == "win32", reason="Windowsでは実行しない")
def test_unix_path():
assert "/" in "a/b"
@pytest.mark.xfail(reason="float の丸め誤差。Decimal へ移行予定")
def test_known_bug():
assert round(2.675, 2) == 2.68
@pytest.mark.slow
def test_heavy():
assert sum(range(10_000)) == 49_995_000
独自の mark(ここでは slow)は設定ファイルに登録します。 登録しないと PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? という警告が出ます。登録後の uv run pytest tests/test_mark.py -v:
tests/test_mark.py::test_coupon SKIPPED (仕様確定待ち) [ 25%]
tests/test_mark.py::test_unix_path PASSED [ 50%]
tests/test_mark.py::test_known_bug XFAIL (float の丸め誤差。Decimal ...) [ 75%]
tests/test_mark.py::test_heavy PASSED [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_mark.py:6: 仕様確定待ち
XFAIL tests/test_mark.py::test_known_bug - float の丸め誤差。Decimal へ移行予定
=================== 2 passed, 1 skipped, 1 xfailed in 0.06s ===================
uv run pytest -m "not slow" -q で slow を除いて実行すると 9 passed, 1 skipped, 1 deselected, 1 xfailed となり、1件が deselected(除外)されました。
| mark | 意味 | 結果の表示 |
|---|---|---|
skip |
常に実行しない | SKIPPED / s |
skipif(条件) |
条件が真なら実行しない | SKIPPED / s |
xfail |
失敗が想定内。失敗しても全体は成功扱い | XFAIL / x(通ると XPASS) |
| 独自mark | -m で選んだり除いたりする |
-m "slow" / -m "not slow" |
7. pyproject.toml の設定例
pytest 9.0 から [tool.pytest] でTOMLの型(リストなど)のまま書けます。 上のmark登録もここで行いました。
[tool.pytest]
minversion = "9.0"
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
"slow: 時間のかかるテスト(-m 'not slow' で除外)",
]
pytest 8 以前も使うプロジェクトでは、従来の [tool.pytest.ini_options](値は文字列)で書きます。
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
pythonpath = ["src"]
markers = ["slow: 時間のかかるテスト"]
| 設定 | 役割 |
|---|---|
testpaths |
引数なしの pytest で探す場所 |
addopts |
毎回付けるオプション(-ra は skip・xfail などの理由を最後にまとめて表示) |
--strict-markers |
未登録の mark をエラーにする(@pytest.mark.slwo のような打ち間違いを検出) |
pythonpath |
import の探索先に追加(エラー①の対処) |
markers |
独自 mark の登録 |
--strict-markers の状態で @pytest.mark.slwo と打ち間違えると、'slwo' not found in `markers` configuration option で収集が止まりました。9.0 では strict = true で関連する厳格モードをまとめて有効にできます。
8. 組み込みfixture(tmp_path・monkeypatch・capsys・caplog)
インストール不要で、引数に書くだけで使える fixture です。
# tests/test_builtin.py
import json
import logging
from shop.cart import free_shipping, save_order
def test_save_order(tmp_path, capsys, caplog):
caplog.set_level(logging.INFO)
path = save_order({"id": "A-001", "item": "ペン"}, tmp_path)
# tmp_path: テストごとの一時ディレクトリ(自動で用意される)
assert json.loads(path.read_text(encoding="utf-8"))["item"] == "ペン"
# capsys: print の出力を取り出す
assert capsys.readouterr().out == "saved A-001.json\n"
# caplog: ログを取り出す
assert "注文を保存しました: A-001" in caplog.text
def test_free_shipping_line(monkeypatch):
# monkeypatch: 環境変数・属性をテストの間だけ差し替える(終われば元に戻る)
monkeypatch.setenv("FREE_SHIPPING_LINE", "3000")
assert free_shipping(3000) is True
assert free_shipping(2999) is False
tests/test_builtin.py::test_save_order PASSED [ 50%]
tests/test_builtin.py::test_free_shipping_line PASSED [100%]
| fixture | 用途 | よく使うメソッド |
|---|---|---|
tmp_path |
テストごとの一時ディレクトリ(pathlib.Path) |
tmp_path / "a.json" |
monkeypatch |
環境変数・属性・辞書の一時的な差し替え | setenv delenv setattr chdir |
capsys |
標準出力・標準エラーの取得 | readouterr().out / .err |
caplog |
ログの取得 | set_level text records |
使える fixture の一覧は pytest --fixtures で表示できます。
9. モック(unittest.mock と pytest-mock)
外部APIやDBを呼ぶ関数は、呼び出し先を差し替えてテストします。 対象の src/shop/stock.py です。
import json
import urllib.request
def fetch_stock(item_id: str) -> int:
"""在庫APIに問い合わせる(テストでは本物を呼ばない)"""
with urllib.request.urlopen(f"https://stock.example.com/items/{item_id}") as r:
return json.load(r)["stock"]
def can_order(item_id: str, qty: int) -> bool:
return fetch_stock(item_id) >= qty
# tests/test_stock.py
from unittest.mock import patch
import pytest
from shop import stock
# 1) 標準ライブラリ unittest.mock の patch
def test_can_order_with_patch():
with patch("shop.stock.fetch_stock", return_value=5) as m:
assert stock.can_order("P-1", 3) is True
m.assert_called_once_with("P-1")
# 2) pytest-mock の mocker(with が不要で、テスト終了時に自動で元に戻る)
def test_can_order_with_mocker(mocker):
m = mocker.patch("shop.stock.fetch_stock", return_value=2)
assert stock.can_order("P-1", 3) is False
m.assert_called_once_with("P-1")
# 3) 例外を起こさせる
def test_api_error(mocker):
mocker.patch("shop.stock.fetch_stock", side_effect=TimeoutError("timeout"))
with pytest.raises(TimeoutError):
stock.can_order("P-1", 1)
tests/test_stock.py::test_can_order_with_patch PASSED [ 33%]
tests/test_stock.py::test_can_order_with_mocker PASSED [ 66%]
tests/test_stock.py::test_api_error PASSED [100%]
patch する場所は「定義した場所」ではなく「使われている場所」です。 別モジュールで from shop.stock import fetch_stock と取り込んでいる shop/order.py をテストしたとき、mocker.patch("shop.stock.fetch_stock", ...) では差し替わらず本物の通信が走り、urllib.error.URLError で失敗しました(検証環境では <urlopen error Tunnel connection failed: 403 Forbidden>)。mocker.patch("shop.order.fetch_stock", ...) に直すと成功します。
10. カバレッジを測る(pytest-cov)
uv run pytest --cov=shop --cov-report=term-missing --cov-fail-under=90 -q
Name Stmts Miss Cover Missing
----------------------------------------------------
src/shop/__init__.py 0 0 100%
src/shop/cart.py 22 0 100%
src/shop/stock.py 7 2 71% 7-8
----------------------------------------------------
TOTAL 29 2 93%
Required test coverage of 90% reached. Total coverage: 93.10%
Missing の 7-8 は、モックで置き換えて一度も実行されていない fetch_stock の本体です。--cov-fail-under を下回ると終了コードが0以外になるため、CIで基準を守れます。HTMLで見るなら --cov-report=html(htmlcov/index.html)です。数値は目安とし、Missing の行が大事な分岐かどうかを見るのが実用的です。
11. CLIオプション早見表
| オプション | 意味 | 例 |
|---|---|---|
-v / -vv |
テスト名ごとに表示/差分を省略せず表示 | pytest -vv |
-q |
簡潔に表示 | pytest -q |
-k 式 |
名前で絞り込み(and or not) |
-k "calc_total and not param" |
-m 式 |
mark で絞り込み | -m "not slow" |
-x |
最初の失敗で止める | pytest -x |
--maxfail=N |
N件失敗したら止める | --maxfail=3 |
--lf |
前回失敗したテストだけ実行 | pytest --lf |
--ff |
前回失敗したテストを先に実行 | pytest --ff |
-s |
print を表示(出力の取り込みを止める) | pytest -s |
-ra |
skip・xfail・失敗の理由を最後にまとめる | pytest -ra |
--durations=N |
遅いテスト上位N件を表示 | --durations=10 |
-n auto |
CPU数で並列実行(pytest-xdist) | pytest -n auto |
--setup-show |
fixture の準備・後片付けを表示 | |
--collect-only |
実行せず、集めたテストだけ表示 | |
ファイル::関数 |
1つだけ実行 | pytest tests/test_cart.py::test_calc_total |
-k "calc_total and not param" は 1 passed, 16 deselected でした。わざと1件失敗させた後、--lf で実行すると失敗した1件だけが動き(1 failed)、直した後の --lf は全件実行に戻りました。
12. よくあるエラーと直し方(再現)
① ModuleNotFoundError: No module named 'calc'(import できない)
src/calc.py と tests/test_calc.py を置き、プロジェクトをインストールせずに実行した場合です。
ImportError while importing test module '…/flat/tests/test_calc.py'.
Hint: make sure your test modules/packages have valid Python names.
...
E ModuleNotFoundError: No module named 'calc'
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
pytest は src を import の探索先に自動では入れません。 設定に pythonpath = ["src"] を追加すると 1 passed になりました。uv init --lib のようにプロジェクト自身をインストールする方法でも解決します。
② fixture 'order' not found
E fixture 'order' not found
> available fixtures: cache, capfd, capfdbinary, caplog, capsys, ... tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.
原因は、fixture 名の打ち間違い、または conftest.py の置き場所です。 conftest.py はそのディレクトリ以下にしか効かないため、tests/ 直下など、使うテストより上の階層に置きます。
③ テストが収集されない
| 状況 | 結果 | 直し方 |
|---|---|---|
ファイル名が calc_tests.py |
no tests ran(終了コード5) |
test_calc.py にする(ファイルを直接指定すると実行はされる) |
関数名が check_sub |
集められない | test_ で始める |
TestCalc に __init__ がある |
PytestCollectionWarning: cannot collect test class 'TestCalc' because it has a __init__ constructor |
__init__ を消し、準備は fixture で行う |
全体の終了コードは、成功0・失敗1・中断2・収集0件5 です。CIで no tests ran が成功扱いにならないよう、この値を知っておくと安心です。
よくある質問
Q. unittest で書いたテストも pytest で実行できますか?
A. できます。unittest.TestCase を継承したテストもそのまま集めて実行するため、新しいテストから pytest の書き方に移していけます。
Q. conftest.py と fixture を書いたファイルを import するのは何が違いますか? A. conftest.py の fixture は import なしで、そのディレクトリ以下の全テストから使えます。共有したい fixture は conftest.py に置くのが pytest の標準的なやり方です。
Q. [tool.pytest] と [tool.pytest.ini_options] はどちらを使えばよいですか?
A. pytest 9.0 以上だけで使うなら [tool.pytest] がTOMLの型のまま書けて便利です。古い pytest でも読ませたい場合は [tool.pytest.ini_options] を使います。
まとめ
uv add --dev pytest→uv run pytestで始められ、test_*.pyのtest_関数が自動で集まるassertだけで書け、失敗時は値の違いを表示。全体の差分は-vv- 例外は
pytest.raises(..., match=...)、準備と後片付けはyieldを使った fixture と conftest.py - parametrize は
idsを付けて失敗ケースをすぐ特定、mark は--strict-markersと登録で打ち間違いを防ぐ - tmp_path・monkeypatch・capsys・caplog は引数に書くだけで使える
- mock は「使われている場所」を patch、カバレッジは
--cov-report=term-missingで未実行行を見る - import できない時は
pythonpathかパッケージのインストール、収集されない時は命名規則を確認
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「pytest 使い方|導入からfixture・mock・カバレッジまで実行結果つき」
株式会社RJCでは、テストを書きやすい設計やツールの使い方を、日々の開発の中で仲間と共有しています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。