Rust Testing Strategy
Context
Prism proxy requires comprehensive testing that:
- Ensures correctness at multiple levels
- Maintains 80%+ code coverage
- Supports rapid development
- Catches regressions early
- Validates async code and concurrency
Testing pyramid: Unit tests (base) → Integration tests → E2E tests (top)
Decision
Implement three-tier testing strategy with Rust best practices:
- Unit Tests: Module-level, test individual functions and types
- Integration Tests: Test crate interactions with real backends
- E2E Tests: Validate full gRPC API with test clients
Coverage Requirements
- Minimum: 80% per crate (CI enforced)
- Target: 90%+ for critical crates (
proxy-core
,backend
,keyvalue
) - New code: 100% coverage required
Rationale
Testing Tiers
Tier 1: Unit Tests
Scope: Individual functions, types, and modules
Location: #[cfg(test)] mod tests
in same file or tests/
subdirectory
Pattern:
// src/backend/postgres.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_config_valid() {
let config = Config {
host: "localhost".to_string(),
port: 5432,
database: "test".to_string(),
};
assert!(validate_config(&config).is_ok());
}
#[test]
fn test_validate_config_invalid_port() {
let config = Config {
host: "localhost".to_string(),
port: 0,
database: "test".to_string(),
};
assert!(validate_config(&config).is_err());
}
#[tokio::test]
async fn test_connect_to_backend() {
let pool = create_test_pool().await;
let result = connect(&pool).await;
assert!(result.is_ok());
}
}
Characteristics:
- Fast (milliseconds)
- No external dependencies (use mocks)
- Test edge cases and error conditions
- Use
#[tokio::test]
for async tests
Tier 2: Integration Tests
Scope: Crate interactions, real backend integration
Location: tests/
directory (separate from source)
Pattern:
// tests/integration_test.rs
use prism_proxy::{Backend, KeyValueBackend, SqliteBackend};
use sqlx::SqlitePool;
#[tokio::test]
async fn test_sqlite_backend_put_get() {
// Create in-memory SQLite database
let pool = SqlitePool::connect(":memory:").await.unwrap();
// Run migrations
sqlx::migrate!("./migrations")
.run(&pool)
.await
.unwrap();
// Create backend
let backend = SqliteBackend::new(pool);
// Test put
let items = vec![Item {
key: b"test-key".to_vec(),
value: b"test-value".to_vec(),
metadata: None,
}];
backend
.put("test-namespace", "test-id", items)
.await
.unwrap();
// Test get
let result = backend
.get("test-namespace", "test-id", vec![b"test-key"])
.await
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].key, b"test-key");
assert_eq!(result[0].value, b"test-value");
}
#[tokio::test]
async fn test_postgres_backend() {
// Use testcontainers for real Postgres
let docker = clients::Cli::default();
let postgres = docker.run(images::postgres::Postgres::default());
let port = postgres.get_host_port_ipv4(5432);
let database_url = format!("postgres://postgres:postgres@localhost:{}/test", port);
let pool = PgPool::connect(&database_url).await.unwrap();
// Run tests against real Postgres...
}
Tier 3: End-to-End Tests
Scope: Full gRPC API with real server
Location: tests/e2e/
Pattern:
// tests/e2e/keyvalue_test.rs
use prism_proto::keyvalue::v1::{
key_value_service_client::KeyValueServiceClient,
GetRequest, PutRequest, Item,
};
use tonic::transport::Channel;
#[tokio::test]
async fn test_keyvalue_put_get_e2e() {
// Start test server
let addr = start_test_server().await;
// Connect client
let mut client = KeyValueServiceClient::connect(format!("http://{}", addr))
.await
.unwrap();
// Put request
let put_req = PutRequest {
namespace: "test".to_string(),
id: "user123".to_string(),
items: vec![Item {
key: b"profile".to_vec(),
value: b"Alice".to_vec(),
metadata: None,
}],
item_priority_token: 0,
};
let response = client.put(put_req).await.unwrap();
assert!(response.into_inner().success);
// Get request
let get_req = GetRequest {
namespace: "test".to_string(),
id: "user123".to_string(),
predicate: None,
};
let response = client.get(get_req).await.unwrap();
let items = response.into_inner().items;
assert_eq!(items.len(), 1);
assert_eq!(items[0].key, b"profile");
assert_eq!(items[0].value, b"Alice");
}
async fn start_test_server() -> std::net::SocketAddr {
// Start server in background task
// Return address when ready
}
Test Utilities and Fixtures
// tests/common/mod.rs
use sqlx::SqlitePool;
pub async fn create_test_db() -> SqlitePool {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
pool
}
pub fn sample_item() -> Item {
Item {
key: b"test".to_vec(),
value: b"value".to_vec(),
metadata: None,
}
}
pub struct TestBackend {
pool: SqlitePool,
}
impl TestBackend {
pub async fn new() -> Self {
Self {
pool: create_test_db().await,
}
}
pub async fn insert_item(&self, namespace: &str, id: &str, item: Item) {
// Helper for test setup
}
}
Property-Based Testing
use proptest::prelude::*;
proptest! {
#[test]
fn test_key_roundtrip(key in "\\PC*", value in "\\PC*") {
// Property: put then get should return same value
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let backend = TestBackend::new().await;
let item = Item {
key: key.as_bytes().to_vec(),
value: value.as_bytes().to_vec(),
metadata: None,
};
backend.put("test", "id", vec![item.clone()]).await.unwrap();
let result = backend.get("test", "id", vec![&item.key]).await.unwrap();
prop_assert_eq!(result[0].value, item.value);
Ok(())
}).unwrap();
}
}
Benchmarking
// benches/keyvalue_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench_put(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().unwrap();
let backend = rt.block_on(async { TestBackend::new().await });
c.bench_function("put single item", |b| {
b.to_async(&rt).iter(|| async {
let item = sample_item();
backend.put("test", "id", vec![item]).await.unwrap()
});
});
}
criterion_group!(benches, bench_put);
criterion_main!(benches);
Alternatives Considered
-
Only unit tests
- Pros: Fast, simple
- Cons: Miss integration bugs
- Rejected: Insufficient for complex system
-
Mock all dependencies
- Pros: Tests run fast, no external dependencies
- Cons: Tests don't validate real integrations
- Rejected: Integration tests must use real backends
-
Fuzzing instead of property tests
- Pros: Finds deep bugs
- Cons: Slow, complex setup
- Deferred: Add cargo-fuzz later for critical parsers
Consequences
Positive
- High confidence in correctness
- Fast unit tests (seconds)
- Integration tests catch real issues
- E2E tests validate gRPC API
- Property tests catch edge cases
- Benchmarks prevent regressions
Negative
- More code to maintain
- Integration tests require database setup
- E2E tests slower (seconds)
- Async tests more complex
Neutral
- 80%+ coverage enforced in CI
- Test utilities shared across tests
Implementation Notes
Directory Structure
proxy/ ├── src/ │ ├── lib.rs │ ├── main.rs │ ├── backend/ │ │ ├── mod.rs # Contains #[cfg(test)] mod tests │ │ ├── sqlite.rs │ │ └── postgres.rs │ └── keyvalue/ │ ├── mod.rs │ └── service.rs # Contains #[cfg(test)] mod tests ├── tests/ │ ├── common/ │ │ └── mod.rs # Shared test utilities │ ├── integration_test.rs │ └── e2e/ │ └── keyvalue_test.rs └── benches/ └── keyvalue_bench.rs
### Running Tests
Unit tests only (fast)
cargo test --lib
Integration tests
cargo test --test integration_test
E2E tests
cargo test --test keyvalue_test
All tests
cargo test
With coverage
cargo tarpaulin --out Html --output-dir coverage
Benchmarks
cargo bench
Property tests (more iterations)
PROPTEST_CASES=10000 cargo test
### CI Configuration
.github/workflows/rust-test.yml
jobs: test: steps: - name: Unit Tests run: cargo test --lib
- name: Integration Tests
run: |
# Start test databases
docker-compose -f docker-compose.test.yml up -d
cargo test --tests
docker-compose -f docker-compose.test.yml down
- name: Coverage
run: |
cargo tarpaulin --out Xml
if [ $(grep -oP 'line-rate="\K[0-9.]+' coverage.xml | head -1 | awk '{print ($1 < 0.8)}') -eq 1 ]; then
echo "Coverage below 80%"
exit 1
fi
- name: Benchmarks (ensure no regression)
run: cargo bench --no-fail-fast
### Dependencies
[dev-dependencies] tokio-test = "0.4" proptest = "1.4" criterion = { version = "0.5", features = ["async_tokio"] } testcontainers = "0.15"
## References
- [Rust Book: Testing](https://doc.rust-lang.org/book/ch11-00-testing.html)
- [tokio::test documentation](https://docs.rs/tokio/latest/tokio/attr.test.html)
- [proptest documentation](https://docs.rs/proptest)
- [criterion documentation](https://docs.rs/criterion)
- ADR-001: Rust for the Proxy
- ADR-019: Rust Async Concurrency Patterns
- ADR-015: Go Testing Strategy (parallel Go patterns)
## Revision History
- 2025-10-07: Initial draft and acceptance