pytestで外部依存を攻略!AAAパターンとモックの境界線で壊れないテストを書く
機能は動くのに、いざテストを書こうとすると手が止まってしまう。何をどこまで確認すればいいのか、外部 API や DB が絡む関数はどう扱えばいいのか。そんな疑問でテストを諦めた経験はありませんか。この記事では、ユニットテスト(単体テスト)の基本骨格である AAA パターンと、モックを使うべき境界線を整理します。Python と pytest の動くコードを使うので、手元に写して試せます。読み終えるころには、外部通信を含む関数でも迷わずテストを書き始められるはずです。
なぜテストコードを書くのか?手動テストの限界と保守性の向上
テストコードを書く最大の理由は、変更のたびに同じ確認を人間が繰り返さなくて済むからです。手動テストは、機能が 3 つのうちは回せます。しかし 30 個に増えると、毎回すべてを確認するのは現実的ではありません。結果として「前に直したところが、別の修正で壊れていた」という退行(リグレッション)に気づくのが遅れます。
テスト自動化の価値は、バグ発見だけではありません。テストがあれば、リファクタリングのときに「外から見た振る舞いは変わっていない」とすぐ確認できます。チーム開発では、他人が書いたコードを触る不安が減ります。テストは「動く仕様書」としても機能します。
ユニットテストは、関数やクラスなど小さな単位を、他の部品から切り離して検証するテストです。小さい単位を対象にするため実行が速く、失敗したときに原因の場所を絞り込みやすい性質があります。まずは「壊れると困るロジック」から書き始めるのが現実的です。getter のような自明なコードまで網羅する必要はありません。
テストの基本骨格「AAAパターン(Arrange / Act / Assert)」をマスターする
AAA パターンは、テストを 3 つのブロックに分けて書く型です。
- Arrange(準備): テスト対象に渡す入力や、必要な部品を用意する
- Act(実行): テスト対象の関数やメソッドを呼ぶ
- Assert(検証): 結果が期待どおりかを確認する
次の例は、割引計算のテストです。
# pricing.py
def apply_discount(price: int, rate: float) -> int:
if not 0 <= rate <= 1:
raise ValueError("rate must be between 0 and 1")
return int(price * (1 - rate))
# test_pricing.py
import pytest
from pricing import apply_discount
def test_apply_discount_returns_discounted_price():
# Arrange
price = 1000
rate = 0.2
# Act
result = apply_discount(price, rate)
# Assert
assert result == 800
def test_apply_discount_raises_error_for_invalid_rate():
# Arrange
price = 1000
# Act & Assert
with pytest.raises(ValueError):
apply_discount(price, 1.5)
型を決めておくと、読む人は「どこが入力で、何を確認しているか」を迷わず追えます。1 つのテストでは Act を 1 回に絞るのがコツです。Act が複数あると、失敗したときにどの操作が原因なのか分かりにくくなります。例外を検証する場合のように、ActとAssertが一体になるケースは、コメントで明示すれば問題ありません。
テスト名も大切です。「何をしたら、どうなるか」を名前から読み取れるようにすると、失敗時のレポートがそのまま仕様の説明になります。
何でも本物を動かさない!モック・スタブを使うべき3つの境界線
外部 API や DB に触れるテストで挫折する理由は、本物を動かすと遅く、不安定で、再現性がなくなるからです。そこで本物の代わりに置く偽物を、総称して テストダブル と呼びます。Martin Fowler の記事「Mocks Aren’t Stubs」でも整理されている考え方です。ここでは次の 2 つを区別しておくと十分です。
- スタブ: あらかじめ決めた値を返すだけの代役。「こう返ってきたとき、どう動くか」を試す
- モック: 呼び出された回数や引数を記録し、検証できる代役。「正しく呼ばれたか」を確認する
では、どこを置き換えるべきでしょうか。目安は次の 3 つの境界線です。
- プロセスの外へ出る通信: HTTP リクエスト、DB、ファイルシステム、メッセージキューなど。ネットワーク障害や相手の状態でテスト結果が変わってしまいます
- 非決定的な要素: 現在時刻や乱数など。実行するたびに値が変わると、同じテストが通ったり落ちたりします
- 副作用が重い処理: メール送信や決済のように、実行すると取り返しがつかない、あるいはコストが発生する処理です
逆に、自分たちが書いた純粋な計算ロジックは、基本的に本物のまま動かします。内部の関数まで置き換えると、テストが実装の写しになり、何も守れなくなるからです。
実践コード例:外部通信を含む関数をテストダブルで安全に検証する
天気 API から気温を取得し、服装のアドバイスを返す関数を題材にします。API の URL は説明用の架空のものです。
# weather.py
import requests
API_URL = "https://api.example.com/weather"
def fetch_temperature(city: str) -> float:
response = requests.get(API_URL, params={"city": city}, timeout=5)
response.raise_for_status()
return response.json()["temperature"]
def clothing_advice(city: str) -> str:
temp = fetch_temperature(city)
if temp < 10:
return "コートが必要です"
if temp < 20:
return "上着があると安心です"
return "半袖で過ごせます"
まず、アドバイスのロジックを検証します。通信部分は unittest.mock.patch で差し替えるスタブとして使い、気温を固定します。@pytest.mark.parametrize で境界値もまとめて確認します。
# test_weather.py
from unittest.mock import patch
import pytest
import requests
from weather import clothing_advice, fetch_temperature
@pytest.mark.parametrize("temp, expected", [
(9.9, "コートが必要です"),
(10.0, "上着があると安心です"),
(19.9, "上着があると安心です"),
(20.0, "半袖で過ごせます"),
])
@patch("weather.fetch_temperature")
def test_clothing_advice_by_temperature(mock_fetch, temp, expected):
# Arrange
mock_fetch.return_value = temp
# Act
result = clothing_advice("Tokyo")
# Assert
assert result == expected
patch の対象は「定義された場所」ではなく「使われている場所」の名前です。ここでは weather モジュールの中で参照されている weather.fetch_temperature を指定します。この点は初心者がよくつまずくポイントで、Python 公式ドキュメントの unittest.mock の項でも「Where to patch」として説明されています。
次に、通信を行う関数自体を検証します。ここでは requests.get を差し替え、正常系とエラー系の両方を確認します。
@patch("weather.requests.get")
def test_fetch_temperature_returns_value(mock_get):
# Arrange
mock_get.return_value.json.return_value = {"temperature": 18.5}
# Act
result = fetch_temperature("Tokyo")
# Assert
assert result == 18.5
mock_get.assert_called_once()
@patch("weather.requests.get")
def test_fetch_temperature_propagates_http_error(mock_get):
# Arrange
mock_get.return_value.raise_for_status.side_effect = requests.HTTPError("500")
# Act & Assert
with pytest.raises(requests.HTTPError):
fetch_temperature("Tokyo")
これで実際にネットワークへ出ることなく、ミリ秒単位で結果が得られます。API が落ちていても、テストの結果は変わりません。ただし、偽物は本物の仕様とずれる可能性があります。API の実際の応答形式が変わっても、このテストは通り続けます。そのため、本物の接続を確認する結合テストを少数、別枠で用意して補うのが一般的です。
テストが壊れやすくなるアンチパターンと保守しやすいテストの書き方
保守しやすいテストとは、「振る舞いが変わらない限り、実装を直しても壊れないテスト」です。逆に、壊れやすいテストにはいくつか典型的な型があります。
- モックだらけのテスト: 依存を片っ端から置き換えると、検証しているのはモックの設定だけになります。置き換えるのは先ほどの 3 つの境界線に絞ります
- 実装詳細への依存: 「内部関数が 3 回呼ばれた」といった検証は、リファクタリングのたびに壊れます。入力に対する出力や、外部への影響という「結果」を確認します
- 1 テストに複数の関心事: 1 つのテストで複数の振る舞いを確認すると、失敗の原因が見えにくくなります。1 テスト 1 振る舞いを基本にします
- テスト同士の依存: 実行順序や共有データに頼ると、一部だけ実行したときに落ちます。各テストが独立して動くようにします
- 時刻や乱数への直接依存: 現在時刻を使う関数は、時刻を引数で受け取る設計にすると、モックなしでテストできます
最後の点は設計の話でもあります。テストが書きにくいコードは、依存が内部に埋め込まれていることが多いからです。たとえば fetch_temperature を引数として渡せる形にすれば、patch を使わずに偽の関数を渡すだけでテストできます。「テストしやすさ」を設計の指標にすると、結果として変更に強いコードになります。
最初から完璧を目指す必要はありません。まずは重要なロジックを 1 つ選び、AAA で 1 本書いてみてください。外部依存が出てきたら、境界線の 3 条件に当てはまるかを確認して、必要なものだけを置き換えます。この繰り返しが、無理なく続くテスト自動化への近道です。


