How to use cargo test features

Run cargo test with the --features flag to execute tests for optional code blocks enabled by specific feature names.

When tests vanish

You wrote a test for the new JSON serialization support. You ran cargo test. The test didn't show up in the output. You checked the file. The test is there. You ran cargo test again. Still nothing. You're staring at the terminal wondering why Cargo is ignoring your code.

The test isn't missing. The test was compiled out.

Rust compiles code based on configuration. Features are the knobs you turn to change that configuration. If a feature is off, the code guarded by #[cfg(feature = "...")] disappears before the compiler finishes its work. The test function vanishes too. Cargo runs the binary, but the binary has no test to run. You have to tell Cargo to turn the knob on.

Features are compile-time switches

A feature in Rust is a boolean flag defined in Cargo.toml. It controls which parts of your crate get compiled. You use #[cfg(feature = "name")] to gate code behind a feature. When the feature is enabled, the code exists. When it's disabled, the code is stripped away during the build.

cargo test is just a build command that runs the result. It builds the crate with the current feature set and executes the test binaries. If a feature is disabled, the guarded code never reaches the test runner. The test runner doesn't know the test ever existed.

This behavior is intentional. It lets you ship crates with optional dependencies. A user who doesn't need JSON support can compile the crate without pulling in serde. The binary is smaller. The compile time is faster. The trade-off is that you must enable the feature to test the guarded code.

Minimal example

Start with a crate that has an optional feature. The feature enables a function and a test.

// Cargo.toml
// Define a feature that enables an optional dependency.
[features]
json_support = ["serde", "serde_json"]

[dependencies]
serde = { version = "1.0", optional = true }
serde_json = { version = "1.0", optional = true }
// src/lib.rs
/// Serialize data to a JSON string.
/// This function only exists when the json_support feature is enabled.
#[cfg(feature = "json_support")]
pub fn to_json(data: &str) -> String {
    // Use the optional dependencies inside the feature gate.
    serde_json::to_string(data).expect("Serialization failed")
}

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

    /// Test the JSON serialization.
    /// This test is gated by the same feature as the function.
    #[cfg(feature = "json_support")]
    #[test]
    fn test_json_roundtrip() {
        // The test uses the feature-gated function.
        let result = to_json("hello");
        assert_eq!(result, "\"hello\"");
    }
}

Run cargo test. The output shows zero tests run. The json_support feature is not enabled by default. The to_json function and the test are stripped out. The binary contains no test named test_json_roundtrip.

Run cargo test --features json_support. Cargo enables the feature. The cfg blocks are included. The dependencies are pulled in. The test appears and runs.

The --features flag tells Cargo to enable specific features for the build. Without it, Cargo uses only the default features. If a feature isn't default, the code behind it stays hidden.

What happens under the hood

When you run cargo test --features json_support, Cargo performs a feature resolution pass. It reads Cargo.toml and builds a dependency graph. It sees that json_support depends on serde and serde_json. It marks those dependencies as required.

Next, Cargo invokes rustc with the feature flags. The compiler evaluates #[cfg(feature = "json_support")]. The flag is true. The compiler includes the to_json function and the test module. The code compiles. The test binary is linked against serde and serde_json.

When you run cargo test without the flag, the resolution pass skips json_support. The dependencies remain optional and unused. The compiler evaluates the cfg attribute. The flag is false. The compiler discards the guarded blocks. The test binary is built without serde or serde_json. The test function doesn't exist in the binary.

This is why cargo test can show zero tests even when your test files are full of #[test] functions. The functions are there, but they're behind gates that are closed.

Realistic scenario: optional backends

Libraries often support multiple backends. A crypto crate might support ring and aws-lc. A UI crate might support winit and android-activity. You need to test each backend. You also need to test that the backends don't conflict.

// Cargo.toml
// Define features for each backend.
[features]
default = ["std"]
std = []
ring_backend = ["dep:ring"]
aws_lc_backend = ["dep:aws-lc-sys"]

