Rust Test Organization: Modules, Feature Flags, and Shared Test Utilities
Rust has three places to write tests: inline in the source file, in a tests/ directory for integration tests, and in doctests. Most guides cover these in isolation. This guide covers how to organize them as your project grows — shared utilities, feature-gated tests, test fixtures, and keeping test code DRY.
The Three Test Locations
src/
lib.rs # Inline #[cfg(test)] modules (unit tests)
user.rs # More inline tests
db/
mod.rs
queries.rs # Inline tests close to the code
tests/
integration_test.rs # Integration tests (separate binary)
common/
mod.rs # Shared test utilities
api_tests.rs
db_tests.rsInline Unit Tests: Best Practices
Inline tests live in a #[cfg(test)] module at the bottom of each file. They can access private functions:
// src/password.rs
use sha2::{Sha256, Digest};
pub struct PasswordHasher {
salt: String,
}
impl PasswordHasher {
pub fn new(salt: impl Into<String>) -> Self {
Self { salt: salt.into() }
}
pub fn hash(&self, password: &str) -> String {
self.hash_with_salt(password, &self.salt)
}
// Private — only accessible in this file's test module
fn hash_with_salt(&self, password: &str, salt: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(format!("{}{}", salt, password));
format!("{:x}", hasher.finalize())
}
pub fn verify(&self, password: &str, hash: &str) -> bool {
self.hash(password) == hash
}
}
#[cfg(test)]
mod tests {
use super::*; // Access to everything in this file, including private items
fn make_hasher() -> PasswordHasher {
PasswordHasher::new("test-salt")
}
#[test]
fn test_hash_is_deterministic() {
let hasher = make_hasher();
let hash1 = hasher.hash("password123");
let hash2 = hasher.hash("password123");
assert_eq!(hash1, hash2);
}
#[test]
fn test_different_passwords_different_hashes() {
let hasher = make_hasher();
let hash1 = hasher.hash("password1");
let hash2 = hasher.hash("password2");
assert_ne!(hash1, hash2);
}
#[test]
fn test_hash_with_salt_changes_result() {
// Testing private method — only possible in inline test module
let hasher = make_hasher();
let hash1 = hasher.hash_with_salt("password", "salt1");
let hash2 = hasher.hash_with_salt("password", "salt2");
assert_ne!(hash1, hash2);
}
#[test]
fn test_verify_correct_password() {
let hasher = make_hasher();
let hash = hasher.hash("my-password");
assert!(hasher.verify("my-password", &hash));
}
#[test]
fn test_verify_wrong_password() {
let hasher = make_hasher();
let hash = hasher.hash("correct-password");
assert!(!hasher.verify("wrong-password", &hash));
}
}Integration Tests: The tests/ Directory
Integration tests test public APIs:
// tests/api_tests.rs
use myapp::{App, Config};
// This runs BEFORE any test in this file
fn setup() -> App {
let config = Config {
database_url: ":memory:".to_string(),
..Config::default()
};
App::new(config).expect("Failed to create app")
}
#[test]
fn test_create_and_retrieve_user() {
let app = setup();
let user_id = app.create_user("Alice", "alice@example.com")
.expect("Failed to create user");
let user = app.get_user(user_id)
.expect("Failed to get user");
assert_eq!(user.name, "Alice");
assert_eq!(user.email, "alice@example.com");
}Shared Test Utilities
The tests/common/ pattern provides utilities shared across multiple integration test files:
// tests/common/mod.rs
use myapp::{App, Config, User};
pub struct TestApp {
pub app: App,
}
impl TestApp {
pub fn new() -> Self {
let config = Config {
database_url: ":memory:".to_string(),
log_level: "error".to_string(), // Suppress logs in tests
..Config::default()
};
Self {
app: App::new(config).expect("Failed to create test app"),
}
}
pub fn create_user(&self, name: &str) -> User {
self.app
.create_user(name, &format!("{}@test.com", name.to_lowercase()))
.expect("Failed to create test user")
}
pub fn create_test_users(&self, count: usize) -> Vec<User> {
(0..count)
.map(|i| self.create_user(&format!("User{}", i)))
.collect()
}
}
// Utility functions
pub fn assert_sorted_by_name(users: &[User]) {
let names: Vec<&str> = users.iter().map(|u| u.name.as_str()).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "Users should be sorted by name");
}// tests/api_tests.rs
mod common;
use common::TestApp;
#[test]
fn test_user_list_is_sorted() {
let test_app = TestApp::new();
test_app.create_user("Charlie");
test_app.create_user("Alice");
test_app.create_user("Bob");
let users = test_app.app.list_users().unwrap();
common::assert_sorted_by_name(&users);
}
// tests/search_tests.rs
mod common;
#[test]
fn test_search_finds_user_by_name() {
let test_app = common::TestApp::new();
test_app.create_user("Alice");
test_app.create_user("Bob");
let results = test_app.app.search_users("Alice").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Alice");
}Feature-Gated Tests
Use feature flags to conditionally include expensive or environment-specific tests:
# Cargo.toml
[features]
default = []
integration-tests = [] # Opt-in: requires running database
slow-tests = [] # Opt-in: benchmark-style tests// tests/database_tests.rs
#[cfg(feature = "integration-tests")]
mod integration {
use std::env;
fn get_database_url() -> String {
env::var("DATABASE_URL")
.expect("DATABASE_URL must be set for integration tests")
}
#[test]
fn test_database_connection() {
let url = get_database_url();
let conn = myapp::db::connect(&url).expect("Failed to connect");
assert!(conn.is_alive());
}
#[test]
fn test_database_migration() {
let url = get_database_url();
let result = myapp::db::run_migrations(&url);
assert!(result.is_ok());
}
}Run with feature:
# Normal tests (no database required)
cargo test
# Integration tests (requires DATABASE_URL)
DATABASE_URL=postgres://localhost/test cargo test --features integration-testsTest Fixtures
Load fixture files in tests:
// tests/common/fixtures.rs
use std::path::PathBuf;
pub fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
}
pub fn load_fixture(name: &str) -> String {
let path = fixtures_dir().join(name);
std::fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("Failed to read fixture: {}", path.display()))
}
pub fn load_json_fixture<T: serde::de::DeserializeOwned>(name: &str) -> T {
let content = load_fixture(name);
serde_json::from_str(&content)
.unwrap_or_else(|e| panic!("Failed to parse JSON fixture {}: {}", name, e))
}// tests/parser_tests.rs
mod common;
use common::fixtures::{load_fixture, load_json_fixture};
use myapp::types::ApiResponse;
#[test]
fn test_parse_valid_response() {
let response: ApiResponse = load_json_fixture("valid_response.json");
assert_eq!(response.status, "ok");
assert_eq!(response.data.len(), 5);
}
#[test]
fn test_parse_error_response() {
let json = load_fixture("error_response.json");
let result = myapp::parser::parse_response(&json);
assert!(result.is_err());
}Conditional Tests Based on Environment
#[test]
fn test_requires_network() {
if std::env::var("SKIP_NETWORK_TESTS").is_ok() {
return; // Skip in offline CI
}
// Test that requires network access
let result = myapp::fetch_external_data();
assert!(result.is_ok());
}
// Better: use a helper
fn skip_if_offline(test_name: &str) -> bool {
if std::env::var("OFFLINE").is_ok() {
eprintln!("Skipping {} (OFFLINE=1)", test_name);
return true;
}
false
}
#[test]
fn test_network_feature() {
if skip_if_offline("test_network_feature") { return; }
// ...
}Organizing Large Test Suites
For large projects, organize tests into modules by domain:
tests/
auth/
mod.rs # mod auth; in integration_tests.rs
login_tests.rs
registration_tests.rs
password_reset_tests.rs
orders/
mod.rs
create_order_tests.rs
payment_tests.rs
common/
mod.rs
test_db.rs
test_api.rs
fixtures.rs
integration_tests.rs # entry point: mod auth; mod orders; mod common;// tests/integration_tests.rs
mod auth;
mod common;
mod orders;Test Naming Conventions
// Descriptive names: test_WHAT_WHEN_EXPECTED
#[test]
fn test_create_user_with_duplicate_email_returns_error() { }
#[test]
fn test_get_user_by_id_when_not_found_returns_none() { }
#[test]
fn test_password_validation_rejects_short_passwords() { }
// Or verb-based: VERB_SUBJECT_CONDITION
#[test]
fn creates_user_successfully() { }
#[test]
fn returns_error_for_duplicate_email() { }
#[test]
fn rejects_password_shorter_than_8_characters() { }CI Configuration
- name: Run unit tests
run: cargo test --lib -- --test-threads=4
- name: Run integration tests
run: cargo test --test '*'
- name: Run feature-gated integration tests
run: cargo test --features integration-tests
env:
DATABASE_URL: postgres://localhost:5432/testdbConclusion
Rust's test organization system rewards structure. Put unit tests inline with the code they test, sharing the module's private scope. Put integration tests in tests/ as separate crates. Share utilities via tests/common/mod.rs. Use feature flags to guard expensive tests that require external services. A well-organized test suite is easier to maintain and gives clearer feedback when something breaks.