モック Cloud Storage クライアントを使用してテストを作成する

Rust 用の Cloud クライアント ライブラリには、テスト用のモックを挿入できるように、実際のクライアント実装をスタブ化する方法が用意されています。モックを使用すると、ネットワーク呼び出しを行わず、料金も発生しない、制御された信頼性の高い単体テストを作成できます。

このドキュメントでは、次のトピックについて説明します。

  • Cloud Storage Storage クライアントを使用してテスト可能なインターフェースを作成する方法。
  • 読み取りオペレーションをモックする方法。
  • 書き込みオペレーションをモックする方法。
  • Storage クライアントの設計が他の Google Cloudクライアントと異なる理由。

依存関係

Cloud Storage クライアント ライブラリを Cargo.toml ファイルに追加します。

cargo add google-cloud-storage

このドキュメントの単体テストをコンパイルして実行するには、次の開発依存関係を追加します。

cargo add --dev anyhow bytes mockall
cargo add --dev tokio --features macros,rt-multi-thread

テスト可能なインターフェースを作成する

ストレージ コードの単体テストを行わない場合は、Storage を使用してインターフェースを直接作成できます。この場合、デフォルトのスタブタイプ パラメータはクライアントの本番環境実装になります。

pub async fn my_function(_client: Storage) {}

モック クライアントでコードをテストするには、gcs::stub::Storage + 'static によって制約される汎用型パラメータ T を使用してインターフェースを記述します。

pub async fn my_testable_function<T>(_client: Storage<T>)
where
    T: gcs::stub::Storage + 'static,
{
}

読み取りオペレーションをモックする

read_object リクエストをモックするには、次の手順を行います。

  1. 汎用 &Storage<T> クライアントを受け取るアプリケーション関数を作成します。たとえば、次の関数はオブジェクトをダウンロードし、含まれている改行文字の数をカウントします。

    // Downloads an object from GCS and counts the total lines.
    pub async fn count_newlines<T>(
        client: &Storage<T>,
        bucket_id: &str,
        object_id: &str,
    ) -> gcs::Result<usize>
    where
        T: gcs::stub::Storage + 'static,
    {
        let mut count = 0;
        let mut reader = client
            .read_object(format!("projects/_/buckets/{bucket_id}"), object_id)
            .set_generation(42)
            .send()
            .await?;
        while let Some(buffer) = reader.next().await.transpose()? {
            count += buffer.into_iter().filter(|c| *c == b'\n').count();
        }
        Ok(count)
    }

  2. ペイロードを ReadObjectResponse::from_source に渡して、偽の ReadObjectResponse バイト ストリームを構築するヘルパー関数を作成します。このメソッドは、Storage::write_object と同じペイロード タイプを受け入れます。

    fn fake_response(size: usize) -> ReadObjectResponse {
        let mut contents = String::new();
        for i in 0..size {
            contents.push_str(&format!("{i}\n"))
        }
        ReadObjectResponse::from_source(ObjectHighlights::default(), bytes::Bytes::from(contents))
    }

  3. gcs::stub::Storage を実装するモック構造体を定義します。このドキュメントでは mockall クレートを使用しますが、テストでは別のモック フレームワークを使用できます。

    mockall::mock! {
        #[derive(Debug)]
        Storage {}
        impl gcs::stub::Storage for Storage {
            async fn read_object(&self, _req: ReadObjectRequest, _options: RequestOptions) -> Result<ReadObjectResponse>;
            async fn write_object_buffered<P: StreamingSource + Send + Sync + 'static>(
                &self,
                _payload: P,
                _req: WriteObjectRequest,
                _options: RequestOptions,
            ) -> Result<Object>;
            async fn write_object_unbuffered<P: StreamingSource + Seek + Send + Sync + 'static>(
                &self,
                _payload: P,
                _req: WriteObjectRequest,
                _options: RequestOptions,
            ) -> Result<Object>;
        }
    }

  4. モックに期待値を設定し、Storage::from_stub を使用してクライアントを構築し、count_newlines を呼び出す単体テストを作成します。

    #[tokio::test]
    async fn test_count_lines() -> anyhow::Result<()> {
        let mut mock = MockStorage::new();
        mock.expect_read_object().return_once({
            move |r, _| {
                // Verify contents of the request
                assert_eq!(r.generation, 42);
                assert_eq!(r.bucket, "projects/_/buckets/my-bucket");
                assert_eq!(r.object, "my-object");
    
                // Return a `ReadObjectResponse`
                Ok(fake_response(100))
            }
        });
        let client = gcs::client::Storage::from_stub(mock);
    
        let count = count_newlines(&client, "my-bucket", "my-object").await?;
        assert_eq!(count, 100);
    
        Ok(())
    }

