shibomb

pytestで外部依存を攻略!AAAパターンとモックの境界線で壊れないテストを書く

機能は動くのに、いざテストを書こうとすると手が止まってしまう。何をどこまで確認すればいいのか、外部 API や DB が絡む関数はどう扱えばいいのか。そんな疑問でテストを諦めた経験はありませんか。この記事では、ユニットテスト(単体テスト)の基本骨格である AAA パターンと、モックを使うべき境界線を整理します。Python と pytest の動くコードを使うので、手元に写して試せます。読み終えるころには、外部通信を含む関数でも迷わずテストを書き始められるはずです。

なぜテストコードを書くのか?手動テストの限界と保守性の向上

テストコードを書く最大の理由は、変更のたびに同じ確認を人間が繰り返さなくて済むからです。手動テストは、機能が 3 つのうちは回せます。しかし 30 個に増えると、毎回すべてを確認するのは現実的ではありません。結果として「前に直したところが、別の修正で壊れていた」という退行(リグレッション)に気づくのが遅れます。

テスト自動化の価値は、バグ発見だけではありません。テストがあれば、リファクタリングのときに「外から見た振る舞いは変わっていない」とすぐ確認できます。チーム開発では、他人が書いたコードを触る不安が減ります。テストは「動く仕様書」としても機能します。

ユニットテストは、関数やクラスなど小さな単位を、他の部品から切り離して検証するテストです。小さい単位を対象にするため実行が速く、失敗したときに原因の場所を絞り込みやすい性質があります。まずは「壊れると困るロジック」から書き始めるのが現実的です。getter のような自明なコードまで網羅する必要はありません。

テストの基本骨格「AAAパターン(Arrange / Act / Assert)」をマスターする

AAA パターンは、テストを 3 つのブロックに分けて書く型です。

  1. Arrange(準備): テスト対象に渡す入力や、必要な部品を用意する
  2. Act(実行): テスト対象の関数やメソッドを呼ぶ
  3. 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 つの境界線です。

  1. プロセスの外へ出る通信: HTTP リクエスト、DB、ファイルシステム、メッセージキューなど。ネットワーク障害や相手の状態でテスト結果が変わってしまいます
  2. 非決定的な要素: 現在時刻や乱数など。実行するたびに値が変わると、同じテストが通ったり落ちたりします
  3. 副作用が重い処理: メール送信や決済のように、実行すると取り返しがつかない、あるいはコストが発生する処理です

逆に、自分たちが書いた純粋な計算ロジックは、基本的に本物のまま動かします。内部の関数まで置き換えると、テストが実装の写しになり、何も守れなくなるからです。

実践コード例:外部通信を含む関数をテストダブルで安全に検証する

天気 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 条件に当てはまるかを確認して、必要なものだけを置き換えます。この繰り返しが、無理なく続くテスト自動化への近道です。

関連記事