DEV Community

Gregory Chris
Gregory Chris

Posted on

Writing Tests with #[cfg(test)] and Modules

Writing Tests with #[cfg(test)] and Modules in Rust

Rust is celebrated for its focus on correctness, performance, and safety. What often gets overlooked is how it empowers developers to write clean, idiomatic tests. If you're aiming to write robust software, testing is not optional—it's essential. But how do you structure your tests in Rust effectively? Enter #[cfg(test)] and private test modules. In this blog post, we'll explore idiomatic testing practices in Rust, unpack why they matter, and walk through practical examples to show you how to structure your tests like a pro.


Why Testing in Rust is Special

Rust's testing ecosystem is built into the language itself. You don’t need external libraries to write basic tests—it’s all part of the standard library. This integration encourages developers to embrace testing from the start.

But what sets Rust apart is its focus on modularity. Tests can live right alongside your implementation code, yet remain isolated from the production build. This is achieved using the #[cfg(test)] attribute, which tells the Rust compiler to compile test code only when running tests.

Think of it like a backstage pass for your code: the tests are there, but they don’t interfere with the main performance of the program when it’s running in production.


Getting Started with #[cfg(test)]

The #[cfg(test)] attribute is a conditional compilation directive. In Rust, it’s used to mark code that should only be compiled and executed during tests. This allows us to define test modules within our implementation files, keeping related code and tests together without bloating the final binary.

Here’s a simple example to get us warmed up:

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

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

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Breaking Down the Code

  1. Production Code: The add function is the core logic we want to test.
  2. Test Module: The mod tests block is marked with #[cfg(test)]. This ensures the tests module is only compiled when running tests.
  3. Importing with use super::*: Inside the test module, we use super::* to import the parent module's items for testing.
  4. Test Functions: Each test function is annotated with #[test], indicating it’s a unit test.

Structuring Your Test Modules

As your codebase grows, you’ll quickly realize that dumping all tests into a single file becomes unwieldy. Organizing your tests into modules is a simple yet powerful way to improve maintainability.

Example with Nested Modules

Let’s extend the add function to include more functionality, and structure our tests accordingly.

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

pub fn subtract(a: i32, b: i32) -> i32 {
    a - b
}

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

    mod add_tests {
        use super::*;

        #[test]
        fn test_positive_numbers() {
            assert_eq!(add(3, 4), 7);
        }

        #[test]
        fn test_negative_numbers() {
            assert_eq!(add(-3, -4), -7);
        }
    }

    mod subtract_tests {
        use super::*;

        #[test]
        fn test_positive_numbers() {
            assert_eq!(subtract(10, 4), 6);
        }

        #[test]
        fn test_negative_numbers() {
            assert_eq!(subtract(-10, -4), -6);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Nested Modules?

By organizing tests into logical groups (e.g., add_tests and subtract_tests), we can:

  • Keep related tests together for readability.
  • Make debugging easier when tests fail.
  • Enable selective running of specific test modules.

You can run tests for a specific module using cargo test add_tests::test_positive_numbers.


Common Pitfalls in Rust Testing

Even seasoned Rust developers can run into issues when writing tests. Here are some common pitfalls and how to avoid them:

1. Forgetting to Use #[cfg(test)]

If you forget to wrap your test module with #[cfg(test)], the test code will be compiled into the production binary, bloating its size unnecessarily.

Solution: Always include #[cfg(test)] for test modules.


2. Testing Private Functions

Rust’s module system restricts access to private items, which can make testing private functions tricky. But there’s a simple workaround: test private functions within the same module.

Example:

   fn private_logic(x: i32) -> i32 {
       x * 2
   }

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

       #[test]
       fn test_private_logic() {
           assert_eq!(private_logic(3), 6);
       }
   }
Enter fullscreen mode Exit fullscreen mode

Private functions can be directly accessed inside the #[cfg(test)] module, allowing you to test them without exposing them to the public API.


3. Hard-to-Debug Failures

If your test fails, Rust’s error messages can be verbose but not always easy to parse. Using helper functions or macros in your tests can improve readability.

Example:

   fn is_even(n: i32) -> bool {
       n % 2 == 0
   }

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

       fn assert_even(n: i32) {
           assert!(is_even(n), "{} is not even", n);
       }

       #[test]
       fn test_is_even() {
           assert_even(4);
           assert_even(7); // This will fail with a readable message: "7 is not even"
       }
   }
Enter fullscreen mode Exit fullscreen mode

4. Testing External Side Effects

For code that interacts with a database, filesystem, or network, testing can get tricky. Rust’s std::fs and std::net provide tools to mock or isolate side effects, but you should strive for pure functions wherever possible.

Solution: Use dependency injection or mock libraries like mockall to isolate external dependencies in tests.


Key Takeaways

  1. Use #[cfg(test)] to isolate test code from production builds.
  2. Organize tests into logical modules to keep them maintainable and easy to navigate.
  3. Test private functions in private test modules, rather than exposing them unnecessarily.
  4. Write readable and debuggable tests using helper functions or macros.
  5. Avoid common pitfalls like bloating your binary or testing side effects directly.

Next Steps for Learning

If you’re ready to dive deeper into Rust testing, here are some additional resources:

Happy testing, and may your code be as bug-free as Rust’s borrow checker! 🚀


Feel free to share your thoughts or testing approaches in the comments below. Let’s learn and grow together!

Top comments (1)

Collapse
 
sgchris profile image
Gregory Chris

One thing to add — if you're using #[cfg(test)] with integration tests in the tests/ folder, it doesn't apply the same way, since those are compiled separately.