書き込みオペレーションをモックする

write_object リクエストをモックするには、次の手順を行います。

  1. 一般的な &Storage<T> クライアントを使用してオブジェクトをアップロードするアプリケーション関数を作成します。

    // Uploads an object to GCS.
    pub async fn upload<T>(client: &Storage<T>, bucket_id: &str, object_id: &str) -> gcs::Result<Object>
    where
        T: gcs::stub::Storage + 'static,
    {
        client
            .write_object(
                format!("projects/_/buckets/{bucket_id}"),
                object_id,
                "payload",
            )
            .set_if_generation_match(42)
            .send_unbuffered()
            .await
    }

  2. 前のセクションで定義した MockStorage 構造体を使用して、モックの期待値を構成し、Storage::from_stub を使用してクライアントを構築し、upload を呼び出す単体テストを作成します。

    #[tokio::test]
    async fn test_upload() -> anyhow::Result<()> {
        let mut mock = MockStorage::new();
        mock.expect_write_object_unbuffered()
            .return_once(
                |_payload: Payload<BytesSource>, r, _| {
                    // Verify contents of the request
                    assert_eq!(r.spec.if_generation_match, Some(42));
                    let o = r.spec.resource.unwrap_or_default();
                    assert_eq!(o.bucket, "projects/_/buckets/my-bucket");
                    assert_eq!(o.name, "my-object");
    
                    // Return the object
                    Ok(Object::default()
                        .set_bucket("projects/_/buckets/my-bucket")
                        .set_name("my-object")
                        .set_generation(42))
                },
            );
        let client = gcs::client::Storage::from_stub(mock);
    
        let object = upload(&client, "my-bucket", "my-object").await?;
        assert_eq!(object.generation, 42);
    
        Ok(())
    }

期待値の詳細

upload 関数は send_unbuffered を呼び出すため、対応する expect_write_object_unbuffered メソッドに期待値を設定します。

mock.expect_write_object_unbuffered()

mockall::mock! マクロは、各汎用インスタンス化を個別のメソッドとして扱います。コンパイラがメソッドの期待値と一致するように、クロージャ シグネチャで具体的なペイロード タイプ Payload<BytesSource> を指定します。

