How to write unit tests

Write unit tests by defining a `tests` module within your source file (or in a separate `tests/` directory for integration tests) and using the `#[test]` attribute on functions that assert expected behavior.

The checkpoint before production

You just finished a function that parses a JSON payload into a Rust struct. It works perfectly in your terminal. Two days later, you refactor the struct fields to match a new API spec. The code compiles. The server starts. Then a user submits a request and the whole pipeline crashes because a field name changed. You could have caught that in ten seconds if you had a test.

Rust treats tests as first-class citizens. You do not write separate test scripts or install heavy frameworks to get started. The language ships with a built-in test harness. You mark a function with #[test], and the compiler knows exactly what to do with it. Think of tests as automated checkpoints on an assembly line. The borrow checker guarantees your memory is safe. Tests guarantee your logic does what you actually want.

The minimal setup

Unit tests live inside your source files. You wrap them in a module marked with #[cfg(test)]. This conditional compilation flag tells the compiler to include the module only when running tests. Your production binary stays lean. You do not ship test code to your users.

/// Adds two integers and returns the result.
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

// Only compile this module when running tests
#[cfg(test)]
mod tests {
    // Import everything from the parent module so we can test private items
    use super::*;

    #[test]
    fn test_add_positive_numbers() {
        // assert_eq! compares two values and prints a diff if they differ
        assert_eq!(add(2, 2), 4);
    }

    #[test]
    fn test_add_negative_numbers() {
        // Verify the function handles negative values correctly
        assert_eq!(add(-2, -2), -4);
    }
}

Convention aside: Rust developers almost always write use super::*; inside the test module. It saves typing and makes the test read like a standalone script. You will also see test functions named test_<function>_<scenario>. This naming pattern keeps your test output readable when you have fifty tests running at once.

Run cargo test from your project root. Cargo compiles your crate twice. The first build produces your normal library or binary. The second build includes the #[cfg(test)] module and links it against Rust's test harness. The harness calls every function marked with #[test]. If a function returns normally, the test passes. If it panics, the test fails. The output shows a clear diff for assert_eq! mismatches, so you never guess why a check failed.

Keep your test module at the bottom of the file. It keeps verification logic close to the code it protects.

How the test harness actually runs

The test harness is a small runtime that wraps each #[test] function in a panic catcher. When you run cargo test, the harness iterates through every test function, executes it, and checks the result. If the function completes without panicking, the harness marks it as passed. If the function panics, the harness catches the panic, marks the test as failed, and prints the panic message.

You can run a single test by passing its name as an argument. cargo test test_add_positive_numbers filters the test list and runs only that function. This saves time when you are debugging a specific failure. You can also pass arguments directly to the test binary using cargo test -- --nocapture. The double dash separates Cargo arguments from test harness arguments. The --nocapture flag disables output buffering, so println! statements inside your tests appear immediately in the terminal. This is invaluable when you need to trace execution flow without adding temporary files.

The #[cfg(test)] attribute is a conditional compilation flag. It tells the compiler to ignore this entire block when building for release. Your production binary stays lean. You do not ship test code to your users.

Trust the harness to catch panics. Write your assertions to catch logic errors.

Testing real-world failure paths

Real code rarely returns plain integers. It returns Result types, handles edge cases, and interacts with other modules. Here is how you structure tests for a function that might fail.

/// Parses a configuration string into a key-value map.
/// Returns an error if the format is invalid.
pub fn parse_config(input: &str) -> Result<Vec<(&str, &str)>, String> {
    let mut pairs = Vec::new();
    for line in input.lines() {
        let parts: Vec<&str> = line.splitn(2, '=').collect();
        if parts.len() == 2 {
            pairs.push((parts[0].trim(), parts[1].trim()));
        } else {
            return Err(format!("Invalid line: {}", line));
        }
    }
    Ok(pairs)
}

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

    #[test]
    fn test_parse_valid_config() {
        let input = "host=localhost\nport=8080";
        let result = parse_config(input);
        // Check the Result variant directly instead of unwrapping
        assert!(result.is_ok());
        let pairs = result.unwrap();
        assert_eq!(pairs.len(), 2);
    }

    #[test]
    fn test_parse_invalid_config() {
        let input = "host=localhost\nbad_line";
        let result = parse_config(input);
        // Verify the error path returns the expected variant
        assert!(result.is_err());
    }
}

Convention aside: Never unwrap a Result in a test unless you have already asserted that it is Ok. Unwrapping an error causes a panic, which fails the test but hides the actual error message. Checking is_ok() or is_err() first gives you a clear assertion failure with the exact mismatch. It also keeps your test output deterministic.

The #[should_panic] attribute exists, but it is a blunt instrument. It passes if the function panics for any reason. If your code panics on a division by zero instead of a missing file, the test still passes. You get a false sense of security. Prefer returning Result and asserting on the error variant. You get precise failure messages and your production code stays idiomatic.

If you accidentally call a test function without the #[test] attribute, cargo test will ignore it completely. The compiler will not warn you. You will run your test suite and miss the bug entirely. Always verify your test output lists the function name.

Another common trap is testing implementation details instead of behavior. If you change a private helper function but the public output stays the same, your tests should not break. Tests are a contract with future you. They should verify what the function does, not how it does it.

Write tests that verify outcomes, not internals. Refactoring should never break a passing test.

Choosing your testing strategy

Rust gives you two distinct places to write tests. Each serves a different purpose. You pick the right location based on what you need to verify.

Use #[cfg(test)] mod tests inside your source file when you need to verify private functions or internal state. Use the tests/ directory at your project root when you want to simulate external users and verify only the public API. Use assert_eq! when you expect two values to match exactly. Use assert! when you need to check a boolean condition or a custom predicate. Use #[should_panic] only when testing code that intentionally panics on unrecoverable errors, and always add expected = "error message" to narrow the check. Reach for Result assertions in production code. Panics are for bugs, not for control flow.

Integration tests in the tests/ directory compile as separate crates. They cannot see private items. They force you to interact with your library exactly like a downstream user would. This catches API design flaws that unit tests miss. If your public interface is confusing, the integration test will feel awkward to write. That friction is a feature. It tells you to improve your API before you release it.

Keep unit tests fast and focused. Reserve integration tests for cross-module workflows and public API contracts.

Where to go next