Écrire des tests à l'aide d'un client Cloud Storage fictif

Les bibliothèques clientes Cloud pour Rust permettent de créer des bouchons d'implémentations clientes réelles afin que vous puissiez injecter un bouchon pour les tests. Vous pouvez utiliser des simulations pour écrire des tests unitaires contrôlés et fiables qui n'effectuent pas d'appels réseau et n'entraînent pas de frais facturés.

Dans ce document, vous allez découvrir les sujets suivants :

  • Écrire des interfaces testables à l'aide du client Storage Cloud Storage.
  • Simuler des opérations de lecture
  • Simuler des opérations d'écriture
  • Pourquoi la conception du client Storage diffère de celle des autres clients Google Cloud.

Dépendances

Ajoutez la bibliothèque cliente Cloud Storage à votre fichier Cargo.toml :

cargo add google-cloud-storage

Pour compiler et exécuter les tests unitaires de ce document, ajoutez les dépendances de développement suivantes :

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

Écrire des interfaces testables

Si vous n'effectuez pas de test unitaire de votre code de stockage, vous pouvez écrire des interfaces directement en termes de Storage, dont le paramètre de type de bouchon par défaut est l'implémentation de production du client :

pub async fn my_function(_client: Storage) {}

Pour tester votre code avec un client fictif, écrivez vos interfaces en termes de paramètre de type générique T limité par gcs::stub::Storage + 'static :

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

Simuler des opérations de lecture

Pour simuler des requêtes read_object, procédez comme suit :

  1. Écrivez une fonction d'application qui utilise un client &Storage<T> générique. Par exemple, la fonction suivante télécharge un objet et compte le nombre de caractères de nouvelle ligne qu'il contient :

    // 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. Créez une fonction d'assistance qui construit un flux d'octets ReadObjectResponse factice en transmettant une charge utile à ReadObjectResponse::from_source. Cette méthode accepte les mêmes types de charge utile que 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. Définissez une structure fictive qui implémente gcs::stub::Storage. Ce document utilise la caisse mockall, mais vous pouvez utiliser un autre framework de simulation dans vos tests :

    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. Écrivez un test unitaire qui définit les attentes sur l'objet fictif, construit un client à l'aide de Storage::from_stub et appelle 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(())
    }

Simuler des opérations d'écriture

Pour simuler des requêtes write_object, procédez comme suit :

  1. Écrivez une fonction d'application qui importe un objet à l'aide d'un client &Storage<T> générique :

    // 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. À l'aide de la structure MockStorage définie dans la section précédente, écrivez un test unitaire qui configure les attentes sur le simulacre, construit un client à l'aide de Storage::from_stub et appelle 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(())
    }

Détails de l'attente

Étant donné que la fonction upload appelle send_unbuffered, définissez l'attente sur la méthode expect_write_object_unbuffered correspondante :

mock.expect_write_object_unbuffered()

La macro mockall::mock! traite chaque instanciation générique comme une méthode distincte. Spécifiez le type de charge utile concret, Payload<BytesSource>, dans la signature de la fermeture afin que le compilateur puisse correspondre à l'attente de la méthode :

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

Justification de la conception

Les sections suivantes expliquent pourquoi le client Storage utilise un paramètre de type générique pour son bouchon plutôt qu'une distribution dynamique.

Autres clients

La plupart des clients, tels que StorageControl, contiennent en interne une implémentation compatible avec dyn de leur trait de bouchon, avec comptage de références (Arc). Ils utilisent la distribution dynamique pour transférer les requêtes du client vers le bouchon sous-jacent, qui peut être l'implémentation de production ou un bouchon.

Comme ces clients utilisent la distribution dynamique, le compilateur n'a pas besoin de résoudre le type de bouchon concret au temps de compilation, et les structures client n'ont pas besoin d'un paramètre de type générique pour leur bouchon.

Le client Storage

En Rust, un trait compatible avec dyn ne peut pas avoir de méthodes génériques ni renvoyer de valeurs impl Future non empaquetées :

  • Les méthodes write_object_buffered et write_object_unbuffered sont génériques par rapport au type de source de la charge utile.
  • Les méthodes de trait gcs::stub::Storage renvoient directement impl Future au lieu de boxer le futur renvoyé.

L'utilisation de la distribution dynamique pour le client Storage nécessiterait de boxer les charges utiles et les futures à chaque appel, ce qui ajouterait des allocations de segments de mémoire et une surcharge de distribution dynamique sur le chemin des données. Pour éviter cette surcharge, le client Storage utilise un paramètre de type pour une implémentation concrète du trait gcs::stub::Storage.

Code d'application complet et suite de tests

Le code suivant combine les fonctions d'application et les tests unitaires dans un exemple complet :

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

Exécutez cargo test pour exécuter test_count_lines et test_upload :

cargo test