aws-smithy-mocksでAWSサービスをモックする

2025-09-30

thumbnail

単体テストを書くためにAWSサービスのモックが必要ですか? aws-smithy-mocksを試してみてはどうでしょうか?

背景

AWS SDK for Rustを使ってAWSサービスとやりとりするRustコードを書いており、そのコードをテストするのにAWSサービスをモックする必要があるとします。 AWSサービスをモックする方法はいくつもありますが、このブログ投稿ではaws-smithy-mocksにフォーカスし、私がそれを使って学んだ実践方法について紹介します

aws-smithy-mocksとは?

aws-smithy-mocksはAWS SDK for Rustを使っているプログラム向けにAWSサービスをモックするためのクレートです。 以下はaws-smithy-mocksREADME.mdから抜き出した紹介文(括弧内は筆者訳文)です。

A flexible mocking framework for testing clients generated by smithy-rs, including all packages of the AWS SDK for Rust. (smithy-rsを用いて生成したクライアントでテストを行うための柔軟なモックフレームワークで、AWS SDK for Rustのすべてのパッケージに対応しています。)

This crate provides a simple yet powerful way to mock SDK client responses for testing purposes. It uses interceptors to return stub responses, allowing you to test both happy-path and error scenarios without mocking the entire client or using traits. (このクレートはSDKクライアントの応答をテスト用にモックするためのシンプルかつパワフルな方法を提供します。スタブ応答を返すのにインターセプターを使っており、正常系とエラーシナリオの両方をテストすることができます。クライアント全体をモックしたりトレイトを使ったりする必要はありません。)

Rust SDKのAWSドキュメントにはaws-smithy-mocksにフォーカスしたページがあります。 私がaws-smithy-mocksに最初に出会った時には、実はaws-smithy-mocks-experimentalと呼ばれていました。 ということでAWSが(少なくとも今のところは)このクレートに対して真剣であることが分かって嬉しい限りです。

はじめてのaws-smithy-mocks

aws-smithy-mocksの基本的な使い方についてはAWSのドキュメントをご参照ください(日本語訳は微妙ですが・・・)。 このブログ投稿で基礎を繰り返すつもりはありません。

aws-smithy-mocksを追加する

aws-smithy-mocksを開発用の依存関係に追加します。

cargo add --dev aws-smithy-mocks

AWS SDKクレートにtest-utilフィーチャーを追加する

aws-smithy-mocksでモックしたい場合、AWS SDKクレートのtest-utilフィーチャーを有効化する必要があります。 aws-sdk-dynamodbを開発中にのみモックしたい場合は以下のコマンドを実行します。前者は製品用、後者は開発用です。

cargo add aws-sdk-dynamodb
cargo add --dev aws-sdk-dynamodb --features test-util

Cargo.tomlには以下が追加されます。

[dependencies]
aws-sdk-dynamodb = "1.93.0"

[dev-dependencies]
aws-sdk-dynamodb = { version = "1.93.0", features = ["test-util"] }

依存関係が重複しているのでSDKのバージョンを変えたい時に同期しなければならないというのは欠点です。

簡単な単体テストを書く

以下はDynamoDBのPutItem APIを呼び出す架空の関数my_functionに対する簡単なテストです。

#[tokio::test]
async fn test_dynamodb_table_put_item() {
    // 1. モックの挙動を定義する
    let put_item_ok = mock!(aws_sdk_dynamodb::Client::put_item)
        .then_output(|| PutItemOutput::builder().build());

    // 2. モックの挙動をクライアントに設定する
    let dynamodb = mock_client!(aws_sdk_dynamodb, [&put_item_ok]);

    // 3. モッククライアントを使ってテストしたい関数を実行する
    let result = my_function(dynamodb).await;

    // 4. 結果を検証する
    assert!(result.is_ok());
}

