Rust Test Organization: Modules, Feature Flags, and Shared Test Utilities

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.rs

Inline 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-tests

Test 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/testdb

Conclusion

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.

Read more

Start now free