|
3 | 3 | **Lens**: "The Auditor" |
4 | 4 | **Philosophy**: "Trust, but verify." |
5 | 5 |
|
| 6 | +`rustapi-testing` provides a comprehensive suite of tools for integration testing your RustAPI applications. It focuses on two main areas: |
| 7 | +1. **In-process API testing**: Testing your endpoints without binding to a real TCP port. |
| 8 | +2. **External service mocking**: Mocking downstream services (like payment gateways or auth providers) that your API calls. |
| 9 | + |
6 | 10 | ## The `TestClient` |
7 | 11 |
|
8 | | -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. |
| 12 | +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. |
| 13 | + |
| 14 | +### Basic Usage |
| 15 | + |
| 16 | +```rust,ignore |
| 17 | +use rustapi_rs::prelude::*; |
| 18 | +use rustapi_testing::TestClient; |
| 19 | +
|
| 20 | +#[tokio::test] |
| 21 | +async fn test_hello_world() { |
| 22 | + let app = RustApi::new().route("/", get(|| async { "Hello!" })); |
| 23 | + let client = TestClient::new(app); |
9 | 24 |
|
10 | | -```rust |
11 | | -let client = TestClient::new(app); |
| 25 | + let response = client.get("/").await; |
| 26 | +
|
| 27 | + response |
| 28 | + .assert_status(200) |
| 29 | + .assert_body_contains("Hello!"); |
| 30 | +} |
12 | 31 | ``` |
13 | 32 |
|
14 | | -## Fluent Assertions |
| 33 | +### Testing JSON APIs |
| 34 | + |
| 35 | +The client provides fluent helpers for JSON APIs. |
| 36 | + |
| 37 | +```rust,ignore |
| 38 | +#[derive(Serialize)] |
| 39 | +struct CreateUser { |
| 40 | + username: String, |
| 41 | +} |
| 42 | +
|
| 43 | +#[tokio::test] |
| 44 | +async fn test_create_user() { |
| 45 | + let app = RustApi::new().route("/users", post(create_user_handler)); |
| 46 | + let client = TestClient::new(app); |
| 47 | +
|
| 48 | + let response = client.post_json("/users", &CreateUser { |
| 49 | + username: "alice".into() |
| 50 | + }).await; |
| 51 | +
|
| 52 | + response |
| 53 | + .assert_status(201) |
| 54 | + .assert_json(&serde_json::json!({ |
| 55 | + "id": 1, |
| 56 | + "username": "alice" |
| 57 | + })); |
| 58 | +} |
| 59 | +``` |
| 60 | + |
| 61 | +## Mocking Services with `MockServer` |
| 62 | + |
| 63 | +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. |
| 64 | + |
| 65 | +### Setting up a Mock Server |
| 66 | + |
| 67 | +```rust,ignore |
| 68 | +use rustapi_testing::{MockServer, MockResponse, RequestMatcher}; |
| 69 | +
|
| 70 | +#[tokio::test] |
| 71 | +async fn test_external_integration() { |
| 72 | + // 1. Start the mock server |
| 73 | + let server = MockServer::start().await; |
| 74 | +
|
| 75 | + // 2. Define an expectation |
| 76 | + server.expect(RequestMatcher::new(Method::GET, "/external-api/data")) |
| 77 | + .respond_with(MockResponse::new() |
| 78 | + .status(StatusCode::OK) |
| 79 | + .json(serde_json::json!({ "result": "success" }))) |
| 80 | + .times(1); |
| 81 | +
|
| 82 | + // 3. Configure your app to use the mock server's URL |
| 83 | + let app = create_app_with_config(Config { |
| 84 | + external_api_url: server.base_url(), |
| 85 | + }); |
| 86 | +
|
| 87 | + let client = TestClient::new(app); |
| 88 | +
|
| 89 | + // 4. Run your test |
| 90 | + client.get("/my-endpoint-calling-external").await.assert_status(200); |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +### Expectations |
| 95 | + |
| 96 | +You can define strict expectations on how your application interacts with the mock server. |
| 97 | + |
| 98 | +#### Matching Requests |
| 99 | + |
| 100 | +`RequestMatcher` allows matching by method, path, headers, and body. |
| 101 | + |
| 102 | +```rust,ignore |
| 103 | +// Match a POST request with specific body |
| 104 | +server.expect(RequestMatcher::new(Method::POST, "/webhook") |
| 105 | + .body_string("event_type=payment_success".into())) |
| 106 | + .respond_with(MockResponse::new().status(StatusCode::OK)); |
| 107 | +``` |
| 108 | + |
| 109 | +#### Verification |
| 110 | + |
| 111 | +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. |
15 | 112 |
|
16 | | -The client provides a fluent API for making requests and asserting responses. |
| 113 | +- `.once()`: Must be called exactly once (default). |
| 114 | +- `.times(n)`: Must be called exactly `n` times. |
| 115 | +- `.at_least_once()`: Must be called 1 or more times. |
| 116 | +- `.never()`: Must not be called. |
17 | 117 |
|
18 | | -```rust |
19 | | -client.post("/login") |
20 | | - .json(&credentials) |
21 | | - .send() |
22 | | - .await |
23 | | - .assert_status(200) |
24 | | - .assert_header("Set-Cookie", "session=..."); |
| 118 | +```rust,ignore |
| 119 | +// Ensure we don't call the billing API if validation fails |
| 120 | +server.expect(RequestMatcher::new(Method::POST, "/charge")) |
| 121 | + .never(); |
25 | 122 | ``` |
26 | 123 |
|
27 | | -## Mocking Services |
| 124 | +## Best Practices |
28 | 125 |
|
29 | | -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. |
| 126 | +1. **Dependency Injection**: Design your application `State` to accept base URLs for external services so you can inject the `MockServer` URL during tests. |
| 127 | +2. **Isolation**: Create a new `MockServer` for each test case to ensure no shared state or interference. |
| 128 | +3. **Fluent Assertions**: Use the chainable assertion methods on `TestResponse` to keep tests readable. |
0 commit comments