Escribe pruebas con un cliente de Cloud Storage simulado

Las bibliotecas cliente de Cloud para Rust proporcionan una forma de crear stub de implementaciones de clientes reales para que puedas insertar una simulación para las pruebas. Puedes usar simulaciones para escribir pruebas de unidades controladas y confiables que no realicen llamadas de red ni generen cargos facturados.

En este documento, aprenderás sobre los siguientes temas:

  • Cómo escribir interfaces que se puedan probar con el cliente de Storage de Cloud Storage.
  • Cómo simular operaciones de lectura
  • Cómo simular operaciones de escritura
  • Por qué el diseño del cliente de Storage difiere de otros clientes de Google Cloud

Dependencias

Agrega la biblioteca cliente de Cloud Storage a tu archivo Cargo.toml:

cargo add google-cloud-storage

Para compilar y ejecutar las pruebas de unidades de este documento, agrega las siguientes dependencias de desarrollo:

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

Escribe interfaces que se puedan probar

Si no realizas pruebas de unidades en tu código de almacenamiento, puedes escribir interfaces directamente en términos de Storage, cuyo parámetro de tipo de stub predeterminado es la implementación de producción del cliente:

pub async fn my_function(_client: Storage) {}

Para probar tu código con un cliente simulado, escribe tus interfaces en términos de un parámetro de tipo genérico T restringido por gcs::stub::Storage + 'static:

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

Simula operaciones de lectura

Para simular solicitudes de read_object, completa los siguientes pasos:

  1. Escribe una función de aplicación que tome un cliente &Storage<T> genérico. Por ejemplo, la siguiente función descarga un objeto y cuenta cuántos caracteres de línea nueva 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 función auxiliar que construya un flujo de bytes ReadObjectResponse falso pasando una carga útil a ReadObjectResponse::from_source. Este método acepta los mismos tipos de carga útil 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. Define una estructura simulada que implemente gcs::stub::Storage. En este documento, se usa la caja mockall, aunque puedes usar un framework de simulación diferente en tus pruebas:

    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. Escribe una prueba de unidades que establezca expectativas en la simulación, cree un cliente con Storage::from_stub y llame a 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(())
    }

Simula operaciones de escritura

Para simular solicitudes de write_object, completa los siguientes pasos:

  1. Escribe una función de aplicación que suba un objeto con un cliente &Storage<T> genérico:

    // 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. Con el struct MockStorage definido en la sección anterior, escribe una prueba de unidades que configure las expectativas en el simulacro, cree un cliente con Storage::from_stub y llame a 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(())
    }

Detalles de las expectativas

Debido a que la función upload llama a send_unbuffered, establece la expectativa en el método expect_write_object_unbuffered correspondiente:

mock.expect_write_object_unbuffered()

La macro mockall::mock! trata cada creación de instancias genérica como un método distinto. Especifica el tipo de carga útil concreto, Payload<BytesSource>, en la firma de cierre para que el compilador pueda coincidir con la expectativa del método:

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

Justificación del diseño

En las siguientes secciones, se explica por qué el cliente Storage usa un parámetro de tipo genérico para su stub en lugar del envío dinámico.

Otros clientes

La mayoría de los clientes, como StorageControl, contienen una implementación compatible con dyn con recuento de referencias (Arc) de su rasgo de stub de forma interna. Usan el envío dinámico para reenviar solicitudes del cliente al stub subyacente, que puede ser la implementación de producción o una simulación.

Debido a que estos clientes usan el envío dinámico, el compilador no necesita resolver el tipo de stub concreto en el tiempo de compilación, y los structs del cliente no necesitan un parámetro de tipo genérico para su stub.

El cliente de Storage

En Rust, un atributo compatible con dyn no puede tener métodos genéricos ni devolver valores impl Future sin empaquetar:

  • Los métodos write_object_buffered y write_object_unbuffered son genéricos en el tipo de fuente de carga útil.
  • Los métodos de atributo gcs::stub::Storage devuelven impl Future directamente en lugar de empaquetar el futuro devuelto.

El uso del envío dinámico para el cliente Storage requeriría cargas útiles de boxing y futuros en cada llamada, lo que agregaría asignaciones de montón y sobrecarga de envío dinámico en la ruta de acceso de los datos. Para evitar esta sobrecarga, el cliente Storage usa un parámetro de tipo para una implementación concreta del rasgo gcs::stub::Storage.

Código de la aplicación y paquete de pruebas completos

En el siguiente código, se combinan las funciones de la aplicación y las pruebas de unidades en un ejemplo 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(())
    }
}

Ejecuta cargo test para ejecutar test_count_lines y test_upload:

cargo test