Rust Testing: #[test] and #[cfg(test)]

Rust ships with a testing framework baked directly into the toolchain. No test runner to install, no assertion library to configure, no test framework to debate over in pull requests. You write...

Key Insights

  • Rust’s testing framework is built into the language and requires zero external dependencies—just add #[test] to any function and run cargo test
  • The #[cfg(test)] attribute enables conditional compilation, ensuring your test code never bloats production binaries
  • Unlike many languages, Rust lets you test private functions directly from within the same module, eliminating the need to expose internals just for testing

Rust’s Built-in Testing Framework

Rust ships with a testing framework baked directly into the toolchain. No test runner to install, no assertion library to configure, no test framework to debate over in pull requests. You write tests, you run cargo test, and you get results.

This isn’t just convenient—it’s a statement about Rust’s priorities. Testing is a first-class citizen, not an afterthought bolted on by the community. The compiler understands your tests. The build system knows how to find them. Everything just works.

For teams building reliable software, this matters. Friction in testing leads to less testing. Rust removes that friction entirely.

The #[test] Attribute

The #[test] attribute marks any function as a test. The function must take no arguments and return either () or Result<(), E>. That’s it.

pub struct Calculator;

impl Calculator {
    pub fn add(a: i32, b: i32) -> i32 {
        a + b
    }

    pub fn divide(a: i32, b: i32) -> Option<i32> {
        if b == 0 {
            None
        } else {
            Some(a / b)
        }
    }
}

#[test]
fn test_addition() {
    let result = Calculator::add(2, 3);
    assert_eq!(result, 5);
}

#[test]
fn test_addition_with_negatives() {
    assert_eq!(Calculator::add(-5, 3), -2);
    assert_eq!(Calculator::add(-5, -3), -8);
}

#[test]
fn test_divide_by_zero_returns_none() {
    assert!(Calculator::divide(10, 0).is_none());
}

Rust provides three primary assertion macros:

  • assert!(expression) — panics if the expression is false
  • assert_eq!(left, right) — panics if the values aren’t equal, showing both values in the failure message
  • assert_ne!(left, right) — panics if the values are equal

The assert_eq! and assert_ne! macros are particularly useful because they print both values when tests fail. Instead of “assertion failed,” you see “assertion failed: left: 5, right: 6.” This small detail saves significant debugging time.

You can also add custom failure messages:

#[test]
fn test_with_custom_message() {
    let user_count = 5;
    assert!(
        user_count > 0,
        "Expected at least one user, but found {}", 
        user_count
    );
}

The #[cfg(test)] Module Pattern

While #[test] marks individual functions, #[cfg(test)] handles conditional compilation at the module level. Code inside a #[cfg(test)] block only compiles when running tests.

pub struct UserValidator;

impl UserValidator {
    pub fn is_valid_email(email: &str) -> bool {
        email.contains('@') && email.contains('.')
    }

    pub fn is_valid_username(username: &str) -> bool {
        let len = username.len();
        len >= 3 && len <= 20 && username.chars().all(|c| c.is_alphanumeric())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_email_contains_at_and_dot() {
        assert!(UserValidator::is_valid_email("user@example.com"));
    }

    #[test]
    fn invalid_email_missing_at() {
        assert!(!UserValidator::is_valid_email("userexample.com"));
    }

    #[test]
    fn invalid_email_missing_dot() {
        assert!(!UserValidator::is_valid_email("user@examplecom"));
    }

    #[test]
    fn valid_username_alphanumeric() {
        assert!(UserValidator::is_valid_username("john123"));
    }

    #[test]
    fn invalid_username_too_short() {
        assert!(!UserValidator::is_valid_username("ab"));
    }

    #[test]
    fn invalid_username_special_chars() {
        assert!(!UserValidator::is_valid_username("john_doe"));
    }
}

The use super::*; line imports everything from the parent module, giving tests access to the code they’re testing.

This pattern provides a critical guarantee: test code never ships in production binaries. When you build with cargo build --release, the compiler completely ignores everything inside #[cfg(test)] blocks. Your tests can import heavy testing utilities, create elaborate fixtures, and define helper functions without adding a single byte to your deployed binary.

Testing Private Functions

In many languages, testing private functions requires awkward workarounds—making things public “just for testing,” using reflection, or restructuring code to expose internals. Rust takes a different approach.

Because the test module lives inside the same file, it has access to private items:

pub struct PasswordHasher;

impl PasswordHasher {
    pub fn hash(password: &str) -> String {
        let normalized = Self::normalize_input(password);
        let salted = Self::add_salt(&normalized);
        Self::compute_hash(&salted)
    }

    // Private helper - trims and lowercases
    fn normalize_input(input: &str) -> String {
        input.trim().to_lowercase()
    }

