mirror of https://github.com/buster-so/buster.git
1088 lines
33 KiB
Plaintext
1088 lines
33 KiB
Plaintext
---
|
|
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
|
|
- **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
|
|
- Mock external dependencies using mockito for HTTP calls
|
|
- **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
|
|
|
|
## Integration Tests
|
|
- 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
|
|
- 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
|
|
- Test realistic user workflows rather than individual functions
|
|
|
|
### 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
|
|
```
|
|
|
|
#### 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
|
|
- 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
|
|
|
|
## 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(())
|
|
}
|
|
```
|
|
``` |