以下、ステップバイステップで解説します。

  1. モックの挙動を定義する。

    mock!マクロを使って所望のAWSサービスAPIの挙動のモックを開始し、続けてRuleBuilderのメソッドを呼び出します。 例ではDynamoDBのPutItem API呼び出しが空の成功結果を返すようにモックします。

    let put_item_ok = mock!(aws_sdk_dynamodb::Client::put_item)
        .then_output(|| PutItemOutput::builder().build());
    
  2. モックの挙動をSDKクライアントに設定する。

    mock_client!マクロを使ってSDKクライアントにモックの挙動を設定します。 例ではDynamoDBクライアントにステップ1で定義したモックの挙動を設定しています。

    let dynamodb = mock_client!(aws_sdk_dynamodb, [&put_item_ok]);
    
  3. モックしたSDKクライアントを使ってテストしたい関数を実行する。

    関数をどう呼び出すかは実装次第ですが、テストする関数は何らかの手段でSDKクライアントをパラメータとして受け取る必要があります。

    let result = my_function(dynamodb).await;
    
  4. 結果を検証する。

    お好きなアサーションで。

    assert!(result.is_ok());
    

実践的なテストを書く

私はもっぱらAWS Rust SDKをlambda_runtimeと組み合わせてAWS Lambda関数を書くのに使っています。 ここで紹介するテクニックはtowerベースのシステム(axumなど)でも使えるかもしれません。

AWS SDKクライアントをパラメータ化する

aws-smithy-mocksを効果的に使うには、テストする関数は何らかの手段でAWS SDKクライアントをパラメータとして受け取る必要があります。.

私は以下のような構造体を定義し、Lambdaインスタンスのライフサイクルを通して再利用する値を管理しています。 derive_builderクレートはビルダーパターンをお手軽に実装するためのderiveアトリビュートを提供しており、テストの際に特に役に立ちます。

#[derive(derive_builder::Builder)]
struct SharedState {
    /// Cognitoクライアント
    cognito: aws_sdk_cognitoidentityprovider::Client,
    /// DynamoDBクライアント
    dynamodb: aws_sdk_dynamodb::Client,
}

メイン関数ではSharedStateのインスタンスを初期化し、Lambda呼び出しのたびにハンドラに渡しています。 非同期(async)呼び出しを越えて共有するためにArcでラップする必要があります。

use lambda_runtime::{Error, run, service_fn};
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), Error> {
    let shared_state = Arc::new(SharedState::new().await?);
    run(service_fn(|req| async {
        function_handler(shared_state.clone(), req).await
    })).await
}

モックをフィクスチャとして分離する

実践的なモックを書きはじめると、すぐにコードがとっちらかってきます。 可読性と再利用性向上のため、私はモックをAWSサービス毎に独立したサブモジュールにフィクスチャとして分離しています。 私のプロジェクトの一つで実際に行っているテストを取り上げて説明します。

テストする関数は以下のAWS APIを使っています。

とっちらかしてみる

もしモックを分離せずにテストを書くと、以下のような感じになるでしょう(本質的でない詳細、タイプ定義、定数の一部は単純化のために省略)。

// 長々としたuse宣言
use aws_sdk_cognitoidentityprovider::{
    Client as CognitoClient,
    operation::{
        admin_create_user::AdminCreateUserOutput,
        admin_set_user_password::AdminSetUserPasswordOutput,
        list_users::ListUsersOutput,
    },
    types::{AttributeType, UserType},
};
use aws_sdk_dynamodb::{
    Client as DynamodbClient,
    operation::{
        delete_item::DeleteItemOutput,
        put_item::PutItemOutput,
    },
    primitives::DateTime,
    types::AttributeValue,
};
use aws_smithy_mocks::{mock, mock_client, RuleMode};
use std::collections::HashMap;
use std::time::SystemTime;

