Scrivi test utilizzando un client Cloud Storage fittizio

Le librerie client di Cloud per Rust forniscono un modo per eseguire lo stub delle implementazioni client reali, in modo da poter inserire un mock per i test. Puoi utilizzare i mock per scrivere test delle unità controllati e affidabili che non effettuano chiamate di rete o non comportano addebiti.

In questo documento vengono trattati i seguenti argomenti:

  • Come scrivere interfacce testabili utilizzando il client Storage di Cloud Storage.
  • Come simulare le operazioni di lettura.
  • Come simulare le operazioni di scrittura.
  • Perché il design del client Storage è diverso da quello di altri client Google Cloud.

Dipendenze

Aggiungi la libreria client di Cloud Storage al file Cargo.toml:

cargo add google-cloud-storage

Per compilare ed eseguire i test delle unità in questo documento, aggiungi le seguenti dipendenze di sviluppo:

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

Scrivi interfacce testabili

Se non esegui test delle unità del codice di archiviazione, puoi scrivere interfacce direttamente in termini di Storage, il cui parametro del tipo di stub predefinito è l'implementazione di produzione del client:

pub async fn my_function(_client: Storage) {}

Per testare il codice con un client fittizio, scrivi le interfacce in termini di un parametro di tipo generico T vincolato da gcs::stub::Storage + 'static:

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

Operazioni di lettura fittizie

Per simulare le richieste read_object, completa i seguenti passaggi:

  1. Scrivi una funzione dell'applicazione che accetti un client &Storage<T> generico. Ad esempio, la seguente funzione scarica un oggetto e conta quanti caratteri di nuova riga contiene:

    // 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. Crea una funzione helper che costruisce un flusso di byte ReadObjectResponse fittizio passando un payload a ReadObjectResponse::from_source. Questo metodo accetta gli stessi tipi di payload di 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. Definisci una struttura fittizia che implementa gcs::stub::Storage. Questo documento utilizza il crate mockall, anche se puoi utilizzare un framework di simulazione diverso nei test:

    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. Scrivi un test delle unità che imposti le aspettative sul mock, costruisca un client utilizzando Storage::from_stub e chiami 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(())
    }

Operazioni di scrittura fittizie

Per simulare le richieste write_object, completa i seguenti passaggi:

  1. Scrivi una funzione dell'applicazione che carica un oggetto utilizzando un client &Storage<T> generico:

    // 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. Utilizzando la struttura MockStorage definita nella sezione precedente, scrivi un test delle unità che configura le aspettative sul mock, costruisce un client utilizzando Storage::from_stub e chiama 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(())
    }

Dettagli sulle aspettative

Poiché la funzione upload chiama send_unbuffered, imposta l'aspettativa sul metodo expect_write_object_unbuffered corrispondente:

mock.expect_write_object_unbuffered()

La macro mockall::mock! tratta ogni istanza generica come un metodo distinto. Specifica il tipo di payload concreto, Payload<BytesSource>, nella firma della chiusura in modo che il compilatore possa corrispondere all'aspettativa del metodo:

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

Motivazione del design

Le sezioni seguenti spiegano perché il client Storage utilizza un parametro di tipo generico per il relativo stub anziché l'invio dinamico.

Altri client

La maggior parte dei client, come StorageControl, contiene internamente un'implementazione compatibile con dyn del proprio tratto stub con conteggio dei riferimenti (Arc). Utilizzano l'invio dinamico per inoltrare le richieste dal client allo stub sottostante, che può essere l'implementazione di produzione o un mock.

Poiché questi client utilizzano l'invio dinamico, il compilatore non deve risolvere il tipo di stub concreto in tempo di compilazione e le strutture client non hanno bisogno di un parametro di tipo generico per il loro stub.

Il client Storage

In Rust, un tratto compatibile con dyn non può avere metodi generici o restituire valori impl Future non unboxed:

  • I metodi write_object_buffered e write_object_unbuffered sono generici per il tipo di origine del payload.
  • I metodi del tratto gcs::stub::Storage restituiscono direttamente impl Future anziché eseguire il boxing del future restituito.

L'utilizzo del dynamic dispatch per il client Storage richiederebbe il boxing dei payload e dei future su ogni chiamata, aggiungendo allocazioni heap e overhead del dynamic dispatch sul percorso dei dati. Per evitare questo overhead, il client Storage utilizza invece un parametro di tipo per un'implementazione concreta del tratto gcs::stub::Storage.

Codice dell'applicazione e suite di test completi

Il seguente codice combina le funzioni dell'applicazione e i test delle unità in un esempio completo:

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

Esegui cargo test per eseguire test_count_lines e test_upload:

cargo test