buster/api/documentation/testing.mdc

1714 lines
50 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
- Use the `testkit` library for test IDs and directly access pools from the database library:
```rust
// tests/common/db.rs
use anyhow::Result;
use diesel_async::AsyncPgConnection;
use database::pool::get_pg_pool;
use testkit::test_id;
use uuid::Uuid;
pub struct TestDb {
pub test_id: String, // Unique identifier for this test run
}
impl TestDb {
/// Creates a new isolated test database environment
pub fn new() -> Self {
// Generate unique test identifier
let test_id = test_id();
Self { test_id }
}
/// Get a database connection from the pre-initialized pool
pub async fn get_conn(&self) -> Result<diesel_async::pooled_connection::bb8::PooledConnection<AsyncPgConnection>> {
Ok(get_pg_pool().get().await?)
}
/// Clean up test data created by this test instance
pub async fn cleanup(&self) -> Result<()> {
let mut conn = self.get_conn().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<()> {
let mut conn = self.get_conn().await?;
// 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
The testkit automatically creates a `.env.test` file if one doesn't exist, with necessary default test configurations. You can customize it with:
```env
TEST_DATABASE_URL=postgres://user:pass@localhost/test_db
TEST_POOLER_URL=postgres://user:pass@localhost/test_db
TEST_REDIS_URL=redis://localhost:6379
TEST_DATABASE_POOL_SIZE=10
TEST_SQLX_POOL_SIZE=10
TEST_API_KEY=test-key
TEST_ENV=test
# Optional: enable test logging
# TEST_LOG=debug
```
**IMPORTANT**: All integration tests should use the pools from the database library (already initialized by the testkit at build time). Tests should never be written with hardcoded service configurations or manual pool creation.
#### 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;
use crate::database::{models, schema, pool};
use crate::handlers::users::get_user_handler;
use anyhow::Result;
#[tokio::test]
async fn test_user_creation_flow() -> Result<()> {
// Setup test database with isolation
// TestDb handles test ID generation and connection access
let test_db = TestDb::new();
// Create test user using fixture or builder pattern
let test_user = models::User::builder()
.email("test@example.com")
.name("Test User")
.test_id(&test_db.test_id) // Set test ID for isolation
.build();
// Insert test data with proper error handling
let mut conn = test_db.get_conn().await?;
diesel::insert_into(schema::users::table)
.values(&test_user)
.execute(&mut conn)
.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(())
}
```
```