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
127 changes: 113 additions & 14 deletions docs/cookbook/src/crates/rustapi_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,126 @@
**Lens**: "The Auditor"
**Philosophy**: "Trust, but verify."

`rustapi-testing` provides a comprehensive suite of tools for integration testing your RustAPI applications. It focuses on two main areas:
1. **In-process API testing**: Testing your endpoints without binding to a real TCP port.
2. **External service mocking**: Mocking downstream services (like payment gateways or auth providers) that your API calls.

## The `TestClient`

Integration testing is often painful. We make it easy. `TestClient` spawns your `RustApi` application without binding to a real TCP port, communicating directly with the service layer.
Integration testing is often slow and painful because it involves spinning up a server, waiting for ports, and managing child processes. `TestClient` solves this by wrapping your `RustApi` application and executing requests directly against the service layer.

### Basic Usage

```rust,ignore
use rustapi_rs::prelude::*;
use rustapi_testing::TestClient;

#[tokio::test]
async fn test_hello_world() {
let app = RustApi::new().route("/", get(|| async { "Hello!" }));
let client = TestClient::new(app);

```rust
let client = TestClient::new(app);
let response = client.get("/").await;

response
.assert_status(200)
.assert_body_contains("Hello!");
}
```

## Fluent Assertions
### Testing JSON APIs

The client provides fluent helpers for JSON APIs.

```rust,ignore
#[derive(Serialize)]
struct CreateUser {
username: String,
}

#[tokio::test]
async fn test_create_user() {
let app = RustApi::new().route("/users", post(create_user_handler));
let client = TestClient::new(app);

let response = client.post_json("/users", &CreateUser {
username: "alice".into()
}).await;

response
.assert_status(201)
.assert_json(&serde_json::json!({
"id": 1,
"username": "alice"
}));
}
```

## Mocking Services with `MockServer`

Real-world applications usually talk to other services. `MockServer` allows you to spin up a lightweight HTTP server that responds to requests based on pre-defined expectations.

### Setting up a Mock Server

```rust,ignore
use rustapi_testing::{MockServer, MockResponse, RequestMatcher};

#[tokio::test]
Comment on lines +67 to +70
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This snippet uses TestClient, Method, and StatusCode without importing them. Consider either adding the missing use statements (e.g. use rustapi_testing::TestClient; and use http::Method;) or qualifying them (http::Method::GET, etc.) to keep the example copy/pasteable.

Copilot uses AI. Check for mistakes.
async fn test_external_integration() {
// 1. Start the mock server
let server = MockServer::start().await;

// 2. Define an expectation
server.expect(RequestMatcher::new(Method::GET, "/external-api/data"))
.respond_with(MockResponse::new()
.status(StatusCode::OK)
.json(serde_json::json!({ "result": "success" })))
.times(1);
Comment on lines +76 to +80
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RequestMatcher::new(Method::GET, "/external-api/data") doesn't match the actual rustapi-testing API: RequestMatcher::new() takes no args, and method/path are set via .method(...) / .path(...). Also Method isn’t brought into scope here (the rustapi_rs::prelude doesn’t re-export it), so the example as written won’t compile for readers.

Copilot uses AI. Check for mistakes.

// 3. Configure your app to use the mock server's URL
let app = create_app_with_config(Config {
external_api_url: server.base_url(),
});

let client = TestClient::new(app);

// 4. Run your test
client.get("/my-endpoint-calling-external").await.assert_status(200);
}
```

### Expectations

You can define strict expectations on how your application interacts with the mock server.

#### Matching Requests

`RequestMatcher` allows matching by method, path, headers, and body.

```rust,ignore
// Match a POST request with specific body
server.expect(RequestMatcher::new(Method::POST, "/webhook")
.body_string("event_type=payment_success".into()))
.respond_with(MockResponse::new().status(StatusCode::OK));
```

#### Verification

The `MockServer` automatically verifies that all expectations were met when it is dropped (at the end of the test scope). If an expectation was set to be called `once` but was never called, the test will panic.

The client provides a fluent API for making requests and asserting responses.
- `.once()`: Must be called exactly once (default).
- `.times(n)`: Must be called exactly `n` times.
- `.at_least_once()`: Must be called 1 or more times.
- `.never()`: Must not be called.
Comment on lines +111 to +116
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs say expectations are automatically verified when MockServer is dropped, but MockServer’s Drop implementation only shuts the server down and does not call verify(). Unless the crate behavior is changed, the docs should instruct callers to explicitly call server.verify() at the end of the test (or assert on unmatched_requests()).

Copilot uses AI. Check for mistakes.

```rust
client.post("/login")
.json(&credentials)
.send()
.await
.assert_status(200)
.assert_header("Set-Cookie", "session=...");
```rust,ignore
// Ensure we don't call the billing API if validation fails
server.expect(RequestMatcher::new(Method::POST, "/charge"))
.never();
```

## Mocking Services
## Best Practices

Because `rustapi-rs` relies heavily on Dependency Injection via `State<T>`, you can easily inject mock implementations of your database or downstream services when creating the `RustApi` instance for your test.
1. **Dependency Injection**: Design your application `State` to accept base URLs for external services so you can inject the `MockServer` URL during tests.
2. **Isolation**: Create a new `MockServer` for each test case to ensure no shared state or interference.
3. **Fluent Assertions**: Use the chainable assertion methods on `TestResponse` to keep tests readable.
5 changes: 4 additions & 1 deletion docs/cookbook/src/learning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ Design and build distributed systems with RustAPI.
| 3 | `rate-limit-demo` | API protection, throttling |
| 4 | `microservices` | Service communication patterns |
| 5 | `microservices-advanced` | Service discovery, Consul integration |
| 6 | Background jobs (conceptual) | Background processing with `rustapi-jobs`, Redis/Postgres backends |
| 6 | Service Mocking | Testing microservices with `MockServer` from `rustapi-testing` |
| 7 | Background jobs (conceptual) | Background processing with `rustapi-jobs`, Redis/Postgres backends |

> Note: The **Background jobs (conceptual)** step refers to using the `rustapi-jobs` crate rather than a standalone example project.
**Related Cookbook Recipes:**
Expand Down Expand Up @@ -122,8 +123,10 @@ Build robust, observable, and secure systems.
| 4 | **Optimization** | Configure [Caching and Deduplication](../crates/rustapi_extras.md#optimization) |
| 5 | **Background Jobs** | Implement [Reliable Job Queues](../crates/rustapi_jobs.md) |
| 6 | **Debugging** | Set up [Time-Travel Debugging](../recipes/replay.md) |
| 7 | **Reliable Testing** | Master [Mocking and Integration Testing](../crates/rustapi_testing.md) |

**Related Cookbook Recipes:**
- [rustapi-testing: The Auditor](../crates/rustapi_testing.md)
- [rustapi-extras: The Toolbox](../crates/rustapi_extras.md)
- [Time-Travel Debugging](../recipes/replay.md)
- [rustapi-jobs: The Workhorse](../crates/rustapi_jobs.md)
Expand Down
2 changes: 1 addition & 1 deletion docs/cookbook/src/recipes/db_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This recipe shows how to integrate PostgreSQL/MySQL/SQLite using a global connec

```toml
[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
dotenvy = "0.15"
Expand Down
Loading