2025-03-12 01:34:18 +08:00
---
description: This is designed to help understand how to do testing in this project.
globs:
alwaysApply: false
---
# Testing Rules and Best Practices
## 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
2025-03-19 13:53:38 +08:00
- **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
2025-03-12 01:34:18 +08:00
## 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
- Mock external dependencies using mockito for HTTP calls
2025-03-19 13:53:38 +08:00
- **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
2025-03-12 01:34:18 +08:00
- Test both success and error cases
- Test edge cases and boundary conditions
2025-03-19 13:53:38 +08:00
- Structure unit tests to maximize code coverage
2025-03-12 01:34:18 +08:00
## Integration Tests
2025-03-19 13:53:38 +08:00
- Integration tests for the main application should be placed in the `/tests` directory
- **For libraries in the `libs/` directory**:
- Integration tests should be within each library in their own `tests/` directory
- Each library should be responsible for testing its own public API
- Follow the Rust convention of placing integration tests in the library's `tests/` directory
2025-03-12 01:34:18 +08:00
- Organize integration tests to mirror the main codebase structure
- Each major feature/resource should have its own test file
- Test the interaction between multiple components
- Use real dependencies when possible, mock only what's necessary
- Include end-to-end workflow tests
2025-03-19 13:53:38 +08:00
- Test realistic user workflows rather than individual functions
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
### Integration Test Setup Best Practices
#### Environment Configuration
- Create a centralized test environment setup system:
2025-03-12 01:34:18 +08:00
```rust
2025-03-19 13:53:38 +08:00
// tests/common/env.rs
use std::sync::Once;
2025-03-12 01:34:18 +08:00
use dotenv::dotenv;
2025-03-19 13:53:38 +08:00
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();
}
});
2025-03-12 01:34:18 +08:00
}
```
2025-03-19 13:53:38 +08:00
#### 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:
2025-03-12 01:34:18 +08:00
```rust
2025-03-19 13:53:38 +08:00
// 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
}
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
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(())
}
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
/// 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
}
2025-03-12 01:34:18 +08:00
}
```
2025-03-19 13:53:38 +08:00
#### Required Environment Variables
2025-03-12 01:34:18 +08:00
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
2025-03-19 13:53:38 +08:00
# Optional: enable test logging
# TEST_LOG=debug
```
#### 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()
}
}
2025-03-12 01:34:18 +08:00
```
## 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
- Use mockito for HTTP service mocks
- Create mock responses that match real API responses
- Include both successful and error responses in mocks
- Clean up mocks after tests complete
## 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
2025-03-19 13:53:38 +08:00
## Example Unit Test with Mockito
2025-03-12 01:34:18 +08:00
```rust
#[cfg(test)]
mod tests {
use super::*;
use mockito;
use tokio;
2025-03-19 13:53:38 +08:00
use anyhow::Result;
2025-03-12 01:34:18 +08:00
#[tokio::test]
2025-03-19 13:53:38 +08:00
async fn test_api_call_success() -> Result<()> {
2025-03-12 01:34:18 +08:00
// Test case: Successful API call returns expected response
// Expected: Response contains user data with status 200
2025-03-19 13:53:38 +08:00
// Create async mockito server
let server = mockito::Server::new_async().await;
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
// Setup the mock with expected request and response
2025-03-12 01:34:18 +08:00
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();
2025-03-19 13:53:38 +08:00
// Create API client with mock server URL
2025-03-12 01:34:18 +08:00
let client = ApiClient::new(server.url());
2025-03-19 13:53:38 +08:00
// Execute the function being tested
let response = client.get_user("test-token").await?;
// Verify results
2025-03-12 01:34:18 +08:00
assert_eq!(response.id, "123");
assert_eq!(response.name, "Test User");
2025-03-19 13:53:38 +08:00
// Verify that the mock was called as expected
2025-03-12 01:34:18 +08:00
mock.assert();
2025-03-19 13:53:38 +08:00
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(())
2025-03-12 01:34:18 +08:00
}
}
```
2025-03-19 13:53:38 +08:00
## Example Application Integration Test
2025-03-12 01:34:18 +08:00
```rust
2025-03-19 13:53:38 +08:00
use crate::tests::common::{db::TestDb, env::init_test_env};
2025-03-12 01:34:18 +08:00
use crate::database::{models, schema};
2025-03-19 13:53:38 +08:00
use crate::handlers::users::get_user_handler;
use anyhow::Result;
2025-03-12 01:34:18 +08:00
#[tokio::test]
2025-03-19 13:53:38 +08:00
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
2025-03-12 01:34:18 +08:00
diesel::insert_into(schema::users::table)
.values(&test_user)
2025-03-19 13:53:38 +08:00
.execute(&mut test_db.pool.get().await?)
.await?;
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
// 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")
2025-03-12 01:34:18 +08:00
.send()
.await?;
2025-03-19 13:53:38 +08:00
// Verify response status
2025-03-12 01:34:18 +08:00
assert_eq!(response.status(), 200);
2025-03-19 13:53:38 +08:00
// 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(())
2025-03-12 01:34:18 +08:00
}
```
## Common Test Utilities
2025-03-19 13:53:38 +08:00
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.
2025-03-12 01:34:18 +08:00
### Common Test Module Structure
```
tests/
├── common/
2025-03-19 13:53:38 +08:00
│ ├── 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
2025-03-12 01:34:18 +08:00
```
2025-03-19 13:53:38 +08:00
### Environment Setup Utilities
The `env.rs` module provides utilities for setting up the test environment:
2025-03-12 01:34:18 +08:00
```rust
2025-03-19 13:53:38 +08:00
// tests/common/env.rs
use std::sync::Once;
2025-03-12 01:34:18 +08:00
use dotenv::dotenv;
2025-03-19 13:53:38 +08:00
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;
2025-03-12 01:34:18 +08:00
pub struct TestDb {
2025-03-19 13:53:38 +08:00
pub pool: Pool,
pub test_id: String, // Unique identifier for this test run
2025-03-12 01:34:18 +08:00
}
impl TestDb {
2025-03-19 13:53:38 +08:00
/// Creates a new isolated test database environment
pub async fn new() -> Result<Self> {
// Initialize environment
crate::common::env::init_test_env();
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
// Generate unique test identifier
let test_id = Uuid::new_v4().to_string();
// Get database config from env
2025-03-12 01:34:18 +08:00
let database_url = std::env::var("TEST_DATABASE_URL")
2025-03-19 13:53:38 +08:00
.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)
2025-03-12 01:34:18 +08:00
.max_size(5)
2025-03-19 13:53:38 +08:00
.build()?;
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
let db = Self { pool, test_id };
// Optional: Setup initial test data
// db.setup_schema().await?;
Ok(db)
2025-03-12 01:34:18 +08:00
}
2025-03-19 13:53:38 +08:00
/// Get a connection from the pool
pub async fn get_conn(&self) -> Result<deadpool_diesel::postgres::Connection> {
Ok(self.pool.get().await?)
2025-03-12 01:34:18 +08:00
}
2025-03-19 13:53:38 +08:00
/// 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?;
2025-03-12 01:34:18 +08:00
Ok(())
}
2025-03-19 13:53:38 +08:00
/// 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
}
2025-03-12 01:34:18 +08:00
}
```
2025-03-19 13:53:38 +08:00
### HTTP Testing Utilities
The `http/` directory contains utilities for HTTP testing, including a MockServer wrapper and a TestHttpClient:
2025-03-12 01:34:18 +08:00
```rust
2025-03-19 13:53:38 +08:00
// tests/common/http/mock_server.rs
use anyhow::Result;
use mockito::{self, Mock, Server};
use serde_json::Value;
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
/// MockServer is a wrapper around mockito::Server
pub struct MockServer {
server: Server,
}
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
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)
}
}
2025-03-12 01:34:18 +08:00
}
```
2025-03-19 13:53:38 +08:00
### Fixture Builder Pattern
The `fixtures/builder.rs` file provides a builder pattern for creating test fixtures:
2025-03-12 01:34:18 +08:00
```rust
2025-03-19 13:53:38 +08:00
// tests/common/fixtures/builder.rs
2025-03-12 01:34:18 +08:00
use uuid::Uuid;
2025-03-19 13:53:38 +08:00
/// 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)
2025-03-12 01:34:18 +08:00
}
}
```
2025-03-19 13:53:38 +08:00
### Using These Test Utilities
Here's an example of using these test utilities in an integration test:
2025-03-12 01:34:18 +08:00
```rust
2025-03-19 13:53:38 +08:00
use crate::tests::common::{
db::TestDb,
env::init_test_env,
http::{MockServer, TestHttpClient},
fixtures::UserBuilder,
assertions::ResponseAssertions,
};
use anyhow::Result;
use serde_json::json;
2025-03-12 01:34:18 +08:00
#[tokio::test]
2025-03-19 13:53:38 +08:00
async fn test_user_api() -> Result<()> {
// Initialize environment
init_test_env();
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
// Setup test database
let test_db = TestDb::new().await?;
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
// 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");
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
// Test the API endpoint
let response = client.get(&format!("/api/users/{}", user.id)).await?;
2025-03-12 01:34:18 +08:00
2025-03-19 13:53:38 +08:00
// 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
2025-03-12 01:34:18 +08:00
test_db.cleanup().await?;
2025-03-19 13:53:38 +08:00
Ok(())
2025-03-12 01:34:18 +08:00
}
2025-03-19 13:53:38 +08:00
```
2025-03-12 01:34:18 +08:00
```