🦀

GA した AWS SDK for Rust を触ってみる~どのようにテストするか~

2023/12/22に公開

はじめに

この記事は Rust Advent Calendar 2023 の 21 日目の記事です。

AWS SDK for Rust が GA しました。What's new はこちらでリリースブログはこちらです。

せっかく GA したので色々触ってみようと思います。個人的にプログラミング言語を触るときに気になるのは AWS API でのテストと OpenTelemetry でのトレースなんですが、後者については OpenTelemetry Rust SDK のトレースは Beta なので[1]もう少し安定してからふかぼることにして、今回は AWS API を叩くときのテストを調べます。

AWS SDK for Rust のテスト

公式ドキュメントはこちらです。またドキュメントに主要なサービスに対する例が書かれていて例えば S3 の場合だと Unit and integration test with an SDK の項目にコード例があります。GitHub にもテストの例があります。

テストする方法は色々あるでしょうがドキュメントに書かれている方法は mockall クレートの automock attribute を使って API クライアントを内包した構造体をモックする方法と、AWS Smithy runtime の StaticReplayClient を使ってフェイクの HTTP クライアントを作成する方法です。それぞれ見ていきましょう。

automock を使う場合

最初に Cargo.toml はこんな感じです。

[dependencies]
aws-config = { version= "1.0.3", features = ["behavior-version-latest"] }
aws-sdk-s3= "1.7.0"
mockall = "0.12.0"
tokio = { version = "1", features = ["full"] }

automock を使う場合は以下のように [cfg(test)] attribute を使ってテストの時と本番で使う構造体を切り替えます。

use aws_sdk_s3::error::SdkError;

#[allow(unused_imports)]
use mockall::automock;

use aws_sdk_s3::operation::list_buckets::{ListBucketsError, ListBucketsOutput};

#[cfg(test)]
pub use MockS3Impl as S3;
#[cfg(not(test))]
pub use S3Impl as S3;

#[allow(dead_code)]
pub struct S3Impl {
    inner: Client,
}

その上で S3Impl 構造体を使った操作を定義します。AWS SDK for Rust のリポジトリでは sdk 配下に各 AWS サービスごとにクレートが分かれています。その中で src/operation に各 API 操作がディレクトリで分けられており、API 操作の input/output の構造体が定義されています。また src/operation 直下にも API 操作のファイルがありここにはエラーが定義されています。

#[cfg_attr(test, automock)]
impl S3Impl {
    #[allow(dead_code)]
    pub fn new(inner: Client) -> Self {
        Self { inner }
    }

    #[allow(dead_code)]
    pub async fn list_buckets(&self) -> Result<ListBucketsOutput, SdkError<ListBucketsError>> {
        self.inner.list_buckets().send().await
    }
}

この S3Impl を使った関数を作ってみます。今回は簡単に S3 バケット名一覧を返す関数にします。

async fn get_bucket_names_with_s3impl(client: S3) -> Result<Vec<String>, Error> {
    let resp = client.list_buckets().await?;
    let buckets = resp.buckets();
    let bucket_names: Vec<String> = buckets
        .iter()
        .filter_map(|bucket| bucket.name())
        .map(|bucket_name| bucket_name.to_string())
        .collect();
    Ok(bucket_names)
}

ではこの関数をテストしましょう。S3Impl をモックした MockS3Impl が list_buckets() を呼んだ時のレスポンスを決めておけば良いです。正常系を定義する場合は ListBucketOubput を与えればよく、Builder パターンでこれを作成します。ListBucketsOutputBuilder で返却するバケットを与えます(このバケットも Builder パターンで作成します)。この MockS3Impl を先ほどの get_bucket_names_with_s3impl() の引数に渡すと、この関数が client.list_buckets() を呼んだときに Builder パターンで与えたバケットが返却されます。

#[cfg(test)]
mod test {
    use super::*;

    #[tokio::test]
    async fn test_show_buckets_with_mock() {
        let mut mock = MockS3Impl::default();
        mock.expect_list_buckets().return_once(|| {
            Ok(ListBucketsOutput::builder()
                .set_buckets(Some(vec![aws_sdk_s3::types::Bucket::builder()
                    .name("test-bucket")
                    .build()]))
                .build())
        });

        let res = get_bucket_names_with_s3impl(mock).await;
        assert!(res.is_ok());
        assert_eq!(res.unwrap(), vec!["test-bucket".to_string()])
    }
}

StaticReplayClient を使う場合

最初に Cargo.toml はこんな感じです。http クレートの 1.0 系はまだ使えなそうでした。また StaticReplayClient を使うには aws-smithy-runtime クレートの client test-util feature を有効にする必要があります

