Menulis pengujian menggunakan klien Cloud Storage tiruan

Library Klien Cloud untuk Rust menyediakan cara untuk melakukan stub pada implementasi klien yang sebenarnya, sehingga Anda dapat memasukkan tiruan untuk pengujian. Anda dapat menggunakan tiruan untuk menulis pengujian unit yang terkontrol dan andal yang tidak melakukan panggilan jaringan atau menimbulkan biaya yang ditagih.

Dalam dokumen ini, Anda akan mempelajari topik berikut:

  • Cara menulis antarmuka yang dapat diuji menggunakan klien Storage Cloud Storage.
  • Cara membuat tiruan operasi baca.
  • Cara membuat tiruan operasi tulis.
  • Alasan desain klien Storage berbeda dengan klien Google Cloud lainnya.

Dependensi

Tambahkan library klien Cloud Storage ke file Cargo.toml Anda:

cargo add google-cloud-storage

Untuk mengompilasi dan menjalankan pengujian unit dalam dokumen ini, tambahkan dependensi pengembangan berikut:

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

Menulis antarmuka yang dapat diuji

Jika tidak menguji unit kode penyimpanan, Anda dapat menulis antarmuka secara langsung dalam hal Storage, yang parameter jenis stub default-nya adalah implementasi produksi klien:

pub async fn my_function(_client: Storage) {}

Untuk menguji kode Anda dengan klien tiruan, tulis antarmuka Anda dalam hal parameter jenis generik T yang dibatasi oleh gcs::stub::Storage + 'static:

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

Operasi baca tiruan

Untuk membuat tiruan permintaan read_object, selesaikan langkah-langkah berikut:

  1. Tulis fungsi aplikasi yang menggunakan klien &Storage<T> generik. Misalnya, fungsi berikut mendownload objek dan menghitung jumlah karakter baris baru yang ada di dalamnya:

    // 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. Buat fungsi bantuan yang membuat aliran byte ReadObjectResponse palsu dengan meneruskan payload ke ReadObjectResponse::from_source. Metode ini menerima jenis payload yang sama dengan 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. Tentukan struct tiruan yang mengimplementasikan gcs::stub::Storage. Dokumen ini menggunakan crate mockall, meskipun Anda dapat menggunakan framework tiruan yang berbeda dalam pengujian:

    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. Tulis pengujian unit yang menetapkan ekspektasi pada tiruan, buat klien menggunakan Storage::from_stub, dan panggil 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(())
    }

Operasi tulis tiruan

Untuk membuat tiruan permintaan write_object, selesaikan langkah-langkah berikut:

  1. Tulis fungsi aplikasi yang mengupload objek menggunakan klien &Storage<T> generik:

    // 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. Dengan menggunakan struct MockStorage yang ditentukan di bagian sebelumnya, tulis pengujian unit yang mengonfigurasi ekspektasi pada tiruan, membuat klien menggunakan Storage::from_stub, dan memanggil 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(())
    }

Detail ekspektasi

Karena fungsi upload memanggil send_unbuffered, tetapkan ekspektasi pada metode expect_write_object_unbuffered yang sesuai:

mock.expect_write_object_unbuffered()

Makro mockall::mock! memperlakukan setiap pembuatan instance generik sebagai metode yang berbeda. Tentukan jenis payload konkret, Payload<BytesSource>, dalam tanda tangan closure sehingga compiler dapat mencocokkan ekspektasi metode:

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

Alasan desain

Bagian berikut menjelaskan alasan klien Storage menggunakan parameter jenis generik untuk stub-nya, bukan pengiriman dinamis.

Klien lainnya

Sebagian besar klien, seperti StorageControl, menyimpan implementasi trait stub yang dihitung referensi (Arc) dan kompatibel dengan dyn secara internal. Mereka menggunakan pengiriman dinamis untuk meneruskan permintaan dari klien ke stub yang mendasarinya, yang dapat berupa implementasi produksi atau tiruan.

Karena klien ini menggunakan pengiriman dinamis, compiler tidak perlu menyelesaikan jenis stub konkret pada waktu kompilasi, dan struct klien tidak memerlukan parameter jenis generik untuk stub-nya.

Klien Storage

Di Rust, trait yang kompatibel dengan dyn tidak boleh memiliki metode generik atau menampilkan nilai impl Future yang tidak di-unboxing:

  • Metode write_object_buffered dan write_object_unbuffered bersifat generik pada jenis sumber payload.
  • Metode trait gcs::stub::Storage menampilkan impl Future secara langsung, bukan mem-boxing future yang ditampilkan.

Menggunakan pengiriman dinamis untuk klien Storage akan memerlukan payload boxing dan future pada setiap panggilan, sehingga menambahkan alokasi heap dan overhead pengiriman dinamis pada jalur data. Untuk menghindari overhead ini, klien Storage menggunakan parameter jenis untuk implementasi konkret dari karakteristik gcs::stub::Storage.

Kode aplikasi dan suite pengujian lengkap

Kode berikut menggabungkan fungsi aplikasi dan pengujian unit menjadi contoh yang lengkap:

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(())
    }
}

Jalankan cargo test untuk mengeksekusi test_count_lines dan test_upload:

cargo test