clawspec_core/test_client/mod.rs
1//! Generic test client framework for async server testing.
2//!
3//! This module provides a generic testing framework that works with any async server
4//! implementation through the [`TestServer`] trait. It allows you to start a server,
5//! make API calls, and generate OpenAPI specifications from your tests.
6//!
7//! # Core Components
8//!
9//! - [`TestClient<T>`]: Generic test client that wraps any server implementing [`TestServer`]
10//! - [`TestServer`]: Trait for server implementations (Axum, Warp, actix-web, etc.)
11//! - [`TestServerConfig`]: Configuration for test behavior
12//! - [`TestAppError`]: Error types for test operations
13//!
14//! # Quick Start
15//!
16//! For a complete working example, see the [axum example](https://github.com/ilaborie/clawspec/tree/main/examples/axum-example).
17//!
18//! ```rust,no_run
19//! use clawspec_core::test_client::{TestClient, TestServer, TestServerConfig};
20//! use std::net::TcpListener;
21//!
22//! #[derive(Debug)]
23//! struct MyTestServer;
24//!
25//! impl TestServer for MyTestServer {
26//! type Error = std::io::Error;
27//!
28//! async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
29//! // Start your server here
30//! listener.set_nonblocking(true)?;
31//! let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
32//! // Your server startup logic
33//! Ok(())
34//! }
35//! }
36//!
37//! #[tokio::test]
38//! async fn test_my_api() -> Result<(), Box<dyn std::error::Error>> {
39//! let mut test_client = TestClient::start(MyTestServer).await?;
40//!
41//! let response = test_client
42//! .get("/api/users")?
43//!
44//! .await?;
45//!
46//! // Generate OpenAPI documentation
47//! test_client.write_openapi("openapi.yml").await?;
48//!
49//! Ok(())
50//! }
51//! ```
52
53use std::fs;
54use std::marker::Sync;
55use std::net::{Ipv4Addr, SocketAddr, TcpListener};
56use std::path::Path;
57use std::sync::Arc;
58use std::time::Duration;
59
60use backon::{ExponentialBuilder, Retryable};
61use tracing::{debug, error};
62
63use crate::ApiClient;
64
65mod error;
66pub use self::error::*;
67
68mod test_server;
69pub use self::test_server::*;
70
71/// A generic test client for async server testing.
72///
73/// `TestClient<T>` provides a framework-agnostic way to test web servers by wrapping
74/// any server implementation that implements the [`TestServer`] trait. It manages
75/// server lifecycle, health checking, and provides convenient access to the underlying
76/// [`ApiClient`] for making requests and generating OpenAPI specifications.
77///
78/// # Type Parameters
79///
80/// * `T` - The server type that implements [`TestServer`]
81///
82/// # Features
83///
84/// - **Server Lifecycle Management**: Automatically starts and stops the server
85/// - **Health Checking**: Waits for server to be ready before returning success
86/// - **OpenAPI Generation**: Collects API calls and generates OpenAPI specifications
87/// - **Deref to ApiClient**: Direct access to all [`ApiClient`] methods
88///
89/// # Examples
90///
91/// ## Basic Usage
92///
93/// ```rust,no_run
94/// use clawspec_core::test_client::{TestClient, TestServer};
95/// use std::net::TcpListener;
96///
97/// #[derive(Debug)]
98/// struct MyServer;
99///
100/// impl TestServer for MyServer {
101/// type Error = std::io::Error;
102///
103/// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
104/// listener.set_nonblocking(true)?;
105/// let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
106/// // Start your server here
107/// Ok(())
108/// }
109/// }
110///
111/// #[tokio::test]
112/// async fn test_api() -> Result<(), Box<dyn std::error::Error>> {
113/// let mut client = TestClient::start(MyServer).await?;
114///
115/// let response = client.get("/users")?.await?.as_raw().await?;
116/// assert_eq!(response.status_code(), http::StatusCode::OK);
117///
118/// Ok(())
119/// }
120/// ```
121///
122/// ## With Custom Configuration
123///
124/// ```rust,no_run
125/// use clawspec_core::{test_client::{TestClient, TestServer, TestServerConfig}, ApiClient};
126/// use std::{net::TcpListener, time::Duration};
127///
128/// #[derive(Debug)]
129/// struct MyServer;
130///
131/// impl TestServer for MyServer {
132/// type Error = std::io::Error;
133///
134/// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
135/// // Server implementation
136/// listener.set_nonblocking(true)?;
137/// let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
138/// Ok(())
139/// }
140///
141/// fn config(&self) -> TestServerConfig {
142/// TestServerConfig {
143/// api_client: Some(
144/// ApiClient::builder()
145/// .with_host("localhost")
146/// .with_base_path("/api/v1").unwrap()
147/// ),
148/// min_backoff_delay: Duration::from_millis(25),
149/// max_backoff_delay: Duration::from_secs(2),
150/// backoff_jitter: true,
151/// max_retry_attempts: 15,
152/// }
153/// }
154/// }
155///
156/// #[tokio::test]
157/// async fn test_with_config() -> Result<(), Box<dyn std::error::Error>> {
158/// let mut client = TestClient::start(MyServer).await?;
159///
160/// // Client is already configured with base path /api/v1
161/// let response = client.get("/users")?.await?; // Calls /api/v1/users
162///
163/// Ok(())
164/// }
165/// ```
166///
167/// ## Generating OpenAPI Documentation
168///
169/// ```rust,no_run
170/// use clawspec_core::test_client::{TestClient, TestServer};
171/// use std::net::TcpListener;
172///
173/// #[derive(Debug)]
174/// struct MyServer;
175///
176/// impl TestServer for MyServer {
177/// type Error = std::io::Error;
178///
179/// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
180/// listener.set_nonblocking(true)?;
181/// let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
182/// // Server implementation
183/// Ok(())
184/// }
185/// }
186///
187/// #[tokio::test]
188/// async fn generate_docs() -> Result<(), Box<dyn std::error::Error>> {
189/// let mut client = TestClient::start(MyServer).await?;
190///
191/// // Make various API calls
192/// client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
193/// client.post("/users")?.json(&serde_json::json!({"name": "John"}))?.await?.as_json::<serde_json::Value>().await?;
194/// client.get("/users/123")?.await?.as_json::<serde_json::Value>().await?;
195///
196/// // Generate OpenAPI specification
197/// client.write_openapi("docs/openapi.yml").await?;
198///
199/// Ok(())
200/// }
201/// ```
202///
203/// # Implementation Details
204///
205/// The `TestClient` uses [`derive_more::Deref`] and [`derive_more::DerefMut`] to provide
206/// transparent access to the underlying [`ApiClient`]. This means you can call any
207/// [`ApiClient`] method directly on the `TestClient`:
208///
209/// ```rust,no_run
210/// # use clawspec_core::test_client::{TestClient, TestServer};
211/// # use std::net::TcpListener;
212/// # #[derive(Debug)] struct MyServer;
213/// # impl TestServer for MyServer {
214/// # type Error = std::io::Error;
215/// # async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
216/// # listener.set_nonblocking(true)?;
217/// # let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
218/// # Ok(())
219/// # }
220/// # }
221/// # #[tokio::test]
222/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
223/// let mut client = TestClient::start(MyServer).await?;
224///
225/// // These are all ApiClient methods available directly on TestClient
226/// let response = client.get("/endpoint")?.await?.as_json::<serde_json::Value>().await?;
227/// let openapi = client.collected_openapi().await;
228/// client.register_schema::<MyType>().await;
229/// # Ok(())
230/// # }
231/// # #[derive(serde::Serialize, utoipa::ToSchema)] struct MyType;
232/// ```
233///
234/// # Lifecycle Management
235///
236/// When a `TestClient` is dropped, it automatically aborts the server task:
237///
238/// ```rust,no_run
239/// # use clawspec_core::test_client::{TestClient, TestServer};
240/// # use std::net::TcpListener;
241/// # #[derive(Debug)] struct MyServer;
242/// # impl TestServer for MyServer {
243/// # type Error = std::io::Error;
244/// # async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
245/// # listener.set_nonblocking(true)?;
246/// # let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
247/// # Ok(())
248/// # }
249/// # }
250/// # #[tokio::test]
251/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
252/// {
253/// let client = TestClient::start(MyServer).await?;
254/// // Server is running
255/// } // Server is automatically stopped when client is dropped
256/// # Ok(())
257/// # }
258/// ```
259#[derive(Debug, derive_more::Deref, derive_more::DerefMut)]
260pub struct TestClient<T> {
261 #[allow(dead_code)]
262 local_addr: SocketAddr,
263 #[deref]
264 #[deref_mut]
265 client: ApiClient,
266 handle: Option<ServerTaskHandle>,
267 #[allow(dead_code)]
268 test_server: Arc<T>,
269}
270
271/// Private wrapper for the server task handle.
272///
273/// This type encapsulates the tokio-specific `JoinHandle` to avoid
274/// exposing runtime-specific types in the public API.
275struct ServerTaskHandle(tokio::task::JoinHandle<()>);
276
277impl ServerTaskHandle {
278 fn new(handle: tokio::task::JoinHandle<()>) -> Self {
279 Self(handle)
280 }
281
282 fn abort(&self) {
283 self.0.abort();
284 }
285
286 #[cfg(test)]
287 fn is_finished(&self) -> bool {
288 self.0.is_finished()
289 }
290}
291
292impl std::fmt::Debug for ServerTaskHandle {
293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294 f.debug_tuple("ServerTaskHandle")
295 .field(&self.0.id())
296 .finish()
297 }
298}
299
300impl<T> TestClient<T>
301where
302 T: TestServer + Send + Sync + 'static,
303{
304 /// Start a test server and create a TestClient.
305 ///
306 /// This method creates a new TestClient by:
307 /// 1. Binding to a random localhost port
308 /// 2. Starting the server in a background task
309 /// 3. Configuring an ApiClient with the server's address
310 /// 4. Waiting for the server to become healthy
311 ///
312 /// # Arguments
313 ///
314 /// * `test_server` - An implementation of [`TestServer`] to start
315 ///
316 /// # Returns
317 ///
318 /// * `Ok(TestClient<T>)` - A ready-to-use test client
319 /// * `Err(TestAppError)` - If server startup or health check fails
320 ///
321 /// # Errors
322 ///
323 /// This method can fail for several reasons:
324 /// - Port binding failure (system resource issues)
325 /// - Server startup failure (implementation errors)
326 /// - Health check timeout (server not becoming ready)
327 /// - ApiClient configuration errors
328 ///
329 /// # Examples
330 ///
331 /// ## Basic Usage
332 ///
333 /// ```rust,no_run
334 /// use clawspec_core::test_client::{TestClient, TestServer};
335 /// use std::net::TcpListener;
336 ///
337 /// #[derive(Debug)]
338 /// struct MyServer;
339 ///
340 /// impl TestServer for MyServer {
341 /// type Error = std::io::Error;
342 ///
343 /// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
344 /// listener.set_nonblocking(true)?;
345 /// let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
346 /// // Start your server
347 /// Ok(())
348 /// }
349 /// }
350 ///
351 /// #[tokio::test]
352 /// async fn test_server_start() -> Result<(), Box<dyn std::error::Error>> {
353 /// let client = TestClient::start(MyServer).await?;
354 /// // Server is now running and ready for requests
355 /// Ok(())
356 /// }
357 /// ```
358 ///
359 /// ## With Health Check
360 ///
361 /// ```rust,no_run
362 /// use clawspec_core::{test_client::{TestClient, TestServer}, ApiClient};
363 /// use std::net::TcpListener;
364 ///
365 /// #[derive(Debug)]
366 /// struct MyServer;
367 ///
368 /// impl TestServer for MyServer {
369 /// type Error = std::io::Error;
370 ///
371 /// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
372 /// // Server implementation
373 /// listener.set_nonblocking(true)?;
374 /// let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
375 /// Ok(())
376 /// }
377 ///
378 /// async fn is_healthy(&self, client: &mut ApiClient) -> Result<clawspec_core::test_client::HealthStatus, Self::Error> {
379 /// // Custom health check
380 /// match client.get("/health").unwrap().await {
381 /// Ok(_) => Ok(clawspec_core::test_client::HealthStatus::Healthy),
382 /// Err(_) => Ok(clawspec_core::test_client::HealthStatus::Unhealthy),
383 /// }
384 /// }
385 /// }
386 ///
387 /// #[tokio::test]
388 /// async fn test_with_health_check() -> Result<(), Box<dyn std::error::Error>> {
389 /// let client = TestClient::start(MyServer).await?;
390 /// // Server is guaranteed to be healthy
391 /// Ok(())
392 /// }
393 /// ```
394 pub async fn start(test_server: T) -> Result<Self, TestAppError> {
395 let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
396 let listener = TcpListener::bind(addr)?;
397 let local_addr = listener.local_addr()?;
398
399 let test_server = Arc::new(test_server);
400 let handle = tokio::spawn({
401 let server = Arc::clone(&test_server);
402 async move {
403 if let Err(error) = server.launch(listener).await {
404 error!(?error, "Server launch failed");
405 }
406 }
407 });
408
409 let TestServerConfig {
410 api_client,
411 min_backoff_delay,
412 max_backoff_delay,
413 backoff_jitter,
414 max_retry_attempts,
415 } = test_server.config();
416
417 // Build client with comprehensive OpenAPI metadata
418 let client = api_client.unwrap_or_else(ApiClient::builder);
419 let client = client.with_port(local_addr.port()).build()?;
420
421 // Wait until ready with exponential backoff
422 let healthy = Self::wait_for_health(
423 &test_server,
424 &client,
425 local_addr,
426 min_backoff_delay,
427 max_backoff_delay,
428 backoff_jitter,
429 max_retry_attempts,
430 )
431 .await;
432
433 if !healthy {
434 return Err(TestAppError::UnhealthyServer {
435 timeout: max_backoff_delay,
436 });
437 }
438
439 let result = Self {
440 local_addr,
441 client,
442 handle: Some(ServerTaskHandle::new(handle)),
443 test_server,
444 };
445 Ok(result)
446 }
447
448 /// Wait for the server to become healthy using exponential backoff.
449 ///
450 /// This method implements a retry mechanism with exponential backoff to check
451 /// if the server is healthy. It handles different health status responses:
452 /// - `Healthy`: Server is ready, returns success
453 /// - `Unhealthy`: Server not ready, retries with exponential backoff
454 /// - `Uncheckable`: Falls back to TCP connection test
455 /// - Error: Returns failure immediately
456 ///
457 /// # Arguments
458 ///
459 /// * `test_server` - The server implementation to check
460 /// * `client` - ApiClient configured for the server
461 /// * `local_addr` - Server address for TCP connection fallback
462 /// * `min_backoff_delay` - Minimum delay for exponential backoff
463 /// * `max_backoff_delay` - Maximum delay for exponential backoff
464 /// * `backoff_jitter` - Whether to add jitter to backoff delays
465 /// * `max_retry_attempts` - Maximum number of retry attempts before giving up
466 ///
467 /// # Returns
468 ///
469 /// * `true` - Server is healthy and ready
470 /// * `false` - Server failed health checks or encountered errors
471 async fn wait_for_health(
472 test_server: &Arc<T>,
473 client: &ApiClient,
474 local_addr: SocketAddr,
475 min_backoff_delay: Duration,
476 max_backoff_delay: Duration,
477 backoff_jitter: bool,
478 max_retry_attempts: usize,
479 ) -> bool {
480 // Configure exponential backoff with provided settings
481 let mut backoff_builder = ExponentialBuilder::default()
482 .with_min_delay(min_backoff_delay)
483 .with_max_delay(max_backoff_delay)
484 .with_max_times(max_retry_attempts); // Limit total retry attempts to prevent infinite loops
485
486 if backoff_jitter {
487 backoff_builder = backoff_builder.with_jitter();
488 }
489
490 let backoff = backoff_builder;
491
492 let health_check = || {
493 let mut client = client.clone();
494 let server = Arc::clone(test_server);
495 async move {
496 let result = server.is_healthy(&mut client).await;
497 match result {
498 Ok(HealthStatus::Healthy) => {
499 debug!("π’ server healthy");
500 Ok(true)
501 }
502 Ok(HealthStatus::Unhealthy) => {
503 debug!("π server not yet healthy, retrying with exponential backoff");
504 Err(std::io::Error::new(
505 std::io::ErrorKind::ConnectionRefused,
506 "Server not healthy yet",
507 ))
508 }
509 Ok(HealthStatus::Uncheckable) => {
510 debug!("βwait until a connection can be establish with the server");
511 let connection = tokio::net::TcpStream::connect(local_addr).await;
512 if let Err(err) = &connection {
513 error!(?err, %local_addr, "Oops, fail to establish connection");
514 }
515 Ok(connection.is_ok())
516 }
517 Err(error) => {
518 error!(?error, "Health check error");
519 Ok(false)
520 }
521 }
522 }
523 };
524
525 health_check.retry(&backoff).await.unwrap_or(false)
526 }
527
528 /// Write the collected OpenAPI specification to a file.
529 ///
530 /// This method generates an OpenAPI specification from all the API calls made
531 /// through this TestClient and writes it to the specified file. The format
532 /// (JSON or YAML) is determined by the file extension.
533 ///
534 /// # Arguments
535 ///
536 /// * `path` - The file path where the OpenAPI specification should be written.
537 /// File extension determines format:
538 /// - `.yml` or `.yaml` β YAML format
539 /// - All others β JSON format
540 ///
541 /// # Returns
542 ///
543 /// * `Ok(())` - File was written successfully
544 /// * `Err(TestAppError)` - If file operations or serialization fails
545 ///
546 /// # Errors
547 ///
548 /// This method can fail if:
549 /// - Parent directories don't exist and can't be created
550 /// - File can't be written (permissions, disk space, etc.)
551 /// - OpenAPI serialization fails (YAML or JSON)
552 ///
553 /// # File Format Detection
554 ///
555 /// The output format is automatically determined by file extension:
556 /// - `openapi.yml` β YAML format
557 /// - `openapi.yaml` β YAML format
558 /// - `openapi.json` β JSON format
559 /// - `spec.txt` β JSON format (default for unknown extensions)
560 ///
561 /// # Examples
562 ///
563 /// ## Basic Usage
564 ///
565 /// ```rust,no_run
566 /// use clawspec_core::test_client::{TestClient, TestServer};
567 /// use std::net::TcpListener;
568 ///
569 /// #[derive(Debug)]
570 /// struct MyServer;
571 ///
572 /// impl TestServer for MyServer {
573 /// type Error = std::io::Error;
574 ///
575 /// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
576 /// listener.set_nonblocking(true)?;
577 /// let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
578 /// // Server implementation
579 /// Ok(())
580 /// }
581 /// }
582 ///
583 /// #[tokio::test]
584 /// async fn generate_openapi() -> Result<(), Box<dyn std::error::Error>> {
585 /// let mut client = TestClient::start(MyServer).await?;
586 ///
587 /// // Make some API calls
588 /// client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
589 /// client.post("/users")?.json(&serde_json::json!({"name": "John"}))?.await?.as_json::<serde_json::Value>().await?;
590 ///
591 /// // Generate YAML specification
592 /// client.write_openapi("openapi.yml").await?;
593 ///
594 /// Ok(())
595 /// }
596 /// ```
597 ///
598 /// ## Different Formats
599 ///
600 /// ```rust,no_run
601 /// # use clawspec_core::test_client::{TestClient, TestServer};
602 /// # use std::net::TcpListener;
603 /// # #[derive(Debug)] struct MyServer;
604 /// # impl TestServer for MyServer {
605 /// # type Error = std::io::Error;
606 /// # async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
607 /// # listener.set_nonblocking(true)?;
608 /// # let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
609 /// # Ok(())
610 /// # }
611 /// # }
612 /// # #[tokio::test]
613 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
614 /// let mut client = TestClient::start(MyServer).await?;
615 ///
616 /// // Make API calls
617 /// client.get("/api/health")?.await?.as_json::<serde_json::Value>().await?;
618 ///
619 /// // Write in different formats
620 /// client.write_openapi("docs/openapi.yaml").await?; // YAML format
621 /// client.write_openapi("docs/openapi.json").await?; // JSON format
622 /// client.write_openapi("docs/spec.txt").await?; // JSON format (default)
623 /// # Ok(())
624 /// # }
625 /// ```
626 ///
627 /// ## Creating Parent Directories
628 ///
629 /// The method automatically creates parent directories if they don't exist:
630 ///
631 /// ```rust,no_run
632 /// # use clawspec_core::test_client::{TestClient, TestServer};
633 /// # use std::net::TcpListener;
634 /// # #[derive(Debug)] struct MyServer;
635 /// # impl TestServer for MyServer {
636 /// # type Error = std::io::Error;
637 /// # async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
638 /// # listener.set_nonblocking(true)?;
639 /// # let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
640 /// # Ok(())
641 /// # }
642 /// # }
643 /// # #[tokio::test]
644 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
645 /// let client = TestClient::start(MyServer).await?;
646 ///
647 /// // This will create the docs/api/v1/ directory structure if it doesn't exist
648 /// client.write_openapi("docs/api/v1/openapi.yml").await?;
649 /// # Ok(())
650 /// # }
651 /// ```
652 ///
653 /// # Generated OpenAPI Structure
654 ///
655 /// The generated OpenAPI specification includes:
656 /// - All API endpoints called through the client
657 /// - Request and response schemas for structured data
658 /// - Parameter definitions (path, query, headers)
659 /// - Status codes and error responses
660 /// - Server information and metadata
661 ///
662 /// The specification follows OpenAPI 3.1 format and can be used with various
663 /// tools for documentation generation, client generation, and API validation.
664 pub async fn write_openapi(mut self, path: impl AsRef<Path>) -> Result<(), TestAppError> {
665 let path = path.as_ref();
666 if let Some(parent) = path.parent() {
667 fs::create_dir_all(parent)?;
668 }
669
670 let openapi = self.client.collected_openapi().await;
671
672 let ext = path.extension().unwrap_or_default();
673 let contents = if ext == "yml" || ext == "yaml" {
674 openapi.to_yaml().map_err(|err| TestAppError::YamlError {
675 error: format!("{err:#?}"),
676 })?
677 } else {
678 serde_json::to_string_pretty(&openapi)?
679 };
680
681 fs::write(path, contents)?;
682
683 Ok(())
684 }
685}
686
687/// Automatic cleanup when TestClient is dropped.
688///
689/// This implementation ensures that the background server task is properly
690/// terminated when the TestClient goes out of scope, preventing resource leaks.
691impl<T> Drop for TestClient<T> {
692 /// Abort the background server task when the TestClient is dropped.
693 ///
694 /// This method is called automatically when the TestClient goes out of scope.
695 /// It ensures that the server task is cleanly terminated, preventing the
696 /// server from continuing to run after the test is complete.
697 ///
698 /// # Example
699 ///
700 /// ```rust,no_run
701 /// # use clawspec_core::test_client::{TestClient, TestServer};
702 /// # use std::net::TcpListener;
703 /// # #[derive(Debug)] struct MyServer;
704 /// # impl TestServer for MyServer {
705 /// # type Error = std::io::Error;
706 /// # async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
707 /// # listener.set_nonblocking(true)?;
708 /// # let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
709 /// # Ok(())
710 /// # }
711 /// # }
712 /// # #[tokio::test]
713 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
714 /// {
715 /// let client = TestClient::start(MyServer).await?;
716 /// // Use the client for testing
717 /// client.get("/api/test")?.await?.as_json::<serde_json::Value>().await?;
718 /// } // <- TestClient is dropped here, server task is automatically aborted
719 ///
720 /// // Server is no longer running
721 /// # Ok(())
722 /// # }
723 /// ```
724 fn drop(&mut self) {
725 if let Some(handle) = self.handle.take() {
726 handle.abort();
727 }
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use crate::ApiClient;
735 use std::net::{Ipv4Addr, TcpListener};
736 use std::sync::Arc;
737 use std::sync::atomic::{AtomicBool, Ordering};
738 use std::time::Duration;
739 use tokio::net::TcpListener as TokioTcpListener;
740
741 /// Mock server for testing TestClient functionality
742 #[derive(Debug)]
743 struct MockTestServer {
744 should_be_healthy: Arc<AtomicBool>,
745 startup_delay: Duration,
746 custom_config: Option<TestServerConfig>,
747 }
748
749 impl MockTestServer {
750 fn new() -> Self {
751 Self {
752 should_be_healthy: Arc::new(AtomicBool::new(true)),
753 startup_delay: Duration::from_millis(10),
754 custom_config: None,
755 }
756 }
757
758 fn with_health_status(self, healthy: bool) -> Self {
759 self.should_be_healthy.store(healthy, Ordering::Relaxed);
760 self
761 }
762
763 fn with_startup_delay(mut self, delay: Duration) -> Self {
764 self.startup_delay = delay;
765 self
766 }
767
768 fn with_config(mut self, config: TestServerConfig) -> Self {
769 self.custom_config = Some(config);
770 self
771 }
772 }
773
774 impl TestServer for MockTestServer {
775 type Error = std::io::Error;
776
777 async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
778 // Simulate startup delay
779 if !self.startup_delay.is_zero() {
780 tokio::time::sleep(self.startup_delay).await;
781 }
782
783 // Convert to non-blocking for tokio compatibility
784 listener.set_nonblocking(true)?;
785 let tokio_listener = TokioTcpListener::from_std(listener)?;
786
787 // Simple HTTP server that responds to health checks
788 loop {
789 if let Ok((mut stream, _)) = tokio_listener.accept().await {
790 tokio::spawn(async move {
791 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
792 let _ =
793 tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes())
794 .await;
795 let _ = tokio::io::AsyncWriteExt::shutdown(&mut stream).await;
796 });
797 }
798 }
799 }
800
801 async fn is_healthy(&self, _client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
802 Ok(if self.should_be_healthy.load(Ordering::Relaxed) {
803 HealthStatus::Healthy
804 } else {
805 HealthStatus::Unhealthy
806 })
807 }
808
809 fn config(&self) -> TestServerConfig {
810 self.custom_config.clone().unwrap_or_default()
811 }
812 }
813
814 #[tokio::test]
815 async fn test_test_client_start_success() {
816 let server = MockTestServer::new();
817
818 let result = TestClient::start(server).await;
819 assert!(result.is_ok());
820
821 let test_client = result.unwrap();
822 assert!(test_client.handle.is_some());
823
824 // Test that the server is running by checking the local address
825 let addr = test_client.local_addr;
826 assert_eq!(addr.ip(), Ipv4Addr::LOCALHOST);
827 assert_ne!(addr.port(), 0); // Should have been assigned a port
828 }
829
830 #[tokio::test]
831 async fn test_test_client_start_with_custom_config() {
832 let min_delay = Duration::from_millis(5);
833 let max_delay = Duration::from_millis(100);
834 let client_builder = ApiClient::builder()
835 .with_host("test.example.com")
836 .with_port(8080);
837
838 let config = TestServerConfig {
839 api_client: Some(client_builder),
840 min_backoff_delay: min_delay,
841 max_backoff_delay: max_delay,
842 backoff_jitter: false,
843 max_retry_attempts: 5,
844 };
845
846 let server = MockTestServer::new().with_config(config);
847 let result = TestClient::start(server).await;
848
849 assert!(result.is_ok());
850 let test_client = result.unwrap();
851 assert!(test_client.handle.is_some());
852 }
853
854 #[tokio::test]
855 async fn test_test_client_start_unhealthy_server() {
856 let expected_max_delay = Duration::from_millis(50);
857 let config = TestServerConfig {
858 api_client: None,
859 min_backoff_delay: Duration::from_millis(5),
860 max_backoff_delay: expected_max_delay,
861 backoff_jitter: false,
862 max_retry_attempts: 3,
863 };
864 let server = MockTestServer::new()
865 .with_health_status(false)
866 .with_config(config);
867
868 let result = TestClient::start(server).await;
869 assert!(result.is_err());
870
871 match result.unwrap_err() {
872 TestAppError::UnhealthyServer {
873 timeout: actual_timeout,
874 } => {
875 assert_eq!(actual_timeout, expected_max_delay);
876 }
877 other => panic!("Expected UnhealthyServer error, got: {other:?}"),
878 }
879 }
880
881 #[tokio::test]
882 async fn test_test_client_start_slow_server() {
883 let server = MockTestServer::new().with_startup_delay(Duration::from_millis(50));
884
885 let result = TestClient::start(server).await;
886 assert!(result.is_ok());
887 }
888
889 #[tokio::test]
890 async fn test_test_client_deref_to_api_client() {
891 let server = MockTestServer::new();
892 let mut test_client = TestClient::start(server)
893 .await
894 .expect("client should start");
895
896 // Test that we can access ApiClient methods through deref
897 let openapi = test_client.collected_openapi().await;
898 assert_eq!(openapi.info.title, ""); // Default title
899 }
900
901 #[tokio::test]
902 async fn test_test_client_deref_mut_to_api_client() {
903 let server = MockTestServer::new();
904 let test_client = TestClient::start(server)
905 .await
906 .expect("client should start");
907
908 // Test that we can mutably access ApiClient methods through deref_mut
909 let result = test_client.get("/test");
910 assert!(result.is_ok());
911 }
912
913 #[tokio::test]
914 async fn test_test_client_write_openapi_json() {
915 let server = MockTestServer::new();
916 let test_client = TestClient::start(server)
917 .await
918 .expect("client should start");
919
920 let temp_file = "/tmp/test_openapi.json";
921 let result = test_client.write_openapi(temp_file).await;
922
923 assert!(result.is_ok());
924
925 // Verify file was created and contains valid JSON
926 let content = std::fs::read_to_string(temp_file).expect("file should exist");
927 let json: serde_json::Value = serde_json::from_str(&content).expect("should be valid JSON");
928
929 assert!(json.get("openapi").is_some());
930 assert!(json.get("info").is_some());
931
932 // Cleanup
933 let _ = std::fs::remove_file(temp_file);
934 }
935
936 #[tokio::test]
937 async fn test_test_client_write_openapi_yaml() {
938 let server = MockTestServer::new();
939 let test_client = TestClient::start(server)
940 .await
941 .expect("client should start");
942
943 let temp_file = "/tmp/test_openapi.yml";
944 let result = test_client.write_openapi(temp_file).await;
945
946 assert!(result.is_ok());
947
948 // Verify file was created and contains valid YAML
949 let content = std::fs::read_to_string(temp_file).expect("file should exist");
950 let yaml: serde_json::Value =
951 serde_saphyr::from_str(&content).expect("should be valid YAML");
952
953 assert!(yaml.get("openapi").is_some());
954 assert!(yaml.get("info").is_some());
955
956 // Cleanup
957 let _ = std::fs::remove_file(temp_file);
958 }
959
960 #[tokio::test]
961 async fn test_test_client_write_openapi_creates_parent_dirs() {
962 let server = MockTestServer::new();
963 let test_client = TestClient::start(server)
964 .await
965 .expect("client should start");
966
967 let temp_dir = "/tmp/test_clawspec_dir/subdir";
968 let temp_file = format!("{temp_dir}/openapi.json");
969
970 let result = test_client.write_openapi(&temp_file).await;
971 assert!(result.is_ok());
972
973 // Verify file and directories were created
974 assert!(std::fs::metadata(&temp_file).is_ok());
975
976 // Cleanup
977 let _ = std::fs::remove_dir_all("/tmp/test_clawspec_dir");
978 }
979
980 #[tokio::test]
981 async fn test_test_client_drop_aborts_handle() {
982 let server = MockTestServer::new();
983 let test_client = TestClient::start(server)
984 .await
985 .expect("client should start");
986
987 let handle = test_client.handle.as_ref().unwrap();
988 assert!(!handle.is_finished());
989
990 // Drop the test client
991 drop(test_client);
992
993 // Give a moment for the handle to be aborted
994 tokio::time::sleep(Duration::from_millis(10)).await;
995 // Note: We can't easily test that the handle was aborted since we dropped test_client
996 // But the Drop implementation should call abort()
997 }
998
999 #[test]
1000 fn test_test_client_debug_trait() {
1001 // Test that TestClient implements Debug (compile-time check)
1002 let server = MockTestServer::new();
1003 // We can't easily create a TestClient in a sync test, but we can verify the trait bounds
1004 fn assert_debug<T: std::fmt::Debug>(_: &T) {}
1005 assert_debug(&server);
1006 }
1007
1008 #[test]
1009 fn test_test_client_trait_bounds() {
1010 // Verify that TestClient has the expected trait bounds
1011 #[allow(dead_code)]
1012 fn assert_bounds<T>(_: TestClient<T>)
1013 where
1014 T: TestServer + Send + Sync + 'static,
1015 {
1016 // TestClient should implement Deref and DerefMut to ApiClient
1017 }
1018
1019 // This is a compile-time check
1020 }
1021
1022 /// Mock server that simulates different error conditions
1023 #[derive(Debug)]
1024 struct ErrorTestServer {
1025 error_type: ErrorType,
1026 }
1027
1028 #[derive(Debug)]
1029 enum ErrorType {
1030 #[allow(dead_code)]
1031 BindFailure,
1032 HealthTimeout,
1033 }
1034
1035 impl ErrorTestServer {
1036 #[allow(dead_code)]
1037 fn bind_failure() -> Self {
1038 Self {
1039 error_type: ErrorType::BindFailure,
1040 }
1041 }
1042
1043 fn health_timeout() -> Self {
1044 Self {
1045 error_type: ErrorType::HealthTimeout,
1046 }
1047 }
1048 }
1049
1050 impl TestServer for ErrorTestServer {
1051 type Error = std::io::Error;
1052
1053 async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
1054 match self.error_type {
1055 ErrorType::BindFailure => {
1056 // Simulate bind failure by returning an error
1057 Err(std::io::Error::new(
1058 std::io::ErrorKind::AddrInUse,
1059 "Simulated bind failure",
1060 ))
1061 }
1062 ErrorType::HealthTimeout => {
1063 // Start normally but never become healthy
1064 listener.set_nonblocking(true)?;
1065 let _tokio_listener = TokioTcpListener::from_std(listener)?;
1066
1067 // Just keep running without accepting connections
1068 loop {
1069 tokio::time::sleep(Duration::from_secs(1)).await;
1070 }
1071 }
1072 }
1073 }
1074
1075 async fn is_healthy(&self, _client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
1076 match self.error_type {
1077 ErrorType::BindFailure => Ok(HealthStatus::Unhealthy),
1078 ErrorType::HealthTimeout => {
1079 // Always return unhealthy, which will cause the exponential backoff to timeout
1080 Ok(HealthStatus::Unhealthy)
1081 }
1082 }
1083 }
1084
1085 fn config(&self) -> TestServerConfig {
1086 TestServerConfig {
1087 api_client: None,
1088 min_backoff_delay: Duration::from_millis(1), // Very fast for testing
1089 max_backoff_delay: Duration::from_millis(10), // Short max delay for testing
1090 backoff_jitter: false, // Predictable timing for tests
1091 max_retry_attempts: 3, // Quick timeout for tests
1092 }
1093 }
1094 }
1095
1096 #[tokio::test]
1097 async fn test_test_client_start_health_timeout() {
1098 let server = ErrorTestServer::health_timeout();
1099
1100 let result = TestClient::start(server).await;
1101 assert!(result.is_err());
1102
1103 match result.unwrap_err() {
1104 TestAppError::UnhealthyServer { timeout } => {
1105 // The timeout should be the max_backoff_delay from the ErrorTestServer config
1106 assert_eq!(timeout, Duration::from_millis(10));
1107 }
1108 other => panic!("Expected UnhealthyServer error, got: {other:?}"),
1109 }
1110 }
1111}