[dependencies]
aws-config = { version= "1.0.3", features = ["behavior-version-latest"] }
aws-sdk-s3= "1.7.0"
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
aws-smithy-runtime = { version="1.1.1", features=["client","test-util"]}
aws-smithy-types = { version="1.1.1"}
aws-smithy-runtime-api = { features = ["test-util","http-02x"],  version = "1.1.1"}
# http = 1.0.0 fail
# https://github.com/smithy-lang/smithy-rs/pull/3236
# This controlls http version
http = "0.2.9"

この方法を使う場合は普通に aws_sdk_s3::Client を使う関数を定義すれば良いです。

use aws_sdk_s3::{Client, Error};

async fn get_bucket_names(client: &Client) -> Result<Vec<String>, Error> {
    let resp = client.list_buckets().send().await?;
    let buckets = resp.buckets();
    let bucket_names: Vec<String> = buckets
        .iter()
        .filter_map(|bucket| bucket.name())
        .map(|bucket_name| bucket_name.to_string())
        .collect();
    Ok(bucket_names)
}

テストコードが若干複雑になります。ReplayEvent を作成するときに HTTP リクエストとレスポンスの組を定義し、これを StaticReplayClient::new() に渡します。そして Client (HTTP クライアントではなく SDK クライアント)を構築するときに http_client() にこの StaticReplayClient を渡します。あとはこうして作成した Client を get_bucket_names() の引数に渡すだけです。

#[cfg(test)]
mod test {
    use super::*;
    use aws_config::BehaviorVersion;
    use aws_sdk_s3::{
        config::{Credentials, Region},
        Config,
    };
    use aws_smithy_runtime::client::http::test_util::{ReplayEvent, StaticReplayClient};
    use aws_smithy_types::body::SdkBody;

    use crate::get_bucket_names;

    fn make_s3_test_credentials() -> Credentials {
        Credentials::new(
            "ATESTCLIENT",
            "astestsecretkey",
            Some("atestsessiontoken".to_string()),
            None,
            "",
        )
    }

    #[tokio::test]
    async fn test_show_buckets_with_replay_client() {
        let page = ReplayEvent::new(
            http::Request::builder()
                .method("GET")
                .uri("https://s3.amazonaws.com/")
                .body(SdkBody::empty())
                .unwrap(),
            http::Response::builder()
                .status(200)
                .body(SdkBody::from(include_str!(
                    "../testing/list_buckets_response.xml"
                )))
                .unwrap(),
        );
        let replay_client = StaticReplayClient::new(vec![page]);
        let client: Client = Client::from_conf(
            Config::builder()
                .behavior_version(BehaviorVersion::latest())
                .credentials_provider(make_s3_test_credentials())
                .region(Region::new("us-east-1"))
                .http_client(replay_client.clone())
                .build(),
        );
        let res = get_bucket_names(&client).await;
        assert!(res.is_ok());
        assert_eq!(
            res.unwrap(),
            vec![
                "DOC-EXAMPLE-BUCKET".to_string(),
                "DOC-EXAMPLE-BUCKET2".to_string()
            ]
        )
    }
}

list_buckets_response.xml はこんな感じです。AWS の API レスポンス形式はドキュメントにまとまっていて、今回の ListBuckets はここで定義されているのでモックしたいレスポンスを定義すれば良いです。

<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult>
   <Buckets>
      <Bucket>
         <CreationDate>2019-12-11T23:32:47+00:00</CreationDate>
         <Name>DOC-EXAMPLE-BUCKET</Name>
      </Bucket>
      <Bucket>
         <CreationDate>2019-11-10T23:32:13+00:00</CreationDate>
         <Name>DOC-EXAMPLE-BUCKET2</Name>
      </Bucket>
   </Buckets>
   <Owner>
      <DisplayName>Account+Name</DisplayName>
      <ID>AIDACKCEVSQ6C2EXAMPLE</ID>
   </Owner>  
</ListAllMyBucketsResult>   

おわりに

二つの方法で AWS SDK for Rust のテストを実装してみました。自分で使うときは automock を使うかなと思います(外部のクレートを使うことになるのと attribute を使って切り替えるのが少し手間には感じるものの、モックの作成自体は割とすんなり書けるのとモックのレスポンス定義をコードにそのままかけるため)。また今回は正常系のテストしか書いてないので、異常系のテストをどう書くかや、? でいい感じにするにはどのようなエラーを返せばいいかを引き続き調べようと思います。

脚注
  1. OpenTelemetry Rust SDK の README には "If you are starting fresh, then consider using tracing as your logging API. It supports structured logging and is actively maintained."と書いています。tracing クレートでトレースを取るようにして、tracing-opentelemetry クレートと組み合わせればいいのではないかと思います。 ↩︎

Discussion