[dependencies]
ring = { version = "0.17", optional = true }
aws-lc-sys = { version = "0.10", optional = true }
// src/lib.rs
/// Compute a hash using the active backend.
/// The implementation changes based on which feature is enabled.
pub fn compute_hash(data: &[u8]) -> Vec<u8> {
    #[cfg(feature = "ring_backend")]
    {
        // Use ring when the feature is active.
        ring::digest::digest(&ring::digest::SHA256, data).as_ref().to_vec()
    }

    #[cfg(feature = "aws_lc_backend")]
    {
        // Use aws-lc when that feature is active.
        // Implementation details omitted for brevity.
        vec![]
    }

    #[cfg(not(any(feature = "ring_backend", feature = "aws_lc_backend")))]
    {
        // Fallback when no backend is selected.
        // This prevents compilation errors if both features are off.
        panic!("No backend enabled")
    }
}

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

    /// Test the ring backend specifically.
    /// This test only runs when ring_backend is enabled.
    #[cfg(feature = "ring_backend")]
    #[test]
    fn test_ring_hash() {
        let hash = compute_hash(b"test");
        assert_eq!(hash.len(), 32);
    }

    /// Test that exactly one backend is active.
    /// This catches configuration errors where both or neither are enabled.
    #[test]
    fn test_backend_exclusivity() {
        let ring_active = cfg!(feature = "ring_backend");
        let aws_active = cfg!(feature = "aws_lc_backend");

        // Ensure the build has a valid backend configuration.
        assert!(ring_active ^ aws_active, "Exactly one backend must be enabled");
    }
}

Run cargo test --features ring_backend. The ring test runs. The exclusivity test passes.

Run cargo test --features aws_lc_backend. The ring test is stripped. The exclusivity test passes.

Run cargo test --features ring_backend,aws_lc_backend. Cargo enables both features. The exclusivity test fails at runtime. This reveals a configuration error. Your CI should catch this.

The cfg! macro evaluates the condition at compile time and returns a boolean. It lets you write assertions about the feature configuration. This is useful for validating that your feature matrix is correct.

Pitfalls and compiler errors

Feature testing has traps. The compiler will catch some, but others require careful setup.

If you forget to enable a feature in a test that uses a feature-gated item, the compiler rejects the code with E0432 (unresolved import) or E0425 (cannot find value). The item doesn't exist in the current build. The test can't reference it. You must add --features to the command or mark the test with #[cfg(feature = "...")].

If you run cargo test --all-features, Cargo enables every feature. This can cause feature unification conflicts. If two features require different versions of the same dependency, Cargo fails to resolve the graph. You'll see an error about conflicting requirements. --all-features is a stress test. If it breaks, your feature matrix is too wide.

Dev-dependencies are always available in tests, even if a feature is off. If you put serde in dev-dependencies, you can use serde in tests without enabling a feature. This is useful for testing internal logic. It doesn't enable feature-gated code in the library. The library code still needs the feature flag.

Convention aside: CI pipelines typically run three commands. cargo test checks the default build. cargo test --all-features checks the full surface area. cargo test --no-default-features checks the minimal build. This covers the matrix. If your crate supports no_std, the no-default run is essential.

Don't trust cargo test to find everything. If a feature is off, the test doesn't exist.

Decision matrix

Use cargo test --features name when you need to test code guarded by a specific feature flag. Use cargo test --all-features when you want to verify the crate works with every optional dependency enabled, typically in CI. Use cargo test --no-default-features when you need to test the minimal build, often for no_std targets or to ensure default features don't hide breakage. Use #[cfg(feature = "...")] on tests when the test logic itself depends on the feature being active. Reach for dev-dependencies when you need a crate only for testing, not when you need to toggle functionality in the library.

Treat --all-features as a stress test. If it breaks, your feature matrix is too wide.

Where to go next