모의 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 클라이언트에 동적 디스패치를 사용하려면 호출할 때마다 페이로드와 future를 박싱해야 하므로 데이터 경로에 힙 할당과 동적 디스패치 오버헤드가 추가됩니다. 이 오버헤드를 방지하기 위해 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