Tests mit einem simulierten Cloud Storage-Client schreiben

Die Cloud-Clientbibliotheken für Rust bieten eine Möglichkeit, echte Clientimplementierungen zu stubben, sodass Sie ein Mock-Objekt für Tests einfügen können. Mit Mocks können Sie kontrollierte, zuverlässige Unittests schreiben, die keine Netzwerkaufrufe ausführen und keine Kosten verursachen.

In diesem Dokument werden die folgenden Themen behandelt:

  • Anleitung zum Schreiben testbarer Schnittstellen mit dem Cloud Storage-Storage-Client.
  • Lesevorgänge simulieren
  • Scheinschreibvorgänge erstellen
  • Warum sich das Design des Storage-Clients von anderen Google Cloud-Clients unterscheidet.

Abhängigkeiten

Fügen Sie die Cloud Storage-Clientbibliothek der Datei Cargo.toml hinzu:

cargo add google-cloud-storage

Fügen Sie die folgenden Entwicklungsabhängigkeiten hinzu, um die Unittests in diesem Dokument zu kompilieren und auszuführen:

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

Testbare Schnittstellen schreiben

Wenn Sie Ihren Speichercode nicht mit Unittests testen, können Sie Schnittstellen direkt in Form von Storage schreiben, dessen Standard-Stub-Typparameter die Produktionsimplementierung des Clients ist:

pub async fn my_function(_client: Storage) {}

Wenn Sie Ihren Code mit einem simulierten Client testen möchten, schreiben Sie Ihre Schnittstellen in Form eines generischen Typparameters T, der durch gcs::stub::Storage + 'static eingeschränkt ist:

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

Mock-Lesevorgänge

Führen Sie die folgenden Schritte aus, um read_object-Anfragen zu simulieren:

  1. Schreiben Sie eine Anwendungsfunktion, die einen generischen &Storage<T>-Client verwendet. Mit der folgenden Funktion wird beispielsweise ein Objekt heruntergeladen und die Anzahl der darin enthaltenen Zeilenumbruchzeichen gezählt:

    // 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. Erstellen Sie eine Hilfsfunktion, die einen gefälschten ReadObjectResponse-Bytestream erstellt, indem eine Nutzlast an ReadObjectResponse::from_source übergeben wird. Diese Methode akzeptiert dieselben Nutzlasttypen wie 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. Definieren Sie eine Mock-Struktur, die gcs::stub::Storage implementiert. In diesem Dokument wird die mockall-Crate verwendet. Sie können jedoch auch ein anderes Mocking-Framework in Ihren Tests verwenden:

    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. Schreiben Sie einen Unittest, der Erwartungen an das Mock-Objekt festlegt, einen Client mit Storage::from_stub erstellt und count_newlines aufruft:

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

Schreibvorgänge simulieren

Führen Sie die folgenden Schritte aus, um write_object-Anfragen zu simulieren:

  1. Schreiben Sie eine Anwendungsfunktion, die ein Objekt mit einem generischen &Storage<T>-Client hochlädt:

    // 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. Schreiben Sie mit der im vorherigen Abschnitt definierten Struktur MockStorage einen Unittest, der Erwartungen an das Mock-Objekt konfiguriert, einen Client mit Storage::from_stub erstellt und upload aufruft:

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

Details zur Erwartung

Da die Funktion upload die Funktion send_unbuffered aufruft, legen Sie die Erwartung für die entsprechende Methode expect_write_object_unbuffered fest:

mock.expect_write_object_unbuffered()

Das Makro mockall::mock! behandelt jede generische Instanziierung als separate Methode. Geben Sie den konkreten Nutzlasttyp Payload<BytesSource> in der Closure-Signatur an, damit der Compiler die Methodenerwartung erfüllen kann:

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

Designbegründung

In den folgenden Abschnitten wird erläutert, warum der Storage-Client einen generischen Typparameter für seinen Stub anstelle von dynamischem Dispatch verwendet.

Andere Clients

Die meisten Clients, z. B. StorageControl, enthalten intern eine referenzgezählte (Arc), dyn-kompatible Implementierung ihres Stub-Traits. Sie verwenden dynamisches Dispatching, um Anfragen vom Client an den zugrunde liegenden Stub weiterzuleiten, der entweder die Produktionsimplementierung oder ein Mock sein kann.

Da diese Clients dynamisches Dispatching verwenden, muss der Compiler den konkreten Stub-Typ nicht zur Kompilierzeit auflösen und die Client-Strukturen benötigen keinen generischen Typparameter für ihren Stub.

Der Storage-Client

In Rust kann ein dyn-kompatibles Trait keine generischen Methoden haben oder unboxed impl Future-Werte zurückgeben:

  • Die Methoden write_object_buffered und write_object_unbuffered sind generisch für den Nutzlastquelltyp.
  • Die gcs::stub::Storage-Trait-Methoden geben impl Future direkt zurück, anstatt das zurückgegebene Future zu verpacken.

Wenn Sie die dynamische Weiterleitung für den Storage-Client verwenden, müssen Sie bei jedem Aufruf Payloads und Futures verpacken, was zu Heap-Zuweisungen und dynamischem Weiterleitungs-Overhead im Datenpfad führt. Um diesen Mehraufwand zu vermeiden, verwendet der Storage-Client stattdessen einen Typparameter für eine konkrete Implementierung des gcs::stub::Storage-Traits.

Vollständiger Anwendungscode und Test-Suite

Der folgende Code kombiniert die Anwendungsfunktionen und Unit-Tests zu einem vollständigen Beispiel:

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

Führen Sie cargo test aus, um test_count_lines und test_upload auszuführen:

cargo test