    // Private helper - adds a salt prefix
    fn add_salt(input: &str) -> String {
        format!("salt_{}", input)
    }

    // Private helper - simple mock hash for demonstration
    fn compute_hash(input: &str) -> String {
        format!("hashed:{}", input.len())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalize_trims_whitespace() {
        let result = PasswordHasher::normalize_input("  password  ");
        assert_eq!(result, "password");
    }

    #[test]
    fn normalize_lowercases_input() {
        let result = PasswordHasher::normalize_input("PASSWORD");
        assert_eq!(result, "password");
    }

    #[test]
    fn salt_prepends_prefix() {
        let result = PasswordHasher::add_salt("test");
        assert!(result.starts_with("salt_"));
    }

    #[test]
    fn full_hash_pipeline_works() {
        let result = PasswordHasher::hash("  TEST  ");
        // Normalized: "test", Salted: "salt_test" (9 chars)
        assert_eq!(result, "hashed:9");
    }
}

This design encourages thorough testing of implementation details when it makes sense, without forcing you to pollute your public API. Test the private helpers directly, verify edge cases in isolation, and keep your public interface clean.

Handling Expected Failures

Sometimes correct behavior means panicking. Input validation, bounds checking, invariant enforcement—these often need to fail loudly. The #[should_panic] attribute tests that code panics as expected:

pub struct BoundedQueue<T> {
    items: Vec<T>,
    capacity: usize,
}

impl<T> BoundedQueue<T> {
    pub fn new(capacity: usize) -> Self {
        if capacity == 0 {
            panic!("capacity must be greater than zero");
        }
        BoundedQueue {
            items: Vec::with_capacity(capacity),
            capacity,
        }
    }

    pub fn push(&mut self, item: T) {
        if self.items.len() >= self.capacity {
            panic!("queue is full: cannot exceed capacity of {}", self.capacity);
        }
        self.items.push(item);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "capacity must be greater than zero")]
    fn zero_capacity_panics() {
        BoundedQueue::<i32>::new(0);
    }

    #[test]
    #[should_panic(expected = "queue is full")]
    fn push_beyond_capacity_panics() {
        let mut queue = BoundedQueue::new(2);
        queue.push(1);
        queue.push(2);
        queue.push(3); // This should panic
    }
}

The expected parameter checks that the panic message contains the specified substring. This prevents false positives where code panics for the wrong reason.

For more nuanced error handling, tests can return Result:

#[cfg(test)]
mod tests {
    use std::num::ParseIntError;

    #[test]
    fn test_parsing() -> Result<(), ParseIntError> {
        let value: i32 = "42".parse()?;
        assert_eq!(value, 42);
        Ok(())
    }
}

This approach works well when testing code that returns Result types—you can use the ? operator naturally instead of unwrapping everywhere.

Running and Filtering Tests

The cargo test command runs your entire test suite:

# Run all tests
cargo test

# Run tests matching a pattern
cargo test valid_email

# Run tests in a specific module
cargo test tests::normalize

# Run a single exact test
cargo test --exact test_addition

By default, Rust runs tests in parallel. Control this when tests have shared dependencies:

# Run tests sequentially
cargo test -- --test-threads=1

For slow or resource-intensive tests, mark them as ignored:

#[test]
#[ignore]
fn expensive_integration_test() {
    // This test takes 30 seconds
}
# Run only ignored tests
cargo test -- --ignored

# Run all tests including ignored
cargo test -- --include-ignored

By default, cargo test captures stdout. To see print statements:

# Show output from passing tests too
cargo test -- --nocapture

Organizing Tests: Unit vs Integration

Rust distinguishes between unit tests and integration tests by location:

my_project/
├── Cargo.toml
├── src/
│   ├── lib.rs          # Unit tests live here with #[cfg(test)]
│   ├── parser.rs       # Module with its own #[cfg(test)] mod tests
│   └── validator.rs    # Same pattern
└── tests/
    ├── parsing_tests.rs      # Integration test file
    └── validation_tests.rs   # Another integration test file

Unit tests in src/ test internal implementation details. They have access to private functions and test small, isolated pieces of logic.

Integration tests in tests/ treat your crate as an external consumer would. They can only access your public API:

// tests/parsing_tests.rs
use my_project::Parser;

#[test]
fn parser_handles_complex_input() {
    let parser = Parser::new();
    let result = parser.parse("complex input here");
    assert!(result.is_ok());
}

Each file in tests/ compiles as a separate crate. This enforces the boundary—integration tests genuinely test your public interface.

Use unit tests for implementation details, edge cases, and fast feedback. Use integration tests for verifying that your public API works correctly as a whole. Both belong in a well-tested codebase.

Rust’s testing story is simple by design. The framework stays out of your way, the tooling handles the complexity, and you focus on writing tests that catch bugs. That’s exactly how it should be.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.