unittest.mockをマスターする:APIの停止でユニットテストを失敗させないために

Python tutorial - IT technology blog
Python tutorial - IT technology blog

午前2時の電話とユニットテストの教訓

3年前のシステム当番の夜のことを今でも鮮明に覚えています。午前2時ちょうど、CI/CDパイプラインが真っ赤になり、Slackの通知が鳴り止みませんでした。チーム全員で確認しましたが、ロジック自体は完全に正常でした。原因は極めて稀なものでした。連携先のAPIが定期メンテナンス中だったのです。またある時は、共有データベースサーバーで誰かが誤って「drop table」を実行したためにテストが失敗したこともありました。

その時、私は残酷な事実に気づきました。ユニットテストを実行するのにインターネット接続が必要なら、それは知らぬ間にインテグレーションテスト(結合テスト)を行っているということです。真のユニットテストは、外部要因からロジックを完全に隔離しなければなりません。この依存関係を根本的に解決するために、unittest.mockはPythonエンジニアにとって必須のスキルです。

なぜテストで本物の接続を使うのをやめるべきなのか?

API呼び出しやデータベース操作を含むコードを処理する際、よくある3つのアプローチを見てみましょう。

  • 本物の接続(Real Connection): テスト用データベースを構築したり、実際のAPIを呼び出したりします。正確ですが、非常に低速です。ネットワークの不安定さもあり、テストスイートの実行に30秒ではなく15分かかることもあります。このような場合は、Pythonプロファイリングでボトルネックを特定する前に、まずテストの設計を見直すべきです。
  • 手動でのフェイクオブジェクト作成: モッククラスを自分で作成します。メンテナンスに手間がかかり、テストファイルが急速に肥大化します。
  • unittest.mockの使用: 依存関係を「偽物」のオブジェクトに置き換えます。わずか2行のコードで、任意の値を返させたり、サーバーエラーをシミュレートしたりできます。

unittest.mockの最大の利点は、Python 3.3以降、標準ライブラリに含まれていることです。外部ライブラリをインストールする必要はありません。非常に柔軟で、テスト実行中に関数やクラス、あるいはオブジェクトの属性を「入れ替える」ことができます。

テストを高速化するためのAPIモック化手法

例えば、Python requestsを使ってAPIから金価格を取得する関数があるとします。そのサイトがダウンすると、テストも連動して失敗してしまいます。これを隔離する方法は以下の通りです。

import requests

def get_gold_price(api_url):
    response = requests.get(api_url)
    if response.status_code == 200:
        return response.json()["price"]
    return None

実際のrequests.getを呼び出す代わりに、デコレータ@patchを使用して戻り値を制御します。

import unittest
from unittest.mock import patch

class TestGoldAPI(unittest.TestCase):
    @patch('requests.get')
    def test_get_gold_price_success(self, mock_get):
        # サーバーからのレスポンスをシミュレート
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {"price": 2000}

        result = get_gold_price("http://fake-api.com")
        
        self.assertEqual(result, 2000)
        # 正しい引数で関数が呼び出されたことを確認
        mock_get.assert_called_once_with("http://fake-api.com")

データベースのモック化:待ち時間を大幅に短縮

ユニットテストにおけるデータベース操作は、パフォーマンス面で「悪夢」になりがちです。データベースがSQL文を実行するのに数百ミリ秒待つ代わりに、Mockを使えば1〜2ミリ秒で結果を受け取れます。以下は、SQLAlchemyでデータベースセッションをモック化する例です。

from unittest.mock import MagicMock

def test_delete_user_exists():
    # モックセッションの作成
    mock_session = MagicMock()
    
    # query.filter_by.first のメソッドチェーンをシミュレート
    mock_user = MagicMock()
    mock_session.query.return_value.filter_by.return_value.first.return_value = mock_user

    result = delete_user(mock_session, 123)

    assert result is True
    mock_session.delete.assert_called_once_with(mock_user)
    mock_session.commit.assert_called_once()

実際、データベースに保存する前に入力データを処理する際、正規表現(Regex)のエラーによく遭遇します。テストコードのデバッグ時間を節約するために、私はよく正規表現チェッカーを使って、Pythonに組み込む前にパターンをテストしています。これにより、スラッシュ一つの間違いでユニットテストを何度もやり直す手間を大幅に減らせます。

side_effectによるエラー状況のハンドリング

コードは常にスムーズに動くとは限りません。高品質なテストスイートには、APIのタイムアウトやデータベースの接続解除が発生した際にアプリケーションがどう振る舞うかを検証する必要があります。エラー発生時の挙動をloggingモジュールで適切に記録できているかも重要な確認ポイントです。side_effect属性を使用すると、例外(Exception)を発生させてエラー処理ロジックをテストできます。

@patch('requests.get')
def test_get_gold_price_timeout(self, mock_get):
    # requests.get が Timeout エラーを発生させるように設定
    mock_get.side_effect = requests.exceptions.Timeout

    with self.assertRaises(requests.exceptions.Timeout):
        get_gold_price("http://fake-api.com")

モックを使用する際の3つの「鉄則」

長年、大規模システムに携わってきた中で、モックがテストロジックを台無しにしないための3つの重要な注意点を導き出しました。

  1. 適切な場所をモックする: オブジェクトが定義されている場所ではなく、使用されている場所をパッチしてください。例えば app.pyrequests をインポートしているなら、'app.requests.get' をパッチする必要があります。
  2. 乱用しない: 1つの関数をテストするために5つ以上のオブジェクトをモックしなければならない場合、それはコードの結合度が高すぎる(High Coupling)兆候です。ロジックのリファクタリングを検討してください。
  3. MagicMockを優先する: これは Mock のアップグレード版で、__iter____len__ などのメソッドを標準でサポートしており、リストや複雑なオブジェクトをよりスムーズにシミュレートできます。

モック技術をマスターすることは、CI/CDを常に「緑色」に保つためだけではありません。たとえFlaskで構築した連携先のAPIが未完成であっても、自信を持ってコードを書くことができるようになります。外部システムの不具合に怯えることなく、効率的な開発を楽しんでください!

Share: