Escrever testes usando um cliente simulado do Cloud Storage

As bibliotecas de cliente do Cloud para Rust oferecem uma maneira de criar stubs de implementações de cliente reais para que você possa injetar uma simulação para testes. É possível usar simulações para escrever testes de unidade controlados e confiáveis que não fazem chamadas de rede nem geram cobranças.

Neste documento, você vai aprender sobre os seguintes tópicos:

  • Como escrever interfaces testáveis usando o cliente Storage do Cloud Storage.
  • Como simular operações de leitura.
  • Como simular operações de gravação.
  • Por que o design do cliente do Storage é diferente de outros clientes do Google Cloud.

Dependências

Adicione a biblioteca de cliente do Cloud Storage ao arquivo Cargo.toml:

cargo add google-cloud-storage

Para compilar e executar os testes de unidade neste documento, adicione as seguintes dependências de desenvolvimento:

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

Criar interfaces testáveis

Se você não fizer testes de unidade no código de armazenamento, poderá escrever interfaces diretamente em termos de Storage, cujo parâmetro de tipo de stub padrão é a implementação de produção do cliente:

pub async fn my_function(_client: Storage) {}

Para testar seu código com um cliente simulado, escreva suas interfaces em termos de um parâmetro de tipo genérico T restrito por gcs::stub::Storage + 'static:

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

Simular operações de leitura

Para simular solicitações de read_object, siga estas etapas:

  1. Escreva uma função de aplicativo que use um cliente &Storage<T> genérico. Por exemplo, a função a seguir faz o download de um objeto e conta quantos caracteres de nova linha ele contém:

    // 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. Crie uma função auxiliar que construa um fluxo de bytes ReadObjectResponse falso transmitindo um payload para ReadObjectResponse::from_source. Esse método aceita os mesmos tipos de payload 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. Defina uma struct simulada que implemente gcs::stub::Storage. Este documento usa o crate mockall, mas é possível usar um framework de simulação diferente nos testes:

    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. Escreva um teste de unidade que defina as expectativas no simulador, construa um cliente usando Storage::from_stub e chame 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(())
    }

Simular operações de gravação

Para simular solicitações de write_object, siga estas etapas:

  1. Escreva uma função de aplicativo que faça upload de um objeto usando um 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. Usando o struct MockStorage definido na seção anterior, escreva um teste de unidade que configure as expectativas no simulador, construa um cliente usando Storage::from_stub e chame 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(())
    }

Detalhes da expectativa

Como a função upload chama send_unbuffered, defina a expectativa no método expect_write_object_unbuffered correspondente:

mock.expect_write_object_unbuffered()

A macro mockall::mock! trata cada instanciação genérica como um método distinto. Especifique o tipo de payload concreto, Payload<BytesSource>, na assinatura de fechamento para que o compilador possa corresponder à expectativa do método:

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

Justificativa do design

As seções a seguir explicam por que o cliente Storage usa um parâmetro de tipo genérico para o stub em vez de envio dinâmico.

Outros clientes

A maioria dos clientes, como StorageControl, mantém internamente uma implementação compatível com dyn e com contagem de referências (Arc) do trait de stub. Eles usam a expedição dinâmica para encaminhar solicitações do cliente para o stub subjacente, que pode ser a implementação de produção ou um modelo.

Como esses clientes usam o envio dinâmico, o compilador não precisa resolver o tipo de stub concreto no tempo de compilação, e as structs do cliente não precisam de um parâmetro de tipo genérico para o stub.

O cliente Storage

No Rust, uma característica compatível com dyn não pode ter métodos genéricos ou retornar valores impl Future não empacotados:

  • Os métodos write_object_buffered e write_object_unbuffered são genéricos em relação ao tipo de origem do payload.
  • Os métodos de característica gcs::stub::Storage retornam impl Future diretamente, em vez de encapsular o futuro retornado.

O uso de despacho dinâmico para o cliente Storage exigiria o boxing de payloads e futuros em cada chamada, adicionando alocações de heap e sobrecarga de despacho dinâmico no caminho de dados. Para evitar essa sobrecarga, o cliente Storage usa um parâmetro de tipo para uma implementação concreta da característica gcs::stub::Storage.

Código do aplicativo e pacote de testes completos

O código a seguir combina as funções do aplicativo e os testes de unidade em um exemplo 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(())
    }
}

Execute cargo test para executar test_count_lines e test_upload:

cargo test