Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions guide/samples/tests/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.

pub mod storage {
pub mod mocking;
pub mod queue;
pub mod quickstart;
pub mod rewrite_object;
Expand Down
175 changes: 175 additions & 0 deletions guide/samples/tests/storage/mocking.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// ANCHOR: all
use gcs::client::Storage;
use gcs::model::Object;
use google_cloud_storage as gcs;

// ANCHOR_END: all
// ANCHOR: prod-only-interface
pub async fn my_function(_client: Storage) {}
// ANCHOR_END: prod-only-interface

// ANCHOR: testable-interface
pub async fn my_testable_function<T>(_client: Storage<T>)
where
T: gcs::stub::Storage + 'static,
{
}
// ANCHOR_END: testable-interface

// ANCHOR: all
// ANCHOR: count-newlines
// 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)
}
// ANCHOR_END: count-newlines

// ANCHOR: upload
// 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
}
// ANCHOR_END: upload

#[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;

// ANCHOR: mockall
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>;
}
}
// ANCHOR_END: mockall

// ANCHOR: fake-read-object-resp
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))
}
// ANCHOR_END: fake-read-object-resp

// ANCHOR: test-count-lines
#[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(())
}
// ANCHOR_END: test-count-lines

// ANCHOR: test-upload
#[tokio::test]
async fn test_upload() -> anyhow::Result<()> {
let mut mock = MockStorage::new();
// ANCHOR: expect-unbuffered
mock.expect_write_object_unbuffered()
// ANCHOR_END: expect-unbuffered
.return_once(
// ANCHOR: explicit-payload-type
|_payload: Payload<BytesSource>, r, _| {
// ANCHOR_END: explicit-payload-type
// 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(())
}
// ANCHOR_END: test-upload
}
// ANCHOR_END: all
1 change: 1 addition & 0 deletions guide/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ limitations under the License.
- [Rewriting objects](storage/rewrite_object.md)
- [Speed up large object downloads](storage/striped_downloads.md)
- [Use errors to terminate uploads](storage/terminate_uploads.md)
- [How to write tests using the Storage client](storage/mocking.md)
- [Update a resource using a field mask](update_resource.md)
- [Configuring retry policies](configuring_retry_policies.md)
- [Error handling](error_handling.md)
Expand Down
170 changes: 170 additions & 0 deletions guide/src/storage/mocking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<!--
Copyright 2025 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

# How to write tests using the Storage client

The Google Cloud Client Libraries for Rust provide a way to stub out the real
client implementations, so a mock can be injected for testing.

Applications can use mocks to write controlled, reliable unit tests that do not
involve network calls, and do not incur billing.

In this guide, you will learn:

- How to write testable interfaces using the `Storage` client
- How to mock reads
- How to mock writes
- Why the design of the `Storage` client deviates from the design of other
Google Cloud clients

This guide is specifically for mocking the `Storage` client. For a generic
mocking guide (which applies to the `StorageControl` client), see
[How to write tests using a client](../mock_a_client.md).

## Testable interfaces

Applications that do not need to test their code can simply write all interfaces
in terms of `Storage`. The default `T` is the real implementation of the client.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:prod-only-interface}}
```

Applications that need to test their code should write their interfaces in terms
of the generic `T`, with the appropriate constraints.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:testable-interface}}
```

## Mocking reads

This section of the guide will show you how to mock `read_object` requests.

Let's say you have an application function which downloads an object and counts
how many newlines it contains.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:count-newlines}}
```

You want to test your code against a known response from the server. You can do
this by faking the `ReadObjectResponse`.

A `ReadObjectResponse` is essentially a stream of bytes. You can create a fake
`ReadObjectResponse` in tests by supplying a payload to
`ReadObjectResponse::from_source`. The library accepts the same payload types as
`Storage::write_object`.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:fake-read-object-resp}}
```

To return the fake response, you need to mock the client.

This guide uses the `mockall` crate to create a mock. You can use a different
mocking framework in your tests.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:mockall}}
```

You are then ready to write a unit test, which calls into your `count_newlines`
function.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:test-count-lines}}
```

## Mocking writes

This section of the guide will show you how to mock `write_object` requests.

Let's say you have an application function which uploads an object from memory.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:upload}}
```

To test this function, you need to mock the client.

This guide uses the `mockall` crate to create a mock. You can use a different
mocking framework in your tests.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:mockall}}
```

You are then ready to write a unit test, which calls into your `upload`
function.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:test-upload}}
```

### Details

Because your function calls `send_unbuffered()`, you should use the
corresponding `write_object_unbuffered()`.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:expect-unbuffered}}
```

Generics in `mockall::mock!` are treated as different functions. You need to
provide the exact payload type, so the compiler knows which function to use.

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:explicit-payload-type}}
```

## Design rationale

### Other clients

Most clients, such as `StorageControl` hold a boxed, `dyn`-compatible
implementation of the stub trait internally. They use dynamic dispatch to
forward requests from the client to their stub (which could be the real
implementation or a mock).

Because these clients use dynamic dispatch, the exact type of the stub does not
need to be known by the compiler. The clients do not need to be generic on their
stub type.

### `Storage` client

In order to have a `dyn`-compatible trait, the size of all types must be known.

The `Storage` client has complex types in its interfaces.

- `write_object` accepts a generic payload.
- `read_object` returns a stream-like thing.

Thus, if we wanted to use the same dynamic dispatch approach for the `Storage`
client, we would have to end up boxing all generics / trait `impl`s. Each box is
an extra heap allocation, plus the dynamic dispatch.

Because we want the `Storage` client to be as performant as possible, we decided
it was preferable to template the client on a non-`dyn`-compatible, concrete
implementation of the stub trait.

______________________________________________________________________

## Full application code and test suite

```rust,ignore,noplayground
{{#rustdoc_include ../../samples/tests/storage/mocking.rs:all}}
```
Loading