Skip to content
rusthacks.dev
Architecture

Library or Binary Splitting a Rust Crate for Testability

Put logic in a library crate and keep main.rs thin, so tests can call functions, integration tests see the public API and other binaries can reuse the code.

3 min read
Summarize in AI
Flat illustration for the post “Library or Binary Splitting a Rust Crate for Testability”, composed around architecture.

Code in main.rs is hard to test from outside because a binary has no importable API. Move it to lib.rs and the same functions become callable by tests, by other binaries and by benchmarks. The split takes five minutes and is the first structural change most projects should make. It is Stage 2 in organizing Rust projects.

Rust 1.90, edition 2024.

The layout

Text
my-tool/
  Cargo.toml
  src/
    lib.rs      the logic
    main.rs     the entry point
  tests/
    cli.rs      integration tests

No [lib] or [[bin]] sections are needed. Cargo detects both files and builds a library and a binary with the package name.

What goes where

src/lib.rsrust
use std::path::Path;

pub struct Config {
    pub path: String,
    pub verbose: bool,
}

pub fn run(config: &Config) -> Result<usize, std::io::Error> {
    let text = std::fs::read_to_string(Path::new(&config.path))?;
    Ok(text.lines().count())
}
src/main.rsrust
use my_tool::{run, Config};
use std::process::ExitCode;

fn main() -> ExitCode {
    let path = std::env::args().nth(1).unwrap_or_else(|| "README.md".to_string());
    match run(&Config { path, verbose: false }) {
        Ok(n) => {
            println!("{n}");
            ExitCode::SUCCESS
        }
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::FAILURE
        }
    }
}

main.rs imports the library by its package name, my_tool. Everything that can fail or that you want to assert on lives in run.

Testing the split

A unit test can sit beside run in lib.rs:

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

    #[test]
    fn counts_lines() {
        let dir = std::env::temp_dir();
        let path = dir.join("my_tool_test.txt");
        std::fs::write(&path, "a\nb\n").unwrap();
        let n = run(&Config { path: path.display().to_string(), verbose: false }).unwrap();
        assert_eq!(n, 2);
    }
}

Integration tests in tests/ call my_tool::run as an outside user would. Using the binary itself in a test is covered in testing CLI tools with assert_cmd, and the test layout in integration tests.

Several binaries

Files in src/bin/ each become a binary: src/bin/migrate.rs builds migrate. All share the library. A service with a serve and a migrate binary is a common use.

The traps

  • Visibility. Items used by main.rs must be pub in the library. Treat them as an API; the discipline in public API design applies, even if you are the only user.
  • Names. Do not name a module the same as the package, or imports become confusing.
  • Documentation. cargo doc documents the library, and a public item without docs is a hint.
  • Dead code. Warnings change: an unused pub function in a library is not flagged, while one in a binary is.

When to skip it

A throwaway script or a tiny tool of one function does not need a library. Split when a second file of logic appears or when you want a test that does not spawn a process. The Cargo book documents package layout.

Also inTesting
Taggedstructure
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.