はじめに

Amazon S3などのクラウドサービスを使用するアプリケーションをテストする場合、
実際のクラウドリソースを使わずにローカル環境でテストを行いたいことがあります。
そのような場合、pytestpytest-mockを使用して、外部サービスの呼び出しをモック化することができます。

今回はpytestとmockを使用してAmazon S3のput_object操作をローカル環境でテストする方法について説明します。

モックとは

簡単に言えば、「本物のコンポーネントの代わりに、テスト用の偽物を使って動作を確認する」ことです。

モックの基本的な動作

まず、pytestとmockの基本的な使い方を見ていきます。

# モックの基本的な例

def test_mock_example(mocker):
    # 関数やメソッドをモック化
    mock_function = mocker.patch("module.function_name")

    # モック化した関数の戻り値を設定
    mock_function.return_value = "mocked_result"

    # モック化した関数の呼び出し
    result = mock_function()

    # アサーション
    assert result == "mocked_result"
    # 関数が呼び出されたことを確認
    mock_function.assert_called_once()

このコードを詳しく説明すると:
1.mockerは pytest-mock プラグインが提供するフィクスチャです。
Pythonの標準ライブラリである unittest.mock のラッパーとして機能し、
テスト内でモックオブジェクトを簡単に作成できるようにします。

2.mocker.patch("module.function_name")の引数は、モック化したい関数やクラスの
「インポートパス」を文字列で指定します。
引数の例:
"os.path.exists" – osモジュールのpath.exists関数をモック
"myapp.models.User" – myappパッケージのmodelsモジュールにあるUserクラスをモック

mocker.patchは指定されたパスのオブジェクトを一時的に置き換え、テスト終了後に自動的に元に戻します。

3.mock_function.return_value = "mocked_result" では、モック化した関数が呼び出されたときに
返す値を設定しています。

4.テスト内では assert を使って、モック関数の挙動や呼び出し履歴を検証しています。

Amazon S3のput_object操作をモック化してテストする

それでは、本題のAmazon S3のput_object操作をモック化してテストするコードを見ていきます。

テスト対象のコード

まず、テスト対象となるS3にファイルをアップロードする関数を定義します。

# s3_upload.py
import boto3
from botocore.exceptions import ClientError


def upload_file_to_s3(file_path, bucket_name, object_name=None):
    """
    S3バケットにファイルをアップロードする関数

    :param file_path: アップロードするファイルのパス
    :param bucket_name: アップロード先のS3バケット名
    :param object_name: S3オブジェクト名(指定しない場合はファイル名が使用される)
    :return: アップロードが成功したかどうかのブール値
    """
    # オブジェクト名が指定されていない場合は、ファイル名を使用
    if object_name is None:
        object_name = file_path.split("/")[-1]

    # S3クライアントを作成
    s3_client = boto3.client("s3")

    try:
        # ファイルをS3にアップロード
        s3_client.put_object(
            Body=open(file_path, "rb"), Bucket=bucket_name, Key=object_name
        )
        return True
    except ClientError as e:
        print(f"エラーが発生しました: {e}")
        return False

テストコード

次に、この関数をテストするコードを作成します。S3へのアクセスをモック化するため
boto3のクライアントをモック化します。簡易的に成功と失敗をテストするケースだけ用意しました。

# test_s3_upload.py
import os
from unittest.mock import mock_open
import pytest
from s3_upload import upload_file_to_s3


def test_upload_file_to_s3_success(mocker):
    """S3アップロードが成功するケースのテスト"""
    # ファイルのモック
    mock_file = mock_open(read_data=b"test file content")
    mocker.patch("builtins.open", mock_file)

    # S3クライアントのモック
    mock_s3_client = mocker.Mock()
    mocker.patch("boto3.client", return_value=mock_s3_client)

    # テスト実行
    result = upload_file_to_s3("dummy_file.txt", "test-bucket", "test-key.txt")

    # アサーション
    assert result is True
    # S3クライアントが正しいパラメータで呼び出されたことを確認
    mock_s3_client.put_object.assert_called_once()
    _, kwargs = mock_s3_client.put_object.call_args
    assert kwargs["Bucket"] == "test-bucket"
    assert kwargs["Key"] == "test-key.txt"
    # Body内容の検証を追加
    assert kwargs["Body"].read() == b"test file content"
    # ファイルが開かれたことを確認
    mock_file.assert_called_once_with("dummy_file.txt", "rb")


def test_upload_file_to_s3_fail(mocker):
    """S3アップロードが失敗するケースのテスト"""
    # ファイルのモック
    mock_file = mock_open(read_data=b"test file content")
    mocker.patch("builtins.open", mock_file)

    # boto3.clientのモック
    mock_s3_client = mocker.Mock()
    # put_objectメソッドが例外を投げるように設定
    from botocore.exceptions import ClientError

    mock_s3_client.put_object.side_effect = ClientError(
        {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}}, "put_object"
    )
    mocker.patch("boto3.client", return_value=mock_s3_client)

    # プリントのモック(エラーメッセージの出力をキャプチャしないため)
    mocker.patch("builtins.print")

    # テスト実行
    result = upload_file_to_s3("dummy_file.txt", "test-bucket")

    # アサーション
    assert result is False
    # put_objectが呼び出されたことを確認
    mock_s3_client.put_object.assert_called_once()

上記コードのポイント

1.ファイルのモック化:
mock_openmocker.patch("builtins.open", mock_file)を使用して、ファイルのオープン操作を
モック化しています。実際のファイルを作成する必要はありません。
mock_openの引数read_dataは、モック化されたファイルオブジェクトから読み込まれるデータを
指定します。今回はb"test file content"というバイナリデータを設定しているため、ファイルを
開いてread()メソッドを呼び出すと、このデータが返されます。
これにより、実際のファイルシステムにアクセスせずにファイル操作をシミュレートできます。

2.boto3.clientのモック化:
mocker.patch("boto3.client", return_value=mock_s3_client)を使用して、boto3のS3クライアントを
モック化しています。これにより、実際のAWSサービスにアクセスすることなくテストができます。
この部分では、boto3.clientの呼び出しが常にmock_s3_clientオブジェクトを返すように設定しています。
mock_s3_clientはMockオブジェクトで、全てのメソッド呼び出しを記録し、指定された戻り値を返す
ことができます。

3.アサーション:
mock_s3_client.put_object.assert_called_once()mock_file.assert_called_once_with(...)
使用して、モック化したオブジェクトが期待通りに呼び出されたことを確認しています。
これらのアサーションは、モックオブジェクトの呼び出し回数や渡されたパラメータを検証します。
例えば、assert_called_once()はメソッドが正確に1回だけ呼び出されたことを確認し、
assert_called_once_with()は特定の引数で1回呼び出されたことを検証します。

4.パラメータ内容の検証:
assert kwargs["Body"].read() == b"test file content"を使用して、S3にアップロードされる
ファイルの内容が期待通りであることを確認しています。
この検証では、S3のput_objectメソッドに渡されたBody引数(ファイルオブジェクト)から実際に
読み込んだデータが、期待した内容と一致するか確認しています。これにより、単にメソッドが
呼び出されただけでなく、正しいデータがアップロードされることも検証できます。

5.例外のモック化:
失敗ケースのテストでは、mock_s3_client.put_object.side_effect = ClientErrorを使用して、
S3クライアントが例外を投げるようにモック化しています。
side_effectを設定することで、メソッドが呼び出されたときに指定した例外を発生させることが
できます。この例では、AWSのClientError例外を発生させることで、S3アップロードの失敗シナリオを
シミュレートしています。これにより、エラー処理コードのテストが可能になります。

テストの実行方法

上記のテストを実行するには、まず必要なパッケージをインストールします。

# 必要なパッケージのインストール
pip install pytest pytest-mock boto3

その後、以下のコマンドでテストを実行します。

pytest test_s3_upload.py -v

まとめ

pytestとmockを使用することで、Amazon S3などの外部サービスを使用するコードを実際のサービスに
アクセスすることなくローカル環境でテストすることができます。このアプローチには以下のメリットがあります。

1.テストの実行が速くなる(実際のネットワーク通信が発生しないため)
2.コストを削減できる(AWSの料金が発生しない)
3.テスト環境の制御が容易になる(エラーケースのシミュレーションなど)
4.CI/CDパイプラインでの実行が簡単になる(認証情報が不要)

モックを効果的に活用することで、より堅牢で信頼性の高いコードを開発することができます。
ただし、最終的には実際の環境で統合テストを実施し、モック化された部分が本番環境でも期待通りに
動作することを確認することが大切です。