כתיבת בדיקות באמצעות לקוח מדומה של Cloud Storage

ספריות הלקוח של Cloud ל-Rust מספקות דרך ליצור סטאבים של הטמעות לקוח אמיתיות, כדי שתוכלו להחדיר מוק לבדיקה. אתם יכולים להשתמש ב-mocks כדי לכתוב בדיקות יחידה מבוקרות ואמינות שלא מבצעות קריאות לרשת או צוברות חיובים.

במאמר הזה נסביר על הנושאים הבאים:

  • איך לכתוב ממשקים שניתנים לבדיקה באמצעות לקוח Storage של Cloud 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, שפרמטר ברירת המחדל של סוג ה-stub שלו הוא הטמעת ה-production של הלקוח:

pub async fn my_function(_client: Storage) {}

כדי לבדוק את הקוד באמצעות לקוח מדומה, כותבים את הממשקים במונחים של פרמטר מסוג גנרי T שמוגבל על ידי gcs::stub::Storage + 'static:

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 על ידי העברת מטען ייעודי (payload) אל ReadObjectResponse::from_source. השיטה הזו מקבלת את אותם סוגי מטען ייעודי (payload) כמו 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. במסמך הזה אנחנו משתמשים ב-crate‏ 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. באמצעות ה-struct‏ MockStorage שהוגדר בקטע הקודם, כותבים בדיקת יחידה שמגדירה את הציפיות ב-mock, יוצרת לקוח באמצעות 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, מגדירים את הציפייה ב-method‏ expect_write_object_unbuffered התואם:

mock.expect_write_object_unbuffered()

הפקודה mockall::mock! מתייחסת לכל יצירת מופע גנרי כשיטה נפרדת. מציינים את סוג המטען הייעודי (payload) הקונקרטי, Payload<BytesSource>, בחתימת הסגירה כדי שהקומפיילר יוכל להתאים לציפייה של השיטה:

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

הסבר לגבי העיצוב

בקטעים הבאים מוסבר למה הלקוח של Storage משתמש בפרמטר מסוג גנרי ל-stub שלו, במקום ב-dynamic dispatch.

לקוחות אחרים

רוב הלקוחות, כמו StorageControl, מחזיקים באופן פנימי בהטמעה תואמת-dyn של מאפיין ה-stub שלהם, שנספרת לפי קובץ עזר (Arc). הם משתמשים ב-dynamic dispatch כדי להעביר בקשות מהלקוח ל-stub הבסיסי, שיכול להיות הטמעה בסביבת הייצור או מדומה.

מכיוון שהלקוחות האלה משתמשים בהקצאה דינמית, המקמפל לא צריך לפתור את סוג ה-stub הקונקרטי בזמן הקימפול, והמבנים של הלקוח לא צריכים פרמטר של סוג גנרי בשביל ה-stub שלהם.

הלקוח Storage

ב-Rust, למאפיין שתואם ל-dyn לא יכולות להיות שיטות גנריות או להחזיר ערכי impl Future לא ארוזים:

  • השיטות write_object_buffered ו-write_object_unbuffered הן כלליות לגבי סוג המקור של המטען הייעודי (payload).
  • ה-methods של המאפיין gcs::stub::Storage מחזירות את impl Future ישירות במקום להכניס את ה-future המוחזר ל-box.

כדי להשתמש בהקצאה דינמית ללקוח Storage, צריך להוסיף מטען ייעודי (payload) ו-futures לכל קריאה, וגם להוסיף הקצאות זיכרון בערימה (heap allocation) ותקורה של הקצאה דינמית בנתיב הנתונים. כדי להימנע מהתקורה הזו, הלקוח 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