Question

Rust Test-Only Dependencies with Cargo dev-dependencies

rustrust-cargo

Question

I have a Rust library that implements a lint plugin. I want to use compiletest for tests without making it a required dependency for normal library users. What is the correct way to declare a test-only dependency in Cargo.toml?

Short Answer

Rust projects use Cargo's [dev-dependencies] section for packages needed only while developing and testing the project. You will learn where to declare these dependencies, when Cargo compiles them, and how to keep test code separate from your published library API.

Concept

A test-only dependency is a crate your project needs to run tests, benchmarks, examples, or development tooling, but does not need when another project uses your library as a dependency.

In Cargo, place these crates under [dev-dependencies] in Cargo.toml:

[dev-dependencies]
compiletest = "0.11"

Use the crate name and version required by the tool's documentation. For some Rust compiler testing workflows, the published package may have a different package name, so verify it on crates.io or in the tool's setup guide.

This matters because your library's normal dependencies are part of its production build graph. If a test runner is declared under [dependencies], users of your library may unnecessarily download, compile, and be affected by that tool. A dev-dependency keeps the dependency associated with your repository's development tasks instead.

Cargo makes dev-dependencies available to:

  • Unit tests written in #[cfg(test)] modules.
  • Integration tests in the tests/ directory.
  • Benchmarks and examples when they use the dependency.

They are not available to ordinary library code compiled for downstream users.

Mental Model

Think of your Rust crate as a product shipped to customers and your test tools as equipment in the workshop.

  • [dependencies] are parts built into every shipped product.
  • [dev-dependencies] are workshop tools used to inspect and test the product.

A customer using your library needs the product, not your test runner. Declaring compiletest as a dev-dependency keeps that workshop tool out of the product's normal dependency list.

Take Quiz

Syntax and Examples

Declare production dependencies and development-only dependencies in separate Cargo manifest sections:

[package]
name = "my_lint_plugin"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = "1"

[dev-dependencies]
compiletest = "0.11"

Then use the development dependency from test code. For example, an integration-test harness can live in tests/compiletest.rs:

#[test]
fn run_compile_tests() {
    // Configure and invoke compiletest according to the version's API.
    // The exact API can differ between compiletest releases.
    assert!(true);
}

Run tests with:

cargo test

Cargo reads [dev-dependencies], builds the required development crates, compiles your test targets, and runs them.

For a simple test helper crate, the pattern is the same:

[dev-dependencies]
pretty_assertions = "1"
#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;

    #[test]
    fn adds_numbers() {
        assert_eq!(2 + 3, 5);
    }
}

Step by Step Execution

Consider this project layout:

my_lint_plugin/
├── Cargo.toml
├── src/
│   └── lib.rs
└── tests/
    └── compiletest.rs

Cargo.toml:

[package]
name = "my_lint_plugin"
version = "0.1.0"
edition = "2021"

[dev-dependencies]
pretty_assertions = "1"

tests/compiletest.rs:

use pretty_assertions::assert_eq;

#[test]
fn test_configuration_name() {
    let configured_name = "my_lint_plugin";
    assert_eq!(configured_name, "my_lint_plugin");
}

When you run cargo test, Cargo performs these steps:

  1. It reads Cargo.toml and sees pretty_assertions in [dev-dependencies].
  2. It resolves and builds pretty_assertions for the test build.
  3. It compiles your library.
  4. It compiles the integration test in tests/.
  5. The test can import pretty_assertions because it is a development dependency.
  6. Cargo runs test_configuration_name.

When another application adds your crate as a regular dependency, its normal build does not compile this integration test or need pretty_assertions.

Real World Use Cases

Test-only dependencies are useful whenever development needs tools that are not part of your library's runtime behavior:

  • Compiler and UI tests: Run compile-fail, pass, or diagnostic-output test suites with a tool such as compiletest.
  • Better assertions: Use crates that produce readable diffs when strings, JSON, or collections differ.
  • Temporary files: Create isolated files and directories while testing parsers, configuration loaders, or code generators.
  • Mock servers: Test an HTTP client against a local fake service rather than a real API.
  • Property-based testing: Generate many input cases to test parsers, validators, or algorithms.
  • Benchmarks: Add benchmark-only tooling without exposing it as a requirement for library users.

For a lint plugin, a compile-test harness can verify that example source files produce expected diagnostics or compile successfully.

Take Quiz

Real Codebase Usage

In real Rust projects, developers usually combine [dev-dependencies] with a clear separation between production and test code.

Unit tests near the code they test

pub fn is_valid_rule_name(name: &str) -> bool {
    !name.is_empty() && name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}

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

    #[test]
    fn accepts_underscores() {
        assert!(is_valid_rule_name("unused_variable"));
    }
}

The #[cfg(test)] module is compiled when Cargo builds tests. This is a natural place to use dev-dependencies.

Integration tests for public behavior

