pub struct TestClient<T> { /* private fields */ }
Expand description
A generic test client for async server testing.
TestClient<T>
provides a framework-agnostic way to test web servers by wrapping
any server implementation that implements the TestServer
trait. It manages
server lifecycle, health checking, and provides convenient access to the underlying
ApiClient
for making requests and generating OpenAPI specifications.
§Type Parameters
T
- The server type that implementsTestServer
§Features
- Server Lifecycle Management: Automatically starts and stops the server
- Health Checking: Waits for server to be ready before returning success
- OpenAPI Generation: Collects API calls and generates OpenAPI specifications
- Deref to ApiClient: Direct access to all
ApiClient
methods
§Examples
§Basic Usage
use clawspec_core::test_client::{TestClient, TestServer};
use std::net::TcpListener;
#[derive(Debug)]
struct MyServer;
impl TestServer for MyServer {
type Error = std::io::Error;
async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
listener.set_nonblocking(true)?;
let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
// Start your server here
Ok(())
}
}
#[tokio::test]
async fn test_api() -> Result<(), Box<dyn std::error::Error>> {
let mut client = TestClient::start(MyServer).await?;
let response = client.get("/users")?.await?.as_raw().await?;
assert_eq!(response.status_code(), http::StatusCode::OK);
Ok(())
}
§With Custom Configuration
use clawspec_core::{test_client::{TestClient, TestServer, TestServerConfig}, ApiClient};
use std::{net::TcpListener, time::Duration};
#[derive(Debug)]
struct MyServer;
impl TestServer for MyServer {
type Error = std::io::Error;
async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
// Server implementation
listener.set_nonblocking(true)?;
let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
Ok(())
}
fn config(&self) -> TestServerConfig {
TestServerConfig {
api_client: Some(
ApiClient::builder()
.with_host("localhost")
.with_base_path("/api/v1").unwrap()
),
min_backoff_delay: Duration::from_millis(25),
max_backoff_delay: Duration::from_secs(2),
backoff_jitter: true,
max_retry_attempts: 15,
}
}
}
#[tokio::test]
async fn test_with_config() -> Result<(), Box<dyn std::error::Error>> {
let mut client = TestClient::start(MyServer).await?;
// Client is already configured with base path /api/v1
let response = client.get("/users")?.await?; // Calls /api/v1/users
Ok(())
}
§Generating OpenAPI Documentation
use clawspec_core::test_client::{TestClient, TestServer};
use std::net::TcpListener;
#[derive(Debug)]
struct MyServer;
impl TestServer for MyServer {
type Error = std::io::Error;
async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
listener.set_nonblocking(true)?;
let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
// Server implementation
Ok(())
}
}
#[tokio::test]
async fn generate_docs() -> Result<(), Box<dyn std::error::Error>> {
let mut client = TestClient::start(MyServer).await?;
// Make various API calls
client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
client.post("/users")?.json(&serde_json::json!({"name": "John"}))?.await?.as_json::<serde_json::Value>().await?;
client.get("/users/123")?.await?.as_json::<serde_json::Value>().await?;
// Generate OpenAPI specification
client.write_openapi("docs/openapi.yml").await?;
Ok(())
}
§Implementation Details
The TestClient
uses derive_more::Deref
and derive_more::DerefMut
to provide
transparent access to the underlying ApiClient
. This means you can call any
ApiClient
method directly on the TestClient
:
let mut client = TestClient::start(MyServer).await?;
// These are all ApiClient methods available directly on TestClient
let response = client.get("/endpoint")?.await?.as_json::<serde_json::Value>().await?;
let openapi = client.collected_openapi().await;
client.register_schema::<MyType>().await;
§Lifecycle Management
When a TestClient
is dropped, it automatically aborts the server task:
{
let client = TestClient::start(MyServer).await?;
// Server is running
} // Server is automatically stopped when client is dropped
Implementations§
Source§impl<T> TestClient<T>
impl<T> TestClient<T>
Sourcepub async fn start(test_server: T) -> Result<Self, TestAppError>
pub async fn start(test_server: T) -> Result<Self, TestAppError>
Start a test server and create a TestClient.
This method creates a new TestClient by:
- Binding to a random localhost port
- Starting the server in a background task
- Configuring an ApiClient with the server’s address
- Waiting for the server to become healthy
§Arguments
test_server
- An implementation ofTestServer
to start
§Returns
Ok(TestClient<T>)
- A ready-to-use test clientErr(TestAppError)
- If server startup or health check fails
§Errors
This method can fail for several reasons:
- Port binding failure (system resource issues)
- Server startup failure (implementation errors)
- Health check timeout (server not becoming ready)
- ApiClient configuration errors
§Examples
§Basic Usage
use clawspec_core::test_client::{TestClient, TestServer};
use std::net::TcpListener;
#[derive(Debug)]
struct MyServer;
impl TestServer for MyServer {
type Error = std::io::Error;
async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
listener.set_nonblocking(true)?;
let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
// Start your server
Ok(())
}
}
#[tokio::test]
async fn test_server_start() -> Result<(), Box<dyn std::error::Error>> {
let client = TestClient::start(MyServer).await?;
// Server is now running and ready for requests
Ok(())
}
§With Health Check
use clawspec_core::{test_client::{TestClient, TestServer}, ApiClient};
use std::net::TcpListener;
#[derive(Debug)]
struct MyServer;
impl TestServer for MyServer {
type Error = std::io::Error;
async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
// Server implementation
listener.set_nonblocking(true)?;
let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
Ok(())
}
async fn is_healthy(&self, client: &mut ApiClient) -> Result<clawspec_core::test_client::HealthStatus, Self::Error> {
// Custom health check
match client.get("/health").unwrap().await {
Ok(_) => Ok(clawspec_core::test_client::HealthStatus::Healthy),
Err(_) => Ok(clawspec_core::test_client::HealthStatus::Unhealthy),
}
}
}
#[tokio::test]
async fn test_with_health_check() -> Result<(), Box<dyn std::error::Error>> {
let client = TestClient::start(MyServer).await?;
// Server is guaranteed to be healthy
Ok(())
}
Sourcepub async fn write_openapi(
self,
path: impl AsRef<Path>,
) -> Result<(), TestAppError>
pub async fn write_openapi( self, path: impl AsRef<Path>, ) -> Result<(), TestAppError>
Write the collected OpenAPI specification to a file.
This method generates an OpenAPI specification from all the API calls made through this TestClient and writes it to the specified file. The format (JSON or YAML) is determined by the file extension.
§Arguments
path
- The file path where the OpenAPI specification should be written. File extension determines format:.yml
or.yaml
→ YAML format- All others → JSON format
§Returns
Ok(())
- File was written successfullyErr(TestAppError)
- If file operations or serialization fails
§Errors
This method can fail if:
- Parent directories don’t exist and can’t be created
- File can’t be written (permissions, disk space, etc.)
- OpenAPI serialization fails (YAML or JSON)
§File Format Detection
The output format is automatically determined by file extension:
openapi.yml
→ YAML formatopenapi.yaml
→ YAML formatopenapi.json
→ JSON formatspec.txt
→ JSON format (default for unknown extensions)
§Examples
§Basic Usage
use clawspec_core::test_client::{TestClient, TestServer};
use std::net::TcpListener;
#[derive(Debug)]
struct MyServer;
impl TestServer for MyServer {
type Error = std::io::Error;
async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
listener.set_nonblocking(true)?;
let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
// Server implementation
Ok(())
}
}
#[tokio::test]
async fn generate_openapi() -> Result<(), Box<dyn std::error::Error>> {
let mut client = TestClient::start(MyServer).await?;
// Make some API calls
client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
client.post("/users")?.json(&serde_json::json!({"name": "John"}))?.await?.as_json::<serde_json::Value>().await?;
// Generate YAML specification
client.write_openapi("openapi.yml").await?;
Ok(())
}
§Different Formats
let mut client = TestClient::start(MyServer).await?;
// Make API calls
client.get("/api/health")?.await?.as_json::<serde_json::Value>().await?;
// Write in different formats
client.write_openapi("docs/openapi.yaml").await?; // YAML format
client.write_openapi("docs/openapi.json").await?; // JSON format
client.write_openapi("docs/spec.txt").await?; // JSON format (default)
§Creating Parent Directories
The method automatically creates parent directories if they don’t exist:
let client = TestClient::start(MyServer).await?;
// This will create the docs/api/v1/ directory structure if it doesn't exist
client.write_openapi("docs/api/v1/openapi.yml").await?;
§Generated OpenAPI Structure
The generated OpenAPI specification includes:
- All API endpoints called through the client
- Request and response schemas for structured data
- Parameter definitions (path, query, headers)
- Status codes and error responses
- Server information and metadata
The specification follows OpenAPI 3.1 format and can be used with various tools for documentation generation, client generation, and API validation.
Methods from Deref<Target = ApiClient>§
pub async fn collected_paths(&mut self) -> Paths
Sourcepub async fn collected_openapi(&mut self) -> OpenApi
pub async fn collected_openapi(&mut self) -> OpenApi
Generates a complete OpenAPI specification from collected request/response data.
This method aggregates all the information collected during API calls and produces a comprehensive OpenAPI 3.1 specification including paths, components, schemas, operation metadata, and server information.
§Features
- Automatic Path Collection: All endpoint calls are automatically documented
- Schema Generation: Request/response schemas are extracted from Rust types
- Operation Metadata: Includes operation IDs, descriptions, and tags
- Server Information: Configurable server URLs and descriptions
- Tag Collection: Automatically computed from all operations
- Component Schemas: Reusable schema definitions with proper references
§Example
use clawspec_core::ApiClient;
use utoipa::openapi::{InfoBuilder, ServerBuilder};
use serde::{Serialize, Deserialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
struct UserData { name: String }
let mut client = ApiClient::builder()
.with_host("api.example.com")
.with_info(
InfoBuilder::new()
.title("My API")
.version("1.0.0")
.build()
)
.add_server(
ServerBuilder::new()
.url("https://api.example.com")
.description(Some("Production server"))
.build()
)
.build()?;
let user_data = UserData { name: "John".to_string() };
// Make some API calls to collect data
client.get("/users")?.await?.as_json::<Vec<UserData>>().await?;
client.post("/users")?.json(&user_data)?.await?.as_json::<UserData>().await?;
// Generate complete OpenAPI specification
let openapi = client.collected_openapi().await;
// The generated spec includes:
// - API info (title, version, description)
// - Server definitions
// - All paths with operations
// - Component schemas
// - Computed tags from operations
// Export to different formats
let yaml = serde_yaml::to_string(&openapi)?;
let json = serde_json::to_string_pretty(&openapi)?;
§Generated Content
The generated OpenAPI specification includes:
- Info: API metadata (title, version, description) if configured
- Servers: Server URLs and descriptions if configured
- Paths: All documented endpoints with operations
- Components: Reusable schema definitions
- Tags: Automatically computed from operation tags
§Tag Generation
Tags are automatically computed from all operations and include:
- Explicit tags set on operations
- Auto-generated tags based on path patterns
- Deduplicated and sorted alphabetically
§Performance Notes
- This method acquires read locks on internal collections
- Schema processing is cached to avoid redundant work
- Tags are computed on-demand from operation metadata
Sourcepub async fn register_schema<T>(&mut self)where
T: ToSchema + 'static,
pub async fn register_schema<T>(&mut self)where
T: ToSchema + 'static,
Manually registers a type in the schema collection.
This method allows you to explicitly add types to the OpenAPI schema collection that might not be automatically detected. This is useful for types that are referenced indirectly, such as nested types.
§Type Parameters
T
- The type to register, must implementToSchema
and'static
§Example
use clawspec_core::ApiClient;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
struct NestedErrorType {
message: String,
}
let mut client = ApiClient::builder().build()?;
// Register the nested type that might not be automatically detected
client.register_schema::<NestedErrorType>().await;
// Now when you generate the OpenAPI spec, NestedErrorType will be included
let openapi = client.collected_openapi().await;
pub fn call( &self, method: Method, path: CallPath, ) -> Result<ApiCall, ApiClientError>
pub fn get(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError>
pub fn post(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError>
pub fn put(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError>
pub fn delete( &self, path: impl Into<CallPath>, ) -> Result<ApiCall, ApiClientError>
pub fn patch( &self, path: impl Into<CallPath>, ) -> Result<ApiCall, ApiClientError>
Trait Implementations§
Source§impl<T: Debug> Debug for TestClient<T>
impl<T: Debug> Debug for TestClient<T>
Source§impl<T> Deref for TestClient<T>
impl<T> Deref for TestClient<T>
Source§impl<T> DerefMut for TestClient<T>
impl<T> DerefMut for TestClient<T>
Source§impl<T> Drop for TestClient<T>
Automatic cleanup when TestClient is dropped.
impl<T> Drop for TestClient<T>
Automatic cleanup when TestClient is dropped.
This implementation ensures that the background server task is properly terminated when the TestClient goes out of scope, preventing resource leaks.
Source§fn drop(&mut self)
fn drop(&mut self)
Abort the background server task when the TestClient is dropped.
This method is called automatically when the TestClient goes out of scope. It ensures that the server task is cleanly terminated, preventing the server from continuing to run after the test is complete.
§Example
{
let client = TestClient::start(MyServer).await?;
// Use the client for testing
client.get("/api/test")?.await?.as_json::<serde_json::Value>().await?;
} // <- TestClient is dropped here, server task is automatically aborted
// Server is no longer running