単体テストを書くために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-mocks
のREADME.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() {
let put_item_ok = mock!(aws_sdk_dynamodb::Client::put_item)
.then_output(|| PutItemOutput::builder().build());
let dynamodb = mock_client!(aws_sdk_dynamodb, [&put_item_ok]);
let result = my_function(dynamodb).await;
assert!(result.is_ok());
}
以下、ステップバイステップで解説します。
-
モックの挙動を定義する。
mock!
マクロを使って所望のAWSサービスAPIの挙動のモックを開始し、続けてRuleBuilder
のメソッドを呼び出します。
例ではDynamoDBのPutItem
API呼び出しが空の成功結果を返すようにモックします。
let put_item_ok = mock!(aws_sdk_dynamodb::Client::put_item)
.then_output(|| PutItemOutput::builder().build());
-
モックの挙動をSDKクライアントに設定する。
mock_client!
マクロを使ってSDKクライアントにモックの挙動を設定します。
例ではDynamoDBクライアントにステップ1で定義したモックの挙動を設定しています。
let dynamodb = mock_client!(aws_sdk_dynamodb, [&put_item_ok]);
-
モックしたSDKクライアントを使ってテストしたい関数を実行する。
関数をどう呼び出すかは実装次第ですが、テストする関数は何らかの手段でSDKクライアントをパラメータとして受け取る必要があります。
let result = my_function(dynamodb).await;
-
結果を検証する。
お好きなアサーションで。
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: aws_sdk_cognitoidentityprovider::Client,
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 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() {
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());
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());
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,
]
);
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クライアントの挙動の初期化に割かれています。
これだとテストの目的が分かりにくくなりそうです。
似たようなテストが何十もあると考えてみてください・・・
Client
がaws_sdk_cognitoidentityprovider::Client
とaws_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() {
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(),
]
);
let dynamodb = mock_client!(
aws_sdk_dynamodb,
RuleMode::MatchAny,
[
&self::mocks::dynamodb::delete_item_session(),
&self::mocks::dynamodb::put_item_ok(),
]
);
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のエラー応答を表すPutItemError
のConditionalCheckFailedException
バリアントなどのことです。
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 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
は上記のテクニックのすべての観点をもっとシンプルな記述でカバーしているように見受けられます。
まとめ
このブログ投稿では以下を紹介しました。
参考
- "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
- "Builder," Rust Design Patterns - https://rust-unofficial.github.io/patterns/patterns/creational/builder.html
- "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
- "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