diff --git a/docs/cookbook/src/crates/rustapi_testing.md b/docs/cookbook/src/crates/rustapi_testing.md index 46418dad..b500dbd2 100644 --- a/docs/cookbook/src/crates/rustapi_testing.md +++ b/docs/cookbook/src/crates/rustapi_testing.md @@ -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] +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); + + // 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. -```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`, 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. diff --git a/docs/cookbook/src/learning/README.md b/docs/cookbook/src/learning/README.md index a1c4f229..ff8471d6 100644 --- a/docs/cookbook/src/learning/README.md +++ b/docs/cookbook/src/learning/README.md @@ -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:** @@ -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) diff --git a/docs/cookbook/src/recipes/db_integration.md b/docs/cookbook/src/recipes/db_integration.md index 4b3d6fcf..804829ff 100644 --- a/docs/cookbook/src/recipes/db_integration.md +++ b/docs/cookbook/src/recipes/db_integration.md @@ -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"