buster/api/documentation/testing.mdc

1731 lines
51 KiB
Plaintext

---
description: This is designed to help understand how to do testing in this project.
globs:
alwaysApply: false
---
# Testing Rules and Best Practices
## Testing Organization Best Practices
### Recommended Mocking Libraries
For consistent mocking across the codebase, use these libraries:
| Library | Purpose | Use Case |
|---------|---------|----------|
| **mockito** | HTTP service mocking | Mocking external API calls |
| **mockall** | Trait/struct mocking | Mocking database and service interfaces |
| **mock-it** | Simple mocking | Quick mocks for simple interfaces |
| **wiremock** | Advanced HTTP mocking | Complex API scenarios with extended matching |
Always prefer dependency injection patterns to enable easy mocking of dependencies.
### Library Code Testing Structure
- **Unit Tests**: Include unit tests inside library source files using `#[cfg(test)]` modules
- **Integration Tests**: Place integration tests in the lib's `tests/` directory
- **Test Utils**: Create a private `test_utils.rs` module in each lib for test utilities specific to that lib
- **Scope Isolation**: Test utilities should only be shared within their scope (e.g., libs/database, libs/agents)
- **Conditional Testing**: Use conditional compilation or runtime checks to make tests non-blocking (e.g., skip tests if a required API key is missing)
### API (src) Testing Structure
- Maintain API-specific tests in the `/api/tests/` directory
- Organize integration tests to mirror the API's route structure
- Keep API-specific test utilities in the shared test common directory
### Testing Configuration
- Use cargo features to make running specific test groups easier
- Consider using cargo-nextest for parallel test execution
- Add test tags to group tests by functionality or component
### Running Tests in Different Scopes
- **Run all tests**: `cargo test`
- **Run library-specific tests**: `cargo test -p <library-name>`
- **Run a specific test**: `cargo test <test_name>`
- **Run tests with specific pattern**: `cargo test -- <pattern>`
- **Run tests conditionally**: Use features to enable/disable test categories
```toml
# In Cargo.toml
[features]
integration-tests = []
unit-tests = []
```
```rust
// In code
#[cfg(test)]
#[cfg(feature = "integration-tests")]
mod integration_tests {
// Test code...
}
```
Then, run: `cargo test --features integration-tests`
### Examples of Proper Test Organization
#### Example: Library Unit Tests (`#[cfg(test)]` Module)
```rust
// File: libs/braintrust/src/helpers.rs
// Implementation code...
/// Get system message from a stored prompt
pub async fn get_prompt_system_message(client: &BraintrustClient, prompt_id: &str) -> Result<String> {
// Function implementation...
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use dotenv::dotenv;
#[tokio::test]
async fn test_get_prompt_system_message() -> Result<()> {
// Load environment variables
dotenv().ok();
// Skip test if no API key is available (non-blocking)
if env::var("BRAINTRUST_API_KEY").is_err() {
println!("Skipping test_get_prompt_system_message: No API key available");
return Ok(());
}
// Test implementation...
Ok(())
}
}
```
#### Example: Library Integration Tests (Separate Directory)
```rust
// File: libs/handlers/tests/metrics/delete_metric_test.rs
use handlers::metrics::delete_metric_handler;
use database::models::MetricFile;
use crate::common::fixtures::create_test_metric;
use anyhow::Result;
/// Integration test for the delete_metric_handler
#[tokio::test]
async fn test_delete_metric_integration() -> Result<()> {
// Setup test environment
let test_db = setup_test_db().await?;
// Create and insert test data
let test_metric = create_test_metric(&test_db).await?;
// Test the handler functionality
let result = delete_metric_handler(&test_metric.id, &test_db.user_id).await?;
// Verify results
assert!(result.deleted_at.is_some());
// Clean up test data
test_db.cleanup().await?;
Ok(())
}
```
#### Example: Library-Specific Test Utilities
```rust
// File: libs/database/src/test_utils.rs
use crate::models::{User, Organization};
use crate::pool::PgPool;
use uuid::Uuid;
use anyhow::Result;
/// Test utilities specific to the database library
pub struct DatabaseTestUtils {
pub pool: PgPool,
pub test_id: String,
}
impl DatabaseTestUtils {
pub async fn new() -> Result<Self> {
// Initialize test database connection
let pool = create_test_pool().await?;
let test_id = Uuid::new_v4().to_string();
Ok(Self { pool, test_id })
}
pub async fn create_test_user(&self) -> Result<User> {
// Create a test user in the database
// ...
}
pub async fn cleanup(&self) -> Result<()> {
// Clean up test data
// ...
}
}
```
## General Testing Guidelines
- All tests must be async and use tokio test framework
- Tests should be well-documented with clear test case descriptions and expected outputs
- Each test should focus on testing a single piece of functionality
- Tests should be independent and not rely on the state of other tests
- Use meaningful test names that describe what is being tested
- **Design for testability**: Code should be written with testing in mind
- Favor dependency injection for easier mocking
- Separate business logic from external dependencies
- Use interfaces/traits to abstract dependencies
- Write small, focused functions that do one thing well
- Avoid global state and side effects where possible
## Unit Tests
- Unit tests should be inline with the code they are testing using `#[cfg(test)]` modules
- Each public function should have corresponding unit tests
- **Important**: Unit tests should NEVER connect to external services or databases
- Mock all external dependencies:
- Use mockito for HTTP services
- Use trait-based mocks for database operations
- Create mock implementations of dependencies
- **IMPORTANT**: Always use the async version of mockito:
```rust
// Correct async approach
let server = mockito::Server::new_async().await;
```
Instead of:
```rust
// Incorrect: Not compatible with tokio runtime
let server = mockito::Server::new();
```
- When using mockito in async tests, ensure all mock setup is done asynchronously
- Test both success and error cases
- Test edge cases and boundary conditions
- Structure unit tests to maximize code coverage
- Use dependency injection to make code easily testable with mocks
## Integration Tests
Integration tests are specifically designed to verify the interaction with external services and should be separate from the main codebase.
### Integration Test Structure
- **Location**: Integration tests should always be in a separate `tests/` directory, never mixed with the main code
- **External Interaction**: Unlike unit tests, integration tests are explicitly designed to interact with external services (databases, APIs, etc.)
- **Configuration**:
- Configuration should come from environment variables via `.env` files
- Use `dotenv` to load environment variables during test setup
- Example: Database connection parameters should come from `.env`
- Prefer `.env.test` for test-specific configurations
```rust
// Example of proper integration test setup
use dotenv::dotenv;
use diesel_async::AsyncPgConnection;
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use deadpool_diesel::postgres::Pool;
// Setup function that loads from environment
async fn setup_test_environment() -> Result<TestContext> {
// Load environment variables, preferring .env.test if available
if std::path::Path::new(".env.test").exists() {
dotenv::from_filename(".env.test").ok();
} else {
dotenv().ok();
}
// Create database pool from environment variables
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set for integration tests");
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_url);
let pool = Pool::builder(config).max_size(5).build()?;
// Return test context with real connections to external services
Ok(TestContext { pool, ... })
}
```
### Library-Specific Integration Tests
- Each library should have its own integration tests in its `tests/` directory
- These tests should focus ONLY on the library's public API
- Integration tests should NOT cross library boundaries
- Example structure:
```
libs/
├── database/
│ ├── src/
│ └── tests/ # Database-specific integration tests
│ ├── common/ # Database test utilities
│ └── models/ # Tests for database models
├── handlers/
│ ├── src/
│ └── tests/ # Handler-specific integration tests
│ ├── common/ # Handler test utilities
│ └── users/ # Tests for user handlers
```
### API Integration Tests
- API tests should be in the main `/api/tests/` directory
- Structure should mirror the API routes
- Focus on end-to-end testing of the entire API
- Example structure:
```
api/
├── src/
└── tests/
├── common/ # Shared API test utilities
└── integration/ # Organized by API resource
├── users/ # User endpoint tests
├── threads/ # Thread endpoint tests
└── messages/ # Message endpoint tests
```
### Modular Test Utilities
- **Scope Isolation**: Test utilities should be scoped to their specific domain:
- Database utilities in `libs/database/tests/common/`
- Handler utilities in `libs/handlers/tests/common/`
- API utilities in `/api/tests/common/`
- **Modular Structure**: Organize test utilities by function:
```
tests/common/
├── mod.rs # Re-exports common utilities
├── db.rs # Database test helpers
├── http.rs # HTTP client testing
├── auth.rs # Authentication test helpers
└── fixtures/ # Test data fixtures
├── mod.rs # Re-exports all fixtures
├── users.rs # User test data
└── messages.rs # Message test data
```
- **No Global Utilities**: Avoid creating global test utilities shared across all components
- **Library-Specific Fixtures**: Test fixtures should be specific to their domain
### Test Data Isolation
- Each test should create its own isolated data
- Use unique identifiers (UUIDs) to mark test data
- Clean up after tests complete
- Use test_id pattern to track and clean up test data:
```rust
#[tokio::test]
async fn test_user_creation() -> Result<()> {
let test_id = Uuid::new_v4().to_string();
let pool = get_db_pool().await?;
// Create test data with test_id marker
let test_user = UserBuilder::new()
.with_email("test@example.com")
.with_test_id(&test_id) // Mark this as test data
.build();
// Insert test data
test_user.insert(&pool).await?;
// Run test assertions...
// Clean up all data with this test_id
cleanup_test_data(&pool, &test_id).await?;
Ok(())
}
```
### Integration Test Setup Best Practices
#### Environment Configuration
- Create a centralized test environment setup system:
```rust
// tests/common/env.rs
use std::sync::Once;
use dotenv::dotenv;
static ENV_SETUP: Once = Once::new();
/// Initialize test environment once per test process
pub fn init_test_env() {
ENV_SETUP.call_once(|| {
// First check for .env.test
if std::path::Path::new(".env.test").exists() {
dotenv::from_filename(".env.test").ok();
} else {
// Fall back to regular .env
dotenv().ok();
}
// Set additional test-specific env vars
if std::env::var("TEST_ENV").is_err() {
std::env::set_var("TEST_ENV", "test");
}
// Initialize logger for tests
if std::env::var("TEST_LOG").is_ok() {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.init();
}
});
}
```
#### Database Setup
- All integration tests must import and utilize the application's schema from [schema.rs](mdc:src/database/schema.rs)
- Database models from [models.rs](mdc:src/database/models.rs) should be used for test data setup and verification
- Create a robust database test helper:
```rust
// tests/common/db.rs
use anyhow::Result;
use diesel_async::{AsyncPgConnection, AsyncConnection};
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use deadpool_diesel::postgres::Pool;
use uuid::Uuid;
pub struct TestDb {
pub pool: Pool,
pub test_id: String, // Unique identifier for this test run
}
impl TestDb {
/// Creates a new isolated test database environment
pub async fn new() -> Result<Self> {
// Initialize environment
crate::common::env::init_test_env();
// Generate unique test identifier
let test_id = Uuid::new_v4().to_string();
// Get database config from env
let database_url = std::env::var("TEST_DATABASE_URL")
.expect("TEST_DATABASE_URL must be set for tests");
// Create connection manager and pool
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_url);
let pool = Pool::builder(config)
.max_size(5)
.build()?;
let db = Self { pool, test_id };
// Setup initial test data
db.setup_schema().await?;
Ok(db)
}
/// Setup schema and initial test data
async fn setup_schema(&self) -> Result<()> {
let conn = &mut self.pool.get().await?;
// Run migrations or setup tables
// Add any shared setup like creating test users
Ok(())
}
/// Clean up test data created by this test instance
pub async fn cleanup(&self) -> Result<()> {
let conn = &mut self.pool.get().await?;
// Delete data with test_id marker
// Reset sequences if needed
Ok(())
}
/// Create test data with proper isolation using the test_id
pub async fn create_test_data(&self) -> Result<()> {
// Create test data tagged with test_id for isolation
Ok(())
}
}
impl Drop for TestDb {
fn drop(&mut self) {
// Optionally perform synchronous cleanup on drop
// This ensures cleanup even if tests panic
}
}
```
#### Required Environment Variables
Create a `.env.test` file with necessary test configurations:
```env
TEST_DATABASE_URL=postgres://user:pass@localhost/test_db
TEST_API_KEY=test-key
TEST_ENV=test
# Optional: enable test logging
# TEST_LOG=debug
```
**IMPORTANT**: All integration tests must have all required services configured and accessible via environment variables loaded through dotenv. Tests should never be written with hardcoded service configurations.
#### Service Mocks
Create reusable mock services for common external dependencies:
```rust
// tests/common/mocks/http_client.rs
pub struct MockHttpClient {
server: mockito::Server,
}
impl MockHttpClient {
pub async fn new() -> Self {
Self {
server: mockito::Server::new_async().await,
}
}
pub fn url(&self) -> String {
self.server.url()
}
pub fn mock_success_response(&self, path: &str, body: &str) -> mockito::Mock {
self.server
.mock("GET", path)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create()
}
pub fn mock_error_response(&self, path: &str, status: usize) -> mockito::Mock {
self.server
.mock("GET", path)
.with_status(status)
.create()
}
}
```
## Test Structure
```rust
#[cfg(test)]
mod tests {
use super::*;
use mockito;
use tokio;
// Optional: Setup function for common test initialization
async fn setup() -> TestContext {
// Setup code here
}
#[tokio::test]
async fn test_name() {
// Test case description in comments
// Expected output in comments
// Arrange
// Setup test data and dependencies
// Act
// Execute the function being tested
// Assert
// Verify the results
}
}
```
## Mocking Guidelines for Async Testing
### Mockito for HTTP Service Mocking
Mockito is the primary tool for mocking HTTP services in tests. When using mockito in an async context:
```rust
// CORRECT: Create async mockito server
#[tokio::test]
async fn test_http_client() -> Result<()> {
// Always use the async version for tokio compatibility
let server = mockito::Server::new_async().await;
// Setup the mock with expected request and response
let mock = server
.mock("GET", "/api/data")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"key": "value"}"#)
.create();
// Use the server's URL with your client
let client = YourHttpClient::new(&server.url());
let response = client.get_data().await?;
// Verify the mock was called as expected
mock.assert();
Ok(())
}
```
#### Mockito Best Practices:
- **Always use `Server::new_async()`** instead of `Server::new()` for tokio compatibility
- **Match complex requests** with matchers:
```rust
// Match JSON request bodies
server.mock("POST", "/api/users")
.match_body(mockito::Matcher::Json(json!({"name": "Test User"})))
.with_status(201)
.create();
// Match headers
server.mock("GET", "/api/protected")
.match_header("authorization", "Bearer token")
.with_status(200)
.create();
```
- **Test different response scenarios** (success, errors, timeouts):
```rust
// Success response
let success_mock = server.mock("GET", "/api/resource/1")
.with_status(200)
.with_body(r#"{"id": "1", "name": "Resource"}"#)
.create();
// Error response
let error_mock = server.mock("GET", "/api/resource/999")
.with_status(404)
.with_body(r#"{"error": "Not found"}"#)
.create();
// Timeout simulation
let timeout_mock = server.mock("GET", "/api/slow")
.with_delay(std::time::Duration::from_secs(10))
.with_status(200)
.create();
```
- **Create reusable mock setup functions** for common patterns
### Additional Mocking Libraries for Async Rust
#### 1. Mock_it for trait mocking
For mocking traits and interfaces:
```rust
use mock_it::Mock;
#[async_trait]
pub trait Database {
async fn get_user(&self, id: &str) -> Result<User>;
}
// Create a mock implementation
let mut db_mock = Mock::new();
// Set up expectations with async behavior
db_mock.expect_call(matching!("get_user", arg if arg == "123"))
.returns_async(Ok(User {
id: "123".to_string(),
name: "Test User".to_string(),
}));
// Test using the mock
let result = db_mock.get_user("123").await?;
assert_eq!(result.name, "Test User");
```
#### 2. Async-std's Async-Mock
For async-std runtime testing:
```rust
use async_std::test;
use async_mock::AsyncMock;
#[derive(AsyncMock)]
#[async_mock(UserRepository)]
pub trait UserRepository {
async fn find_user(&self, id: &str) -> Result<User>;
}
#[test]
async fn test_user_service() {
// Create a mock repository
let mock_repo = MockUserRepository::new();
// Setup expectations
mock_repo.expect_find_user()
.with(eq("123"))
.returns(Ok(User { id: "123".to_string(), name: "User" }));
// Test service with mock
let service = UserService::new(mock_repo);
let user = service.get_user_data("123").await?;
assert_eq!(user.name, "User");
}
```
#### 3. WireMock for Complex HTTP Mocking
For extensive API mocking scenarios:
```rust
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path};
#[tokio::test]
async fn test_api_client() -> Result<()> {
// Start mock server
let mock_server = MockServer::start().await;
// Setup mock
Mock::given(method("GET"))
.and(path("/api/users"))
.respond_with(ResponseTemplate::new(200)
.set_body_json(json!([
{"id": "1", "name": "User 1"},
{"id": "2", "name": "User 2"}
]))
)
.mount(&mock_server)
.await;
// Create client using mock URL
let client = ApiClient::new(&mock_server.uri());
// Test the client
let users = client.list_users().await?;
assert_eq!(users.len(), 2);
Ok(())
}
```
#### 4. Tower's `mock` Module for Service Mocking
For mocking Tower services:
```rust
use tower_test::{mock, assert_request_eq};
use tower::Service;
#[tokio::test]
async fn test_tower_service() {
// Create mock service
let (mut mock, handle) = mock::pair::<Request<()>, Response<()>>();
// Spawn a task that processes requests
tokio::spawn(async move {
// Send successful response
if let Ok(request) = handle.recv().await {
handle.send_response(
Response::builder()
.status(200)
.body(())
.unwrap()
);
}
});
// Create your service that uses the mock
let service = YourService::new(mock);
// Make request through your service
let response = service
.call(Request::get("/test").body(()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), 200);
}
```
### Mocking Database Access for Unit Tests
For unit tests, completely mock the database access rather than using real connections:
```rust
use mock_it::Mock;
use async_trait::async_trait;
use uuid::Uuid;
// Define a repository trait that can be mocked
#[async_trait]
pub trait UserRepository {
async fn find_user_by_id(&self, id: &str) -> Result<Option<User>>;
async fn create_user(&self, email: &str, name: &str) -> Result<User>;
async fn delete_user(&self, id: &str) -> Result<()>;
}
// In unit tests, create a mock implementation
#[tokio::test]
async fn test_user_service_with_mocked_db() -> Result<()> {
// Create mock repository
let mut repo_mock = Mock::new();
// Setup expectations for database operations
let test_user = User {
id: Uuid::new_v4().to_string(),
email: "test@example.com".to_string(),
name: "Test User".to_string(),
created_at: chrono::Utc::now(),
};
// Mock the find_user_by_id method
repo_mock.expect_call(matching!("find_user_by_id", arg if arg == "123"))
.returns_async(Ok(Some(test_user.clone())));
// Create the service with the mocked repository
let service = UserService::new(repo_mock);
// Test the service logic
let user = service.get_user_profile("123").await?;
// Assertions to verify service logic
assert_eq!(user.email, "test@example.com");
Ok(())
}
```
### Mocking Diesel Models for Unit Tests
For mocking Diesel model operations:
```rust
use mockall::predicate::*;
use mockall::mock;
// Create a mock for database operations
mock! {
pub DieselDb {
async fn find_user(&self, id: &str) -> Result<Option<User>>;
async fn create_user(&self, data: NewUser) -> Result<User>;
}
}
// User service that depends on database
struct UserService {
db: Box<dyn DieselDbTrait>,
}
impl UserService {
fn new(db: impl DieselDbTrait + 'static) -> Self {
Self { db: Box::new(db) }
}
async fn get_user(&self, id: &str) -> Result<Option<User>> {
self.db.find_user(id).await
}
}
// Unit test with mocked database
#[tokio::test]
async fn test_get_user() -> Result<()> {
// Create mock DB
let mut mock_db = MockDieselDb::new();
// Setup expectations
let test_user = User {
id: "user123".to_string(),
name: "Test User".to_string(),
email: "test@example.com".to_string(),
created_at: chrono::Utc::now(),
};
mock_db.expect_find_user()
.with(eq("user123"))
.times(1)
.returning(move |_| Ok(Some(test_user.clone())));
// Create service with mock
let service = UserService::new(mock_db);
// Test service method
let user = service.get_user("user123").await?;
// Verify results
assert!(user.is_some());
let user = user.unwrap();
assert_eq!(user.email, "test@example.com");
Ok(())
}
```
### Combining Different Mocking Approaches
For complex systems, combine different mocking libraries:
```rust
#[tokio::test]
async fn test_complex_system() -> Result<()> {
// Set up HTTP mock
let server = mockito::Server::new_async().await;
// Set up mock responses
let api_mock = server
.mock("GET", "/api/data")
.with_status(200)
.with_body(r#"{"data": "value"}"#)
.create();
// Create mock database implementation
let mut db_mock = Mock::new();
db_mock.expect_call(matching!("save_data", _))
.returns_async(Ok(()));
// Initialize system with mocks
let client = HttpClient::new(&server.url());
let system = YourSystem::new(client, db_mock);
// Test system behavior
let result = system.process_and_save().await?;
assert!(result.is_success());
// Verify HTTP mock was called
api_mock.assert();
Ok(())
}
```
## Error Testing
- Test error conditions and error handling
- Verify error messages and error types
- Test timeout scenarios
- Test connection failures
- Test invalid input handling
## Database Testing
- Use a separate test database for integration tests
- Clean up test data after tests complete
- Test database connection error handling
## Test Output
- Tests should provide clear error messages
- Use descriptive assert messages
- Print relevant debug information in test failures
- Log test execution progress for long-running tests
## CI/CD Considerations
- All tests must pass in CI environment
- Tests should be reproducible
- Tests should not have external dependencies that could fail CI
- Test execution time should be reasonable
## Example Unit Test with Mockito
```rust
#[cfg(test)]
mod tests {
use super::*;
use mockito;
use tokio;
use anyhow::Result;
#[tokio::test]
async fn test_api_call_success() -> Result<()> {
// Test case: Successful API call returns expected response
// Expected: Response contains user data with status 200
// Create async mockito server
let server = mockito::Server::new_async().await;
// Setup the mock with expected request and response
let mock = server
.mock("GET", "/api/user")
.match_header("authorization", "Bearer test-token")
.with_status(200)
.with_body(r#"{"id": "123", "name": "Test User"}"#)
.create();
// Create API client with mock server URL
let client = ApiClient::new(server.url());
// Execute the function being tested
let response = client.get_user("test-token").await?;
// Verify results
assert_eq!(response.id, "123");
assert_eq!(response.name, "Test User");
// Verify that the mock was called as expected
mock.assert();
Ok(())
}
#[tokio::test]
async fn test_api_call_error_handling() -> Result<()> {
// Test case: API returns error status
// Expected: Function returns appropriate error
let server = mockito::Server::new_async().await;
// Setup mock with error response
let mock = server
.mock("GET", "/api/user")
.with_status(401)
.with_body(r#"{"error": "Unauthorized"}"#)
.create();
let client = ApiClient::new(server.url());
// Execute and verify it returns an error
let result = client.get_user("invalid-token").await;
assert!(result.is_err());
// Verify the error contains the expected message
let err = result.unwrap_err();
assert!(err.to_string().contains("Unauthorized"));
mock.assert();
Ok(())
}
}
```
## Example Application Integration Test
```rust
use crate::tests::common::{db::TestDb, env::init_test_env};
use crate::database::{models, schema};
use crate::handlers::users::get_user_handler;
use anyhow::Result;
#[tokio::test]
async fn test_user_creation_flow() -> Result<()> {
// Initialize test environment with proper setup
init_test_env();
// Setup test database with isolation
let test_db = TestDb::new().await?;
// Create test user using fixture or builder pattern
let test_user = models::User::builder()
.email("test@example.com")
.name("Test User")
.build();
// Insert test data with proper error handling
diesel::insert_into(schema::users::table)
.values(&test_user)
.execute(&mut test_db.pool.get().await?)
.await?;
// Create HTTP client for API testing
let client = reqwest::Client::new();
// Test the API endpoint
let response = client
.get(&format!("{}/api/users/{}", TEST_API_URL, test_user.id))
.header("Authorization", "Bearer test-token")
.send()
.await?;
// Verify response status
assert_eq!(response.status(), 200);
// Parse and verify response body
let user_response = response.json::<UserResponse>().await?;
assert_eq!(user_response.email, "test@example.com");
// Clean up test data
test_db.cleanup().await?;
Ok(())
}
```
## Example Library Integration Test
This example shows how to structure integration tests for libraries in the `libs/` directory:
```rust
// File: libs/my_library/tests/integration_test.rs
use my_library::{Client, Config};
use anyhow::Result;
// Import test utilities if needed
// Testing utilities can be in the library's tests/common/ directory
#[tokio::test]
async fn test_client_performs_operation() -> Result<()> {
// Setup mock server for external API
let server = mockito::Server::new_async().await;
// Configure mock responses
let mock = server
.mock("POST", "/api/resource")
.with_status(201)
.with_body(r#"{"id": "new-resource-id"}"#)
.create();
// Configure the client to use the mock server
let config = Config::builder()
.base_url(server.url())
.timeout(std::time::Duration::from_secs(5))
.build();
let client = Client::new(config);
// Call the library function being tested
let result = client.create_resource("test-resource").await?;
// Verify the library correctly processed the response
assert_eq!(result.id, "new-resource-id");
// Verify the mock was called as expected
mock.assert();
Ok(())
}
```
## Common Test Utilities
A comprehensive set of testing utilities has been implemented in the `tests/common/` directory. These utilities provide a standardized approach to testing throughout the codebase and should be used for all new tests. The utilities address common testing patterns and provide a consistent interface for test code.
### Common Test Module Structure
```
tests/
├── common/
│ ├── mod.rs # Main module that re-exports all utilities
│ ├── env.rs # Environment setup utilities
│ ├── db.rs # Database testing utilities with test isolation
│ ├── http/ # HTTP testing utilities
│ │ ├── mock_server.rs # MockServer wrapper for mockito
│ │ ├── client.rs # TestHttpClient for API requests
│ │ └── mod.rs # HTTP module exports
│ ├── fixtures/ # Test data fixtures
│ │ ├── mod.rs # Exports all fixtures
│ │ ├── builder.rs # FixtureBuilder trait and helpers
│ │ ├── users.rs # User-related test data
│ │ └── threads.rs # Thread-related test data
│ ├── assertions/ # Test assertion utilities
│ │ ├── mod.rs # Exports all assertions
│ │ ├── response.rs # HTTP response assertions
│ │ └── model.rs # Model validation assertions
│ ├── matchers/ # Matcher utilities for mockito
│ │ ├── mod.rs # Exports all matchers
│ │ ├── json.rs # JSON matchers for request bodies
│ │ └── headers.rs # Header matchers for requests
│ └── helpers.rs # General test helper functions
└── integration/ # Integration test files
```
### Environment Setup Utilities
The `env.rs` module provides utilities for setting up the test environment:
```rust
// tests/common/env.rs
use std::sync::Once;
use dotenv::dotenv;
static ENV_SETUP: Once = Once::new();
/// Initialize test environment once per test process
pub fn init_test_env() {
ENV_SETUP.call_once(|| {
// First check for .env.test
if std::path::Path::new(".env.test").exists() {
dotenv::from_filename(".env.test").ok();
} else {
// Fall back to regular .env
dotenv().ok();
}
// Set additional test-specific env vars
if std::env::var("TEST_ENV").is_err() {
std::env::set_var("TEST_ENV", "test");
}
// Initialize logger for tests
if std::env::var("TEST_LOG").is_ok() {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.init();
}
});
}
/// Get a config value from environment with fallback
pub fn get_test_config(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
```
### Database Testing Utilities
The `db.rs` module provides utilities for database testing with proper test isolation:
```rust
// tests/common/db.rs
use anyhow::Result;
use diesel_async::{AsyncPgConnection, AsyncConnection};
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use deadpool_diesel::postgres::Pool;
use uuid::Uuid;
pub struct TestDb {
pub pool: Pool,
pub test_id: String, // Unique identifier for this test run
}
impl TestDb {
/// Creates a new isolated test database environment
pub async fn new() -> Result<Self> {
// Initialize environment
crate::common::env::init_test_env();
// Generate unique test identifier
let test_id = Uuid::new_v4().to_string();
// Get database config from env
let database_url = std::env::var("TEST_DATABASE_URL")
.expect("TEST_DATABASE_URL must be set for tests");
// Create connection manager and pool
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_url);
let pool = Pool::builder(config)
.max_size(5)
.build()?;
let db = Self { pool, test_id };
// Optional: Setup initial test data
// db.setup_schema().await?;
Ok(db)
}
/// Get a connection from the pool
pub async fn get_conn(&self) -> Result<deadpool_diesel::postgres::Connection> {
Ok(self.pool.get().await?)
}
/// Clean up test data created by this test instance
pub async fn cleanup(&self) -> Result<()> {
// Example: Clean up tables used in tests
// let conn = &mut self.get_conn().await?;
// diesel::delete(schema::users::table)
// .filter(schema::users::test_id.eq(&self.test_id))
// .execute(conn)
// .await?;
Ok(())
}
/// Get the unique test ID for this test instance
pub fn test_id(&self) -> &str {
&self.test_id
}
}
impl Drop for TestDb {
fn drop(&mut self) {
// Optional synchronous cleanup fallback
}
}
```
### HTTP Testing Utilities
The `http/` directory contains utilities for HTTP testing, including a MockServer wrapper and a TestHttpClient:
```rust
// tests/common/http/mock_server.rs
use anyhow::Result;
use mockito::{self, Mock, Server};
use serde_json::Value;
/// MockServer is a wrapper around mockito::Server
pub struct MockServer {
server: Server,
}
impl MockServer {
/// Create a new MockServer
pub async fn new() -> Result<Self> {
Ok(Self {
server: Server::new_async().await,
})
}
/// Get the base URL of the mock server
pub fn url(&self) -> String {
self.server.url()
}
/// Mock a GET request with JSON response
pub fn mock_get_json<T: serde::Serialize>(&self, path: &str, response: &T) -> Result<Mock> {
let body = serde_json::to_string(response)?;
Ok(self.server
.mock("GET", path)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create())
}
/// Mock a POST request with JSON request and response
pub fn mock_post_json<T: serde::Serialize>(&self,
path: &str,
request_matcher: Option<Value>,
response: &T) -> Result<Mock> {
let body = serde_json::to_string(response)?;
let mut mock = self.server
.mock("POST", path)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body);
// Add request body matcher if provided
if let Some(req_body) = request_matcher {
mock = mock.match_body(crate::common::matchers::json::json_contains(req_body));
}
Ok(mock.create())
}
/// Mock an error response
pub fn mock_error(&self, path: &str, method: &str, status: usize, message: &str) -> Result<Mock> {
let error_body = serde_json::json!({
"error": message,
});
Ok(self.server
.mock(method, path)
.with_status(status)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&error_body)?)
.create())
}
}
// tests/common/http/client.rs
use anyhow::Result;
use reqwest::{Client, Method, RequestBuilder, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::time::Duration;
/// TestHttpClient provides a fluent API for making HTTP requests in tests
pub struct TestHttpClient {
client: Client,
base_url: String,
}
impl TestHttpClient {
/// Create a new TestHttpClient with the given base URL
pub fn new(base_url: &str) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("Failed to create HTTP client");
Self {
client,
base_url: base_url.to_string(),
}
}
/// Create a request builder with the given method and path
pub fn request(&self, method: Method, path: &str) -> RequestBuilder {
let url = format!("{}{}", self.base_url, path);
self.client.request(method, url)
}
/// Make a GET request and return the response
pub async fn get(&self, path: &str) -> Result<Response> {
Ok(self.request(Method::GET, path).send().await?)
}
/// Make a POST request with the given body and return the response
pub async fn post<T: serde::Serialize>(&self, path: &str, body: &T) -> Result<Response> {
Ok(self.request(Method::POST, path)
.json(body)
.send()
.await?)
}
/// Make a GET request and parse the response as JSON
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let response = self.get(path).await?;
if response.status().is_success() {
Ok(response.json::<T>().await?)
} else {
let status = response.status();
let error_text = response.text().await?;
anyhow::bail!("Request failed with status {}: {}", status, error_text)
}
}
/// Make a POST request with the given body and parse the response as JSON
pub async fn post_json<T: serde::Serialize, R: DeserializeOwned>(
&self,
path: &str,
body: &T
) -> Result<R> {
let response = self.post(path, body).await?;
if response.status().is_success() {
Ok(response.json::<R>().await?)
} else {
let status = response.status();
let error_text = response.text().await?;
anyhow::bail!("Request failed with status {}: {}", status, error_text)
}
}
}
```
### Fixture Builder Pattern
The `fixtures/builder.rs` file provides a builder pattern for creating test fixtures:
```rust
// tests/common/fixtures/builder.rs
use uuid::Uuid;
/// TestFixture is a trait for test fixtures
pub trait TestFixture {
/// Get the test ID for this fixture
fn test_id(&self) -> Option<&str>;
/// Set the test ID for this fixture
fn with_test_id(self, test_id: &str) -> Self;
}
/// FixtureBuilder is a trait for building test fixtures
pub trait FixtureBuilder<T> {
/// Build the fixture
fn build(&self) -> T;
/// Build the fixture with the given test ID
fn build_with_test_id(&self, test_id: &str) -> T;
}
/// Example builder for a User fixture
#[derive(Default)]
pub struct UserBuilder {
pub email: Option<String>,
pub name: Option<String>,
pub test_id: Option<String>,
}
impl UserBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn email(mut self, email: &str) -> Self {
self.email = Some(email.to_string());
self
}
pub fn name(mut self, name: &str) -> Self {
self.name = Some(name.to_string());
self
}
pub fn test_id(mut self, test_id: &str) -> Self {
self.test_id = Some(test_id.to_string());
self
}
}
impl FixtureBuilder<User> for UserBuilder {
fn build(&self) -> User {
User {
id: Uuid::new_v4(),
email: self.email.clone().unwrap_or_else(|| format!("user-{}@example.com", Uuid::new_v4())),
name: self.name.clone(),
test_id: self.test_id.clone(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
fn build_with_test_id(&self, test_id: &str) -> User {
let mut builder = self.clone();
builder.test_id = Some(test_id.to_string());
builder.build()
}
}
```
### Assertion Utilities
The `assertions/` directory contains utilities for making assertions in tests:
```rust
// tests/common/assertions/response.rs
use anyhow::Result;
use reqwest::Response;
use reqwest::StatusCode;
use serde::de::DeserializeOwned;
use serde_json::Value;
/// Extension trait for reqwest::Response providing assertion methods
pub trait ResponseAssertions {
/// Assert that the response has the given status code
fn assert_status(self, status: StatusCode) -> Self;
/// Assert that the response has a success status code (2xx)
fn assert_success(self) -> Self;
/// Assert that the response has an error status code (4xx or 5xx)
fn assert_error(self) -> Self;
/// Assert that the response contains the given header
fn assert_header(self, name: &str, value: &str) -> Self;
/// Assert that the response body contains the given JSON value
fn assert_json_contains(self, expected: Value) -> Result<Self>;
/// Deserialize the response body as JSON and apply the given assertion function
fn assert_json<T, F>(self, assert_fn: F) -> Result<Self>
where
T: DeserializeOwned,
F: FnOnce(&T) -> bool;
}
impl ResponseAssertions for Response {
fn assert_status(self, status: StatusCode) -> Self {
assert_eq!(self.status(), status, "Expected status code {}, got {}", status, self.status());
self
}
fn assert_success(self) -> Self {
assert!(self.status().is_success(), "Expected success status, got {}", self.status());
self
}
fn assert_error(self) -> Self {
assert!(self.status().is_client_error() || self.status().is_server_error(),
"Expected error status, got {}", self.status());
self
}
fn assert_header(self, name: &str, value: &str) -> Self {
let header_value = self.headers().get(name)
.expect(&format!("Header {} not found", name))
.to_str()
.expect(&format!("Header {} is not valid UTF-8", name));
assert_eq!(header_value, value, "Expected header {} to be {}, got {}", name, value, header_value);
self
}
async fn assert_json_contains(self, expected: Value) -> Result<Self> {
let json = self.json::<Value>().await?;
// Check if expected is a subset of json
assert!(json_contains(&json, &expected),
"Expected JSON to contain {:?}, got {:?}", expected, json);
Ok(self)
}
async fn assert_json<T, F>(self, assert_fn: F) -> Result<Self>
where
T: DeserializeOwned,
F: FnOnce(&T) -> bool,
{
let json = self.json::<T>().await?;
assert!(assert_fn(&json), "JSON assertion failed");
Ok(self)
}
}
// Helper function to check if one JSON value contains another
fn json_contains(json: &Value, expected: &Value) -> bool {
match (json, expected) {
(Value::Object(json_obj), Value::Object(expected_obj)) => {
expected_obj.iter().all(|(k, v)| {
json_obj.get(k).map_or(false, |json_v| json_contains(json_v, v))
})
}
(Value::Array(json_arr), Value::Array(expected_arr)) => {
expected_arr.iter().all(|expected_v| {
json_arr.iter().any(|json_v| json_contains(json_v, expected_v))
})
}
_ => json == expected,
}
}
```
### JSON and Header Matchers
The `matchers/` directory contains utilities for matching JSON and headers in mockito:
```rust
// tests/common/matchers/json.rs
use mockito::Matcher;
use serde_json::Value;
use std::fmt;
/// Create a matcher that checks if a JSON request body contains the given JSON value
pub fn json_contains(expected: Value) -> JsonContainsMatcher {
JsonContainsMatcher { expected }
}
/// Matcher for checking if a JSON request body contains a JSON value
pub struct JsonContainsMatcher {
expected: Value,
}
impl Matcher for JsonContainsMatcher {
fn matches(&self, body: &[u8]) -> bool {
let actual: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(_) => return false,
};
contains_json(&actual, &self.expected)
}
}
impl fmt::Display for JsonContainsMatcher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "JSON containing {}", self.expected)
}
}
/// Helper function to check if one JSON value contains another
fn contains_json(json: &Value, expected: &Value) -> bool {
match (json, expected) {
(Value::Object(json_obj), Value::Object(expected_obj)) => {
expected_obj.iter().all(|(k, v)| {
json_obj.get(k).map_or(false, |json_v| contains_json(json_v, v))
})
}
(Value::Array(json_arr), Value::Array(expected_arr)) => {
expected_arr.iter().all(|expected_v| {
json_arr.iter().any(|json_v| contains_json(json_v, expected_v))
})
}
_ => json == expected,
}
}
// tests/common/matchers/headers.rs
use mockito::Matcher;
use std::collections::HashMap;
use std::fmt;
/// Create a matcher that checks if request headers match the expected headers
pub fn header_matcher(expected_headers: HashMap<String, String>) -> HeaderMatcher {
HeaderMatcher { expected_headers }
}
/// Matcher for checking request headers
pub struct HeaderMatcher {
expected_headers: HashMap<String, String>,
}
impl Matcher for HeaderMatcher {
fn matches(&self, request_headers: &[u8]) -> bool {
let headers_str = match std::str::from_utf8(request_headers) {
Ok(s) => s,
Err(_) => return false,
};
// Parse headers
let headers: HashMap<String, String> = headers_str
.split("\r\n")
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut parts = line.splitn(2, ": ");
let name = parts.next()?;
let value = parts.next()?;
Some((name.to_lowercase(), value.to_string()))
})
.collect();
// Check if expected headers are present
self.expected_headers.iter().all(|(name, value)| {
headers.get(&name.to_lowercase()).map_or(false, |v| v == value)
})
}
}
impl fmt::Display for HeaderMatcher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Headers containing {:?}", self.expected_headers)
}
}
```
### Using These Test Utilities
Here's an example of using these test utilities in an integration test:
```rust
use crate::tests::common::{
db::TestDb,
env::init_test_env,
http::{MockServer, TestHttpClient},
fixtures::UserBuilder,
assertions::ResponseAssertions,
};
use anyhow::Result;
use serde_json::json;
#[tokio::test]
async fn test_user_api() -> Result<()> {
// Initialize environment
init_test_env();
// Setup test database
let test_db = TestDb::new().await?;
// Create test user using builder
let user = UserBuilder::new()
.email("test@example.com")
.name("Test User")
.build_with_test_id(test_db.test_id());
// Insert test user into database
// ...
// Setup mock server for external API
let mock_server = MockServer::new().await?;
// Configure mock responses
let mock = mock_server.mock_get_json("/api/external", &json!({
"status": "success"
}))?;
// Create test HTTP client
let client = TestHttpClient::new("http://localhost:8000");
// Test the API endpoint
let response = client.get(&format!("/api/users/{}", user.id)).await?;
// Assert on the response
response
.assert_status(StatusCode::OK)
.assert_header("content-type", "application/json")
.assert_json_contains(json!({
"id": user.id.to_string(),
"email": "test@example.com"
}))?;
// Verify the mock was called
mock.assert();
// Clean up
test_db.cleanup().await?;
Ok(())
}
```
```