|_payload: Payload<BytesSource>, r, _| {

設計の根拠

以降のセクションでは、Storage クライアントが動的ディスパッチではなく、スタブに汎用型パラメータを使用する理由について説明します。

その他のクライアント

StorageControl などのほとんどのクライアントは、スタブトレイトの参照カウント(Arc)dyn 互換実装を内部に保持します。動的ディスパッチを使用して、クライアントから基盤となるスタブにリクエストを転送します。このスタブは、本番環境の実装またはモックのいずれかです。

これらのクライアントは動的ディスパッチを使用するため、コンパイラはコンパイル時に具体的なスタブタイプを解決する必要がなく、クライアント構造体はスタブに汎用型パラメータを必要としません。

Storage クライアント

Rust では、dyn 互換のトレイトにジェネリック メソッドを含めることはできません。また、ボックス化されていない impl Future 値を返すこともできません。

  • write_object_buffered メソッドと write_object_unbuffered メソッドは、ペイロード ソースタイプに対して汎用的です。
  • gcs::stub::Storage トレイト メソッドは、返された Future をボックス化するのではなく、impl Future を直接返します。

Storage クライアントに動的ディスパッチを使用すると、呼び出しごとにペイロードとフューチャーをボックス化する必要があり、データパスにヒープ割り当てと動的ディスパッチのオーバーヘッドが追加されます。このオーバーヘッドを回避するため、Storage クライアントは、代わりに gcs::stub::Storage トレイトの具体的な実装に型パラメータを使用します。

完全なアプリケーション コードとテストスイート

次のコードは、アプリケーション関数と単体テストを組み合わせて実証済みの例にしています。

use gcs::client::Storage;
use gcs::model::Object;
use google_cloud_storage as gcs;

// Downloads an object from GCS and counts the total lines.
pub async fn count_newlines<T>(
    client: &Storage<T>,
    bucket_id: &str,
    object_id: &str,
) -> gcs::Result<usize>
where
    T: gcs::stub::Storage + 'static,
{
    let mut count = 0;
    let mut reader = client
        .read_object(format!("projects/_/buckets/{bucket_id}"), object_id)
        .set_generation(42)
        .send()
        .await?;
    while let Some(buffer) = reader.next().await.transpose()? {
        count += buffer.into_iter().filter(|c| *c == b'\n').count();
    }
    Ok(count)
}

// Uploads an object to GCS.
pub async fn upload<T>(client: &Storage<T>, bucket_id: &str, object_id: &str) -> gcs::Result<Object>
where
    T: gcs::stub::Storage + 'static,
{
    client
        .write_object(
            format!("projects/_/buckets/{bucket_id}"),
            object_id,
            "payload",
        )
        .set_if_generation_match(42)
        .send_unbuffered()
        .await
}

#[cfg(test)]
mod tests {
    use super::{count_newlines, upload};
    use gcs::Result;
    use gcs::model::{Object, ReadObjectRequest};
    use gcs::model_ext::{ObjectHighlights, WriteObjectRequest};
    use gcs::read_object::ReadObjectResponse;
    use gcs::request_options::RequestOptions;
    use gcs::streaming_source::{BytesSource, Payload, Seek, StreamingSource};
    use google_cloud_storage as gcs;

    mockall::mock! {
        #[derive(Debug)]
        Storage {}
        impl gcs::stub::Storage for Storage {
            async fn read_object(&self, _req: ReadObjectRequest, _options: RequestOptions) -> Result<ReadObjectResponse>;
            async fn write_object_buffered<P: StreamingSource + Send + Sync + 'static>(
                &self,
                _payload: P,
                _req: WriteObjectRequest,
                _options: RequestOptions,
            ) -> Result<Object>;
            async fn write_object_unbuffered<P: StreamingSource + Seek + Send + Sync + 'static>(
                &self,
                _payload: P,
                _req: WriteObjectRequest,
                _options: RequestOptions,
            ) -> Result<Object>;
        }
    }

    fn fake_response(size: usize) -> ReadObjectResponse {
        let mut contents = String::new();
        for i in 0..size {
            contents.push_str(&format!("{i}\n"))
        }
        ReadObjectResponse::from_source(ObjectHighlights::default(), bytes::Bytes::from(contents))
    }

    #[tokio::test]
    async fn test_count_lines() -> anyhow::Result<()> {
        let mut mock = MockStorage::new();
        mock.expect_read_object().return_once({
            move |r, _| {
                // Verify contents of the request
                assert_eq!(r.generation, 42);
                assert_eq!(r.bucket, "projects/_/buckets/my-bucket");
                assert_eq!(r.object, "my-object");

                // Return a `ReadObjectResponse`
                Ok(fake_response(100))
            }
        });
        let client = gcs::client::Storage::from_stub(mock);

        let count = count_newlines(&client, "my-bucket", "my-object").await?;
        assert_eq!(count, 100);

        Ok(())
    }

    #[tokio::test]
    async fn test_upload() -> anyhow::Result<()> {
        let mut mock = MockStorage::new();
        mock.expect_write_object_unbuffered()
            .return_once(
                |_payload: Payload<BytesSource>, r, _| {
                    // Verify contents of the request
                    assert_eq!(r.spec.if_generation_match, Some(42));
                    let o = r.spec.resource.unwrap_or_default();
                    assert_eq!(o.bucket, "projects/_/buckets/my-bucket");
                    assert_eq!(o.name, "my-object");

                    // Return the object
                    Ok(Object::default()
                        .set_bucket("projects/_/buckets/my-bucket")
                        .set_name("my-object")
                        .set_generation(42))
                },
            );
        let client = gcs::client::Storage::from_stub(mock);

        let object = upload(&client, "my-bucket", "my-object").await?;
        assert_eq!(object.generation, 42);

        Ok(())
    }
}

cargo test を実行して test_count_lines と test_upload を実行します。

cargo test