This guide covers testing strategies for cloud provider implementations.
Testing Levels
flowchart BT unit["Unit Tests<br/>(Mocked API responses)<br/>cargo test"] integration["Integration Tests<br/>(Real API, real resources)<br/>cargo test --ignored"] unit --> integration
#[tokio::test]
async fn test_authentication_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/servers"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": { "code": "unauthorized", "message": "Invalid token" }
})))
.mount(&mock_server)
.await;
let provider = create_mock_provider(&mock_server).await;
let result = provider.list_instances().await;
assert!(matches!(
result.unwrap_err(),
ProviderError::Authentication { .. }
));
}
#[tokio::test]
async fn test_rate_limit_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/servers"))
.respond_with(ResponseTemplate::new(429).set_body_json(serde_json::json!({
"error": { "code": "rate_limit_exceeded", "message": "Too many requests" }
})))
.mount(&mock_server)
.await;
let provider = create_mock_provider(&mock_server).await;
let result = provider.list_instances().await;
let err = result.unwrap_err();
assert!(matches!(err, ProviderError::RateLimit { .. }));
assert!(err.is_retryable());
assert!(err.retry_after().is_some());
}
// tests/integration/provider_test.rs
use spuff::provider::{Provider, InstanceRequest, ImageSpec, create_provider};
use std::env;
fn skip_if_no_credentials() -> bool {
if env::var("HETZNER_TOKEN").is_err() {
eprintln!("Skipping: HETZNER_TOKEN not set");
return true;
}
false
}
fn create_test_provider() -> Box<dyn Provider> {
let token = env::var("HETZNER_TOKEN").expect("HETZNER_TOKEN required");
let registry = ProviderRegistry::with_defaults();
registry.create_by_name("hetzner", &token, ProviderTimeouts::default())
.expect("Failed to create provider")
}
#[tokio::test]
#[ignore] // Run with: cargo test -- --ignored
async fn test_full_instance_lifecycle() {
if skip_if_no_credentials() {
return;
}
let provider = create_test_provider();
// Create instance
let request = InstanceRequest {
name: format!("spuff-test-{}", uuid::Uuid::new_v4().to_string()[..8].to_string()),
region: "fsn1".to_string(),
size: "cx11".to_string(),
image: ImageSpec::Ubuntu("24.04".to_string()),
user_data: None,
labels: HashMap::from([
("managed-by".to_string(), "spuff".to_string()),
("test".to_string(), "true".to_string()),
]),
};
println!("Creating instance: {}", request.name);
let instance = provider.create_instance(&request).await
.expect("Failed to create instance");
println!("Instance ID: {}", instance.id);
// Wait for ready
println!("Waiting for instance to be ready...");
let ready = provider.wait_ready(&instance.id).await
.expect("Instance never became ready");
println!("Instance ready at IP: {}", ready.ip);
assert!(!ready.ip.is_unspecified());
assert_eq!(ready.status, InstanceStatus::Active);
// List instances
let instances = provider.list_instances().await
.expect("Failed to list instances");
assert!(instances.iter().any(|i| i.id == instance.id));
// Create snapshot (if supported)
if provider.supports_snapshots() {
println!("Creating snapshot...");
let snapshot = provider.create_snapshot(&instance.id, "test-snapshot").await
.expect("Failed to create snapshot");
println!("Snapshot ID: {}", snapshot.id);
// List snapshots
let snapshots = provider.list_snapshots().await
.expect("Failed to list snapshots");
assert!(snapshots.iter().any(|s| s.id == snapshot.id));
// Delete snapshot
println!("Deleting snapshot...");
provider.delete_snapshot(&snapshot.id).await
.expect("Failed to delete snapshot");
}
// Cleanup: Destroy instance
println!("Destroying instance...");
provider.destroy_instance(&instance.id).await
.expect("Failed to destroy instance");
println!("Test complete!");
}
# Set credentials
export HETZNER_TOKEN="your-api-token"
# Run integration tests
cargo test -- --ignored
# Run specific integration test
cargo test test_full_instance_lifecycle -- --ignored
# Run with output
cargo test test_full_instance_lifecycle -- --ignored --nocapture
# Install cargo-tarpaulin
cargo install cargo-tarpaulin
# Run with coverage
cargo tarpaulin --out Html
# Open coverage report
open tarpaulin-report.html