Files in tests/ are separate crates. They test the library through its public API:

use my_lint_plugin::is_valid_rule_name;

#[test]
fn rejects_spaces() {
    assert!(!is_valid_rule_name("unused variable"));
}

Dedicated test harnesses

A project that uses compiler tests often creates one integration test that configures and runs the harness. Test input files then live in a directory such as tests/ui/ or tests/compile-fail/. Keep the harness-specific crate in [dev-dependencies] and keep the library's normal source free of imports from it.

Feature-dependent test tooling

If tests require an optional production feature, enable that feature when running the test command:

cargo test --features experimental-rules

This is different from making a testing crate a normal dependency. Features control optional parts of your package; dev-dependencies describe tools used during development.

Common Mistakes

Putting a test crate in [dependencies]

This works, but makes a test tool look like a normal runtime/build dependency:

[dependencies]
compiletest = "0.11" # Usually the wrong section for test tooling

Prefer:

[dev-dependencies]
compiletest = "0.11"

Importing a dev-dependency from normal library code

This library code is compiled for ordinary users, so it cannot rely on a dev-dependency:

// src/lib.rs
use pretty_assertions::assert_eq; // Incorrect for normal library code

Keep the import inside a test-only module or a test target:

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;

    #[test]
    fn works() {
        assert_eq!(1, 1);
    }
}

Forgetting #[cfg(test)] for an internal test module

Without #[cfg(test)], test helpers may be compiled in normal builds:

mod tests {
    // Not automatically test-only just because of the name.
}

Use:

#[cfg(test)]
mod tests {
    // Compiled only for test builds.
}

Assuming dev-dependencies are available to downstream crates

A crate that depends on your library cannot import your dev-dependencies. Each package declares its own dependencies. If your public API truly requires another crate's types or functionality, it belongs in [dependencies] instead.

Using the wrong crate name or API

Testing tools can have similarly named packages, version-specific APIs, or special setup requirements. Confirm the exact package name, version, and harness configuration from the crate's documentation before copying an example.

Comparisons

Cargo manifest sectionIntended useAvailable to normal library code?Used for cargo test?
[dependencies]Code required by the library or binary in normal buildsYesYes, when needed by the package
[dev-dependencies]Test, benchmark, example, and development toolingNo, not in ordinary buildsYes
[build-dependencies]Crates used by build.rsOnly in build.rsYes, if the build script runs

#[cfg(test)] and [dev-dependencies] solve related but different problems:

ToolWhat it controls
[dev-dependencies]Which external crates Cargo provides to development targets
#[cfg(test)]Which Rust code is compiled only while testing

Use both together when test code imports a testing-only crate.

Take Quiz

Cheat Sheet

# Cargo.toml
[dev-dependencies]
compiletest = "0.11"
// src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn example() {
        assert_eq!(2 + 2, 4);
    }
}
cargo test
  • Use [dev-dependencies] for test runners, assertion helpers, mocks, temporary-file helpers, and benchmark tools.
  • Use [dependencies] only when normal library or binary code needs the crate.
  • Test code in tests/ can use dev-dependencies.
  • Unit-test modules should normally use #[cfg(test)].
  • A downstream crate does not inherit your dev-dependencies.
  • build.rs dependencies belong in [build-dependencies], not [dev-dependencies].

FAQ

How do I add a dependency only for Rust tests?

Add it under [dev-dependencies] in Cargo.toml, then run tests with cargo test.

Will users of my library download a dev-dependency?

Not as part of a normal dependency build of your library. Dev-dependencies are intended for your package's development targets rather than its normal library build.

Can unit tests use dev-dependencies?

Yes. Put the test code in a #[cfg(test)] module and import the crate there.

Can files in the tests/ directory use dev-dependencies?

Yes. Integration tests are development targets and can use crates listed in [dev-dependencies].

Should compiletest be a normal dependency for a lint plugin?

Usually no, if it is only used to run the plugin's test suite. Put it under [dev-dependencies]. Use a normal dependency only if production code imports it.

Is #[cfg(test)] enough by itself?

No. #[cfg(test)] controls whether code is compiled for tests; [dev-dependencies] tells Cargo which external testing crates are available.

When should I use [build-dependencies] instead?

Use it only for crates imported by build.rs. A test harness that runs through cargo test belongs in [dev-dependencies].

Take Quiz

Mini Project

Description

Create a small rule-name validator library with a test-only assertion helper. The project demonstrates that a dev-dependency can improve test output without becoming part of the library's normal implementation.

Goal

Write and test a public function while keeping the assertion helper limited to development and test builds.

Requirements

Create a library crate with a public is_valid_rule_name function. Accept names containing ASCII letters, digits, and underscores. Reject empty names and names containing spaces or hyphens. Declare pretty_assertions as a dev-dependency. Write unit tests that use pretty_assertions::assert_eq!.

Take Quiz

Keep learning

Related questions

View more