DEV Community

XT
XT

Posted on

Testing Rust Code Without Adding Test-Only Traits

A common pattern in Rust unit tests looks like this:

You have a small function that calls the filesystem, a clock, a socket, an FFI function, or some other dependency.

The production code is simple.

Then you try to unit test an error path.

Suddenly you are introducing a trait, adding a generic parameter, threading an implementation through several layers, and maintaining an abstraction that exists primarily because the test needs a seam.

Traits are often the right design. But I don't think every function call should require a trait just because I want to control it in a unit test.

That's the problem I built ShimForge to address.

The problem

Suppose the production code looks like this:

use std::fs;

fn claim_slot() -> Result<(), String> {
    if let Err(error) = fs::create_dir_all("/var/run/dispatcher") {
        return Err(format!("cannot claim a slot: {error}"));
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

There isn't anything particularly wrong with this code.

But testing the failure path means std::fs::create_dir_all has to fail somehow.

One option is to manipulate the real filesystem.

Another is to change the production design:

trait FileSystem {
    fn create_dir_all(&self, path: &str) -> std::io::Result<()>;
}
Enter fullscreen mode Exit fullscreen mode

Then pass a filesystem implementation through the code.

That can be a good architecture if the filesystem really is a dependency your application should model explicitly.

But sometimes it isn't.

Sometimes I just want this:

During this test, when this function is called, return this result.

ShimForge

ShimForge lets a test replace a Rust function while leaving the production code unchanged.

Add it as a development dependency:

cargo add --dev shimforge
Enter fullscreen mode Exit fullscreen mode

The same claim_slot function can then be tested like this:

use shimforge::{mock, Session};
use std::io;

#[test]
fn claim_slot_succeeds_without_a_real_directory() {
    let mut session = Session::new();

    let create = mock!(
        session,
        fs::create_dir_all::<&str>,
        fn(&str) -> io::Result<()>
    );

    create
        .expect()
        .with(|path| *path == "/var/run/dispatcher")
        .once()
        .returning(|_| Ok(()));

    assert!(claim_slot().is_ok());

    session.verify();
}
Enter fullscreen mode Exit fullscreen mode

claim_slot is still the original production function.

The call to fs::create_dir_all doesn't reach the filesystem during the test.

And the expectation also verifies that the function was called once with the expected path.

The error path can be tested the same way:

#[test]
fn claim_slot_reports_directory_failure() {
    let mut session = Session::new();

    let create = mock!(
        session,
        fs::create_dir_all::<&str>,
        fn(&str) -> io::Result<()>
    );

    create
        .expect()
        .once()
        .returning(|_| {
            Err(io::Error::new(
                io::ErrorKind::PermissionDenied,
                "denied",
            ))
        });

    let error = claim_slot().unwrap_err();

    assert!(error.contains("cannot claim a slot"));

    session.verify();
}
Enter fullscreen mode Exit fullscreen mode

No temporary directory.

No filesystem setup.

No test-only trait.

No production-code changes.

Expectations

A mock isn't limited to returning a fixed value.

Arguments can be matched:

read.expect()
    .with(|path| *path == Path::new("service.port"))
    .once()
    .returning(|_| Ok("8080\n".to_owned()));
Enter fullscreen mode Exit fullscreen mode

Calls can be counted:

mock.expect()
    .times(3)
    .returns(true);
Enter fullscreen mode Exit fullscreen mode

Or explicitly forbidden:

mock.expect()
    .never();
Enter fullscreen mode Exit fullscreen mode

Return values can also be computed from arguments with returning(...).

ShimForge can mock normal functions, methods, generic function instantiations, unsafe functions, and extern "C" / extern "system" functions.

There is also replace! for cases where expectation matching isn't necessary:

use shimforge::{replace, Session};

fn checksum(bytes: &[u8]) -> u32 {
    bytes.iter().map(|b| u32::from(*b)).sum()
}

fn fixed_checksum(_: &[u8]) -> u32 {
    7
}

#[test]
fn replaces_checksum() {
    let mut session = Session::new();

    replace!(
        session,
        checksum => fixed_checksum,
        fn(&[u8]) -> u32
    );

    assert_eq!(checksum(b"abc"), 7);
}
Enter fullscreen mode Exit fullscreen mode

The signature is checked at compile time.

Parallel tests

Function replacement raises an obvious question:

What happens when Rust runs tests in parallel?

A normal:

Session::new()
Enter fullscreen mode Exit fullscreen mode

creates a thread-local session.

The mock applies to the current thread while other threads continue calling the original function.

For code that moves execution onto threads you don't control, there is:

Session::new_global()
Enter fullscreen mode Exit fullscreen mode

Global sessions intentionally serialize access because the replacement is visible across threads.

That distinction was important to me. Making every mock process-global would make normal parallel cargo test runs much less useful.

There is a tradeoff

ShimForge isn't pretending that runtime function replacement is free.

The test build needs function calls to remain patchable, so the workspace test profile should disable optimizations that can inline or otherwise transform those calls:

[profile.test]
opt-level = 0
debug = true
lto = false
codegen-units = 1
incremental = false
Enter fullscreen mode Exit fullscreen mode

ShimForge also has runtime constraints around which function entries can safely be patched.

I'd rather make those constraints explicit than present this as magic.

This isn't an argument against traits

I don't think mocking functions should replace dependency injection.

If your application genuinely has multiple implementations of an abstraction, a trait is probably the better design.

If separating a functional core from an imperative shell naturally improves the architecture, do that.

But I also don't think production architecture should always be dictated by what a mocking framework can mock.

There's a difference between:

"This is an important boundary in my program."

and:

"I created this boundary because otherwise I couldn't test one failure branch."

ShimForge is intended for the second case.

It is particularly useful around existing code, system APIs, filesystem calls, C libraries, legacy code, and dependencies that weren't designed around traits you control.

Supported platforms

ShimForge currently supports Linux, macOS, and Windows on both x86-64 and ARM64.

The project is still young — the current release is 0.1.0 — and I'm interested in cases where Rust developers currently have to restructure otherwise reasonable code purely to make a dependency mockable.

If you have one of those cases, I'd especially like to hear about it.

GitHub: https://github.com/XTSoftwareLabs/shimforge

Docs: https://docs.rs/shimforge/latest/shimforge/

Website: https://shimforge.com

Crate: https://crates.io/crates/shimforge

Top comments (0)