The copy-paste trap
You are writing tests for a user authentication module. The first test creates a mock user, hashes the password, and checks the login. The second test does the same setup but checks password reset. The third test checks session expiration. By the fourth test, you are copy-pasting ten lines of setup code. You change the password hashing algorithm, and suddenly three tests fail not because the logic broke, but because you forgot to update the setup in one of them. The test suite becomes a minefield of duplicated boilerplate. Changing a helper requires hunting through half a dozen test files.
Don't copy-paste setup code. Abstract it once.
Helpers as mise en place
Test helpers are the mise en place of your test suite. In a professional kitchen, chefs do not chop onions from scratch for every single dish. They prep bowls of chopped onions, minced garlic, and diced peppers at the start. When the order comes in, they grab the prepped ingredients and assemble the dish. Shared test utilities work the same way. You write the setup logic once in a helper function or module. Your tests grab the prepped data and focus on the actual assertion. This keeps your tests readable and ensures consistency. If the recipe changes, you update the prep station, not every individual plate.
Treat your test helpers like production code. They need to be reliable.
Local helpers in #[cfg(test)]
The standard starting point is a helper function inside a #[cfg(test)] module. This attribute tells the compiler to include the module only when running tests. When you build your library for release, the entire block vanishes. It costs zero space in your final binary.
#[cfg(test)]
mod tests {
use super::*;
// Helper: creates a default user for testing.
// Lives in the test module so it does not bloat the public API.
fn create_test_user() -> User {
User {
id: 1,
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
}
}
#[test]
fn test_user_display() {
let user = create_test_user();
assert_eq!(user.display_name(), "Alice");
}
#[test]
fn test_user_email_validation() {
let user = create_test_user();
assert!(user.is_valid_email());
}
}
Inside the module, helper functions behave exactly like normal functions. They can return values, take arguments, and panic. The compiler treats them as private to the module by default. This is good. Test helpers are implementation details of your verification strategy. They should not leak into your public API. If a helper becomes complex enough that you want to share it across modules, you will hit Rust's visibility rules. That is a signal to restructure.
Helpers are just functions. Keep them pure.
The TestContext pattern
When tests share complex state, a simple function is not enough. You might need a mock database, a configured HTTP client, and a set of pre-loaded users. Grouping this state in a struct prevents duplication and makes the dependencies explicit. The community calls this a TestContext or TestFixture.
#[cfg(test)]
mod tests {
use super::*;
// Context holds shared state for a group of tests.
// Encapsulates setup logic so tests stay focused on assertions.
struct TestContext {
db: MockDatabase,
user: User,
}
impl TestContext {
// Factory method to set up the full environment.
// Returns a fresh instance to avoid shared mutable state.
fn new() -> Self {
let db = MockDatabase::default();
let user = User::new(1, "Bob");
db.insert_user(&user);
Self { db, user }
}
}
#[test]
fn test_delete_user_removes_from_db() {
let ctx = TestContext::new();
delete_user(&ctx.db, ctx.user.id);
assert!(!ctx.db.user_exists(ctx.user.id));
}
#[test]
fn test_delete_nonexistent_user() {
let ctx = TestContext::new();
delete_user(&ctx.db, 999);
assert!(ctx.db.user_exists(ctx.user.id));
}
}
The TestContext struct centralizes the setup. Each test calls TestContext::new() to get a clean slate. This pattern scales well. You can add methods to the struct for common actions, like ctx.create_post() or ctx.login_as_admin(). The tests read like documentation.
If your test setup takes more lines than the assertion, you need a helper.
Pitfalls and compiler traps
Test helpers introduce their own class of bugs. The compiler catches many of them, but you need to know what to look for.
Privacy walls. If you put helpers in a tests module and try to call them from another test module, you hit a visibility error. Rust enforces privacy rules even in tests. You will see E0603 (module is private) or E0432 (unresolved import). The fix is to make the helper pub(crate) if you need to share it across modules within the crate. This exposes the helper to the rest of the crate but keeps it hidden from external users.
Trait bounds. Helpers often return custom types. If you pass those types to assert_eq!, the compiler requires PartialEq and Debug. If your type lacks these traits, you get E0277 (trait bound not satisfied). Derive the traits on your test types or implement them manually. Test helpers frequently return types that need these traits for assertions.
Parallel state mutation. Rust's test runner executes tests concurrently by default. If a helper mutates global state, tests will stomp on each other. A helper that writes to a static variable or a shared file causes flaky failures. Always return fresh data from helpers. Never rely on side effects that persist across test calls.
Accidental test attributes. Slapping #[test] on a helper function by accident causes the compiler to run it as a test. If the helper does not assert anything, it passes silently. If it panics, you get a confusing failure from a function named setup_data instead of the actual test. Keep helpers as plain functions. Only mark functions that perform assertions with #[test].
Parallel tests eat shared state for breakfast. Return fresh data.
Sharing across crates
Local helpers work great for unit tests inside a single crate. They break down when you need to share utilities across a workspace. The #[cfg(test)] attribute binds code to the crate's test configuration. If Crate A has a helper in #[cfg(test)], Crate B cannot see it. Integration tests live in the tests/ directory and compile as separate crates. They cannot access private helpers inside src/lib.rs.
The solution is a dedicated test-utils crate. This crate compiles normally and exposes public helper functions. You add it as a dev-dependency in your workspace crates. This bypasses the #[cfg(test)] isolation and gives integration tests access to shared setup logic.
Create a crate named my-crate-test-utils. Add it to your workspace. In the crates that need the helpers, add it to Cargo.toml:
[dev-dependencies]
my-crate-test-utils = { path = "../test-utils" }
The helpers in test-utils are just public functions. They do not need #[cfg(test)]. They compile into the test binary of any crate that depends on them.
// In test-utils/src/lib.rs
/// Creates a mock user with a valid email.
/// Used across unit and integration tests for consistent setup.
pub fn create_test_user() -> User {
User {
id: 1,
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
}
}
Community convention favors naming these crates with a -utils or -test suffix. This signals that the crate contains support code, not production logic. Another convention: add #[allow(dead_code)] to test modules. Test helpers often go unused in specific files, triggering warnings. The attribute silences these noise warnings without affecting safety.
Integration tests live in their own universe. Bridge the gap with a shared crate.
Decision matrix
Use a local helper function when the setup is simple and only needed within one test module. Use a TestContext struct when multiple tests share complex state like database connections or mock servers. Use a separate test-utils crate when you need to share helpers across multiple crates in a workspace. Use pub(crate) visibility when helpers must be shared across modules inside the same crate. Reach for #[allow(dead_code)] when test helpers trigger unused warnings in specific files.
Start simple. Extract to a crate only when the pain of duplication outweighs the cost of a new dependency.