#[tokio::test]
async fn finish_registration_of_legitimate_new_user() {
    // Cognitoの挙動をモックする
    let list_users_empty = mock!(CognitoClient::list_users)
        .then_output(|| ListUsersOutput::builder().build());
    let admin_create_user_ok = mock!(CognitoClient::admin_create_user)
        .then_output(|| AdminCreateUserOutput::builder()
            .user(UserType::builder()
                .attributes(AttributeType::builder()
                    .name("sub")
                    .value("dummy-sub-123")
                    .build()
                    .unwrap())
                .build())
            .build());
    let admin_set_user_password_ok = mock!(CognitoClient::admin_set_user_password)
        .then_output(|| AdminSetUserPasswordOutput::builder().build());

    // DynamoDBの挙動をモックする
    let delete_item_session = mock!(DynamodbClient::delete_item)
        .then_output(|| {
            let ttl = DateTime::from(SystemTime::now()).secs() + 300;
            DeleteItemOutput::builder()
                .attributes("ttl", AttributeValue::N(format!("{}", ttl)))
                .attributes("state", AttributeValue::S(OK_PASSKEY_REGISTRATION.to_string()))
                .attributes("userId", AttributeValue::S("8TZ_kg_dp_pr0t7SDvGJiw".to_string()))
                .attributes("userInfo", AttributeValue::M(HashMap::from([
                    ("username".to_string(), AttributeValue::S("test".to_string())),
                    ("displayName".to_string(), AttributeValue::S("Test User".to_string())),
                ])))
                .build()
        });
    let put_item_ok = mock!(DynamodbClient::put_item)
        .then_output(|| PutItemOutput::builder().build());

    // SDKクライアントを構築する
    let cognito = mock_client!(
        aws_sdk_cognitoidentityprovider,
        RuleMode::MatchAny,
        [
            &list_users_empty,
            &admin_create_user_ok,
            &admin_set_user_password_ok,
        ]
    );
    let dynamodb = mock_client!(
        aws_sdk_dynamodb,
        RuleMode::MatchAny,
        [
            &delete_item_session,
            &put_item_ok,
        ]
    );

    // SharedStateを初期化する
    let shared_state = SharedStateBuilder::default()
        .webauthn(ConstantWebauthn::new(OK_PASSKEY))
        .cognito(cognito)
        .dynamodb(dynamodb)
        .build()
        .unwrap();
    let shared_state = Arc::new(shared_state);

    // 関数を検証する
    let res = finish_registration(
        shared_state,
        FinishRegistrationSession {
            session_id: "dummy-session-id".to_string(),
            public_key_credential: serde_json::from_str(
                OK_REGISTER_PUBLIC_KEY_CREDENTIAL,
            ).unwrap(),
        },
    ).await.unwrap();
    assert_eq!(res.user_id, "8TZ_kg_dp_pr0t7SDvGJiw");
}

上記の例では、たくさんの行がCognitoとDynamoDBクライアントの挙動の初期化に割かれています。 これだとテストの目的が分かりにくくなりそうです。 似たようなテストが何十もあると考えてみてください・・・

Clientaws_sdk_cognitoidentityprovider::Clientaws_sdk_dynamodb::Clientとの間で衝突するという微妙な気持ち悪さもあります。 曖昧さを取り除くためには完全限定名もしくはエイリアスを使用するかしないといけません。

モックの挙動をサブモジュールに分離する

各モックの挙動をフィクスチャとして分離するとテストの可読性が向上します。 それぞれのフィクスチャはRuleを返す関数となります。 例えば、上記のテストにおけるCognitoのListUsers APIのモックの挙動は以下のような関数として書くことができます。

pub(crate) fn list_users_empty() -> Rule {
    mock!(Client::list_users)
        .then_output(|| ListUsersOutput::builder().build())
}

そしてDynamoDBのDeleteItem APIのモックの挙動は以下のように書けます。

pub(crate) fn delete_item_session() -> Rule {
    mock!(Client::delete_item)
        .then_output(|| {
            let ttl = DateTime::from(SystemTime::now()).secs() + 300;
            DeleteItemOutput::builder()
                .attributes("ttl", AttributeValue::N(format!("{}", ttl)))
                .attributes("state", AttributeValue::S(OK_PASSKEY_REGISTRATION.to_string()))
                .attributes("userId", AttributeValue::S("8TZ_kg_dp_pr0t7SDvGJiw".to_string()))
                .attributes("userInfo", AttributeValue::M(HashMap::from([
                    ("username".to_string(), AttributeValue::S("test".to_string())),
                    ("displayName".to_string(), AttributeValue::S("Test User".to_string())),
                ])))
                .build()
        })
}

すべてのモックの挙動をAWSサービスごとのサブモジュールにグループ分けすると、以下のような感じになります。

pub(crate) mod mocks {
    use aws_smithy_mocks::{mock, Rule};

    pub(crate) mod cognito {
        use aws_sdk_cognitoidentityprovider::{
            Client as CognitoClient,
            operation::{
                admin_create_user::AdminCreateUserOutput,
                admin_set_user_password::AdminSetUserPasswordOutput,
                list_users::ListUsersOutput,
            },
            types::{AttributeType, UserType},
        };

        pub(crate) fn list_users_empty() -> Rule {
            mock!(Client::list_users)
                .then_output(|| ListUsersOutput::builder().build())
        }

        pub(crate) fn admin_create_user_ok() -> Rule {
            mock!(Client::admin_create_user)
                .then_output(|| AdminCreateUserOutput::builder()
                    .user(UserType::builder()
                        .attributes(AttributeType::builder()
                            .name("sub")
                            .value("dummy-sub-123")
                            .build()
                            .unwrap())
                        .build())
                    .build())
        }

        pub(crate) fn admin_set_user_password_ok() -> Rule {
            mock!(Client::admin_set_user_password)
                .then_output(|| AdminSetUserPasswordOutput::builder().build())
        }
    }

    pub(crate) mod dynamodb {
        use aws_sdk_dynamodb::{
            Client,
            operation::{
                delete_item::DeleteItemOutput,
                put_item::PutItemOutput,
            },
            primitives::DateTime,
            types::AttributeValue,
        };
        use std::collections::HashMap;
        use std::time::SystemTime;

        pub(crate) fn delete_item_session() -> Rule {
            mock!(Client::delete_item)
                .then_output(|| {
                    let ttl = DateTime::from(SystemTime::now()).secs() + 300;
                    DeleteItemOutput::builder()
                        .attributes("ttl", AttributeValue::N(format!("{}", ttl)))
                        .attributes("state", AttributeValue::S(OK_PASSKEY_REGISTRATION.to_string()))
                        .attributes("userId", AttributeValue::S("8TZ_kg_dp_pr0t7SDvGJiw".to_string()))
                        .attributes("userInfo", AttributeValue::M(HashMap::from([
                            ("username".to_string(), AttributeValue::S("test".to_string())),
                            ("displayName".to_string(), AttributeValue::S("Test User".to_string())),
                        ])))
                        .build()
                })
        }

        pub(crate) fn put_item_ok() -> Rule {
            mock!(Client::put_item)
                .then_output(|| PutItemOutput::builder().build())
        }
    }
}

各サブモジュールは特定のAWSサービスにフォーカスしているので、モックの挙動を記述するのにインポートするタイプを絞り込むことができます。 Clientの曖昧さの問題も解消します。

すっきりまとめる

上記のテストはモックの挙動のフィクスチャを使うと以下のように書き直せます。

use aws_smithy_mocks::{mock_client, RuleMode};

#[tokio::test]
async fn finish_registration_of_legitimate_new_user() {
    // モックの挙動を選択してCognitoクライアントを構築する
    let cognito = mock_client!(
        aws_sdk_cognitoidentityprovider,
        RuleMode::MatchAny,
        [
            &self::mocks::cognito::list_users_empty(),
            &self::mocks::cognito::admin_create_user_ok(),
            &self::mocks::cognito::admin_set_user_password_ok(),
        ]
    );
    // モックの挙動を選択してDynamoDBクライアントを構築する
    let dynamodb = mock_client!(
        aws_sdk_dynamodb,
        RuleMode::MatchAny,
        [
            &self::mocks::dynamodb::delete_item_session(),
            &self::mocks::dynamodb::put_item_ok(),
        ]
    );

    // SharedStateを初期化する
    let shared_state = SharedStateBuilder::default()
        .webauthn(ConstantWebauthn::new(OK_PASSKEY))
        .cognito(cognito)
        .dynamodb(dynamodb)
        .build()
        .unwrap();
    let shared_state = Arc::new(shared_state);

    // 関数を検証する
    let res = finish_registration(
        shared_state,
        FinishRegistrationSession {
            session_id: "dummy-session-id".to_string(),
            public_key_credential: serde_json::from_str(
                OK_REGISTER_PUBLIC_KEY_CREDENTIAL,
            ).unwrap(),
        },
    ).await.unwrap();
    assert_eq!(res.user_id, "8TZ_kg_dp_pr0t7SDvGJiw");
}

エラー応答をモックする

正常系だけでなくエラーシナリオもテストするのは大変重要です。 実際、私の書くテストはほとんどエラーシナリオを想定したものです。

モデルエラーをモックする

モデルエラー(modeled error) とは、特定のAWS APIのエラー応答を表すenumのバリアントとして定義されているエラーのことです。例えば、DynamoDBのPutItem APIのエラー応答を表すPutItemErrorConditionalCheckFailedExceptionバリアントなどのことです。

RuleBuilder::then_errorメソッドを使うと、AWS API呼び出しが特定のエラー応答を返すように仕向けることができます。 以下はDynamoDBのPutItem API呼び出しがThrottlingExceptionエラー応答を返すようにする例です。

pub(crate) fn put_item_throttling_exception() -> Rule {
    mock!(Client::put_item)
        .then_error(|| PutItemError::ThrottlingException(
            ThrottlingException::builder()
                .meta(ErrorMetadata::builder()
                    .code("ThrottlingException")
                    .build())
                .build(),
        ))
}

上記の例はThrottlingExceptionオブジェクトを構築する際にmeta関数を使ってエラーコード"ThrottlingException"を設定しています。 これは重要なポイントで、エラーハンドラがバリアントタイプではなくむしろエラーコードに依存している可能性があるためです。 残念ながら、エラー構造体のビルダーはデフォルトでエラーコードを設定してくれません。

非モデルエラーをモックする

AWSサービスのすべての応答がenumのバリアントとして定義されているわけではありません。 例えば、ServiceUnavailable応答は私の知る限りモデルエラーになっていません。 AWS API呼び出しが非モデルエラー応答を返すようにするためには、RuleBuilder::then_http_responseメソッドを用いて生のHTTP応答を指定する必要があります。 以下はCognitoのListUsers API呼び出しがServiceUnavailableエラーで応答するようにさせる例です。

// 追加のuse宣言
use aws_smithy_runtime_api::{
    client::orchestrator::HttpResponse,
    http::StatusCode,
};
use aws_smithy_types::body::SdkBody;

pub(crate) fn list_users_service_unavailable() -> Rule {
    mock!(Client::list_users)
        .then_http_response(|| {
            HttpResponse::new(
                StatusCode::try_from(503).unwrap(),
                SdkBody::from(r#"{"code": "ServiceUnavailable", "message": "Service unavailable."}"#),
            )
        })
}

応答のJSONオブジェクトのcodeフィールドがエラーコード(タイプ = ServiceUnavailable)を決定します。 また、対応するHTTPステータスコードも設定する必要があります(ServiceUnavailableエラーだと503)。

限界

外部サービスをモックする場合あるあるですが、テストしたい状況におけるAWSサービスの挙動について理解しておかなくてはいけません。 この限界を乗り越えるためには、以下が必要です。

  • AWSドキュメントをしっかり読む
  • 実際のAWSサービスで実験する

生成AIに相談するのもよいかもしれません。

他の選択肢

AWSのドキュメントはAWS SDKの挙動をモックする他のテクニックも紹介しています。

私自身は上記を試していませんが、aws-smithy-mocksは上記のテクニックのすべての観点をもっとシンプルな記述でカバーしているように見受けられます。

まとめ

このブログ投稿では以下を紹介しました。

参考

  1. "Unit testing with aws-smithy-mocks in the AWS SDK for Rust," AWS SDK for Rust Developer Guide - https://docs.aws.amazon.com/sdk-for-rust/latest/dg/testing-smithy-mocks.html
  2. "Builder," Rust Design Patterns - https://rust-unofficial.github.io/patterns/patterns/creational/builder.html
  3. "Automatically generate mocks using mockall in the AWS SDK for Rust," AWS SDK for Rust Developer Guide - https://docs.aws.amazon.com/sdk-for-rust/latest/dg/testing-automock.html
  4. "Simulate HTTP traffic using static replay in the AWS SDK for Rust," AWS SDK for Rust Developer Guide - https://docs.aws.amazon.com/sdk-for-rust/latest/dg/testing-replay.html