Struct TestClient

Source
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

§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>
where T: TestServer + Send + Sync + 'static,

Source

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:

  1. Binding to a random localhost port
  2. Starting the server in a background task
  3. Configuring an ApiClient with the server’s address
  4. Waiting for the server to become healthy
§Arguments
  • test_server - An implementation of TestServer to start
§Returns
  • Ok(TestClient<T>) - A ready-to-use test client
  • Err(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(())
}
Source

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 successfully
  • Err(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 format
  • openapi.yaml → YAML format
  • openapi.json → JSON format
  • spec.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>§

Source

pub async fn collected_paths(&mut self) -> Paths

Source

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
Source

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 implement ToSchema 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;
Source

pub fn call( &self, method: Method, path: CallPath, ) -> Result<ApiCall, ApiClientError>

Source

pub fn get(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError>

Source

pub fn post(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError>

Source

pub fn put(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError>

Source

pub fn delete( &self, path: impl Into<CallPath>, ) -> Result<ApiCall, ApiClientError>

Source

pub fn patch( &self, path: impl Into<CallPath>, ) -> Result<ApiCall, ApiClientError>

Trait Implementations§

Source§

impl<T: Debug> Debug for TestClient<T>

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<T> Deref for TestClient<T>

Source§

type Target = ApiClient

The resulting type after dereferencing.
Source§

fn deref(&self) -> &Self::Target

Dereferences the value.
Source§

impl<T> DerefMut for TestClient<T>

Source§

fn deref_mut(&mut self) -> &mut Self::Target

Mutably dereferences the value.
Source§

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)

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

Auto Trait Implementations§

§

impl<T> !Freeze for TestClient<T>

§

impl<T> !RefUnwindSafe for TestClient<T>

§

impl<T> Send for TestClient<T>
where T: Sync + Send,

§

impl<T> Sync for TestClient<T>
where T: Sync + Send,

§

impl<T> Unpin for TestClient<T>

§

impl<T> !UnwindSafe for TestClient<T>

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> PolicyExt for T
where T: ?Sized,

Source§

fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow only if self and other return Action::Follow. Read more
Source§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow if either self or other returns Action::Follow. Read more
Source§

impl<P, T> Receiver for P
where P: Deref<Target = T> + ?Sized, T: ?Sized,

Source§

type Target = T

🔬This is a nightly-only experimental API. (arbitrary_self_types)
The target type on which the method may be called.
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

impl<T> ErasedDestructor for T
where T: 'static,