Skip to content

Instantly share code, notes, and snippets.

@Nekrolm
Last active April 28, 2025 18:24
Show Gist options
  • Save Nekrolm/82827563061fcf9488473fcad9aa1f03 to your computer and use it in GitHub Desktop.
Save Nekrolm/82827563061fcf9488473fcad9aa1f03 to your computer and use it in GitHub Desktop.
Mock testing in Rust projects

Mock testing in Rust projects

Rust usually gives you a pleasant experience: if it compiles — it works. But strong type checking doesn’t eliminate necessity of testing. Especially if your code deals with some external dependencies:

  • Have your checked the behaviour of your code on all error paths? May there be typo, incorrect condition, that passes type checks but contradicts with your intentions?
  • What if dependency returns success on the first call, but error for the second?
  • What if there’s sudden network issue?

To cover such cases there is a common approach — substitute, or “mock”, your dependencies by special, configurable test objects with same interface. And here our story begins.

There are multiple ways how to do such mocking. Some of them differs just in style, influenced by other programming languages. But the significant difference arises depending on the size of your code base, and the simple question: was it written with testing in mind?

Before we start

Technically you don’t need any crate: you always can implement mocks manually. But, depends on your requirement, it may lead to bunch of repetitive boilerplate code. Thus, of course, there are pile of different crates that can do it for you. The most popular crate for mock testing in Rust — mockall https://docs.rs/mockall/latest/mockall/ I’ll show some examples how to use (and abuse) it.

Lazy and desperate approach

You had written entire project without any test (yeah, it compiles - it works). It’s big, multicrate ( you tried to speed up compulation :) ) project: let’s say 10 crates in one cargo workspace. But then you realised:

  • Oh, this external dependency behaves weirdly... How can I debug my logic in this particular test?

Let’s assume the dependency is represented by something like this

// defined in third-party crate, you don't have control over it
struct ExternService { ... }
impl ExternService {
   pub fn new(cfg: &str) -> Self;
   pub fn foo(&self) -> Result<(), Err> {...};
   pub async bar(&self) -> Result<i32, Err> {...}; 
}

You need to substitute the dependency with fake object that can reproduce the weird scenario. How to do that? Your code is already written. You already have hundred functions like

fn do_this(app: &MyApp, srvc: &ExternService)
fn do_that(retry: bool, srvc: ExternService)

and this new method also can be called in dozen places, and you probably have another structs with this ExternService inside

struct SubTask {
   srvs: Arc<ExternalService>
}

and so on

What can you do to mock it without rewriting everything?

Well, there is a solution to cover your bad foresight:

  1. Create another struct:
struct MyExternService {
   // put whatever you need here
}

impl MyExternService {
   pub fn new(cfg: &str) -> Self { todo!() } 
   pub fn foo(&self) -> Result<(), Err> { todo!() };
   pub async fn bar(&self) -> Result<i32, Err> { todo!() }; 
}

You don’t need to do that manually: you can utilise mockall feature for mocking:

 mockall::mock!{
    pub ExternService { // will generate type MockExternService
        fn new(cfg: &str) -> Self;
        fn foo(&self) -> Result<(), Err>;
        async fn bar(&self) -> Result<i32, Err>;
    }
}
  1. Use compile time feature flag to substitute the real dependency
// You cannot use just `cfg(test)` if you have multi crates workspace and you use 
// ExternService across them
#[cfg(feature = "mock-extern-service") 
use MyExternService as ExternService;
#[cfg(not(feature = "mock-extern-service"))]
use extern_service::ExternService;

For single crate cases this can be done with https://docs.rs/mockall_double/latest/mockall_double/

  1. Don’t forget to enable the feature everywhere for test.
cargo test --features=mock-extern-service

Congratulations! Now you can configure the mock object for tests and debug your logic! Hooray!

And also your code now infected with useless feature flags that you have to enable in test builds and disable for release builds. And I’ve used word “infected” to not just sound pompously. cargo features are literally infect workspaces: because they are global and additive. Let me show the simplified example:

Create a simple workspace

mkdir feat_test
cd feat_test
touch Cargo.toml

Put this into Cargo.toml

[workspace]
resolver = "2"

The let’s create 3 crates:

cargo new —lib alib
cargo new —lib blib
cargo new —lib clib

Then introduce the feature for clib

[package]
name = "clib"
version = "0.1.0"
edition = "2024"
[dependencies]

[features]
feat = []
// clib/src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(feature = "feat")]
pub fn special_function() {
    println!("Something special")
}

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

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

And add clib as a dependency for alib and blib.

[package]
name = "blib"
version = "0.1.0"
edition = "2024"
[dependencies]
clib = { path = "../clib" }
[package]
name = "alib"
version = "0.1.0"
edition = "2024"

[dependencies]
clib = { path = "../clib", features = ["feat"]} # I want to enable the feature for alib only

And now, in blib I can try to use special_function

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

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

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn test_feature_additivity() {
        clib::special_function();
    }
}

if I run test only for specific crate — it will fail to compile (as expected)

cargo test --package blib --lib -- tests::test_feature_additivity --exact --show-output 
feat_test % cargo test --package blib --lib -- tests::test_feature_additivity --exact --show-output 
   Compiling blib v0.1.0 (feat_test/blib)
error[E0425]: cannot find function `special_function` in crate `clib`
  --> blib/src/lib.rs:17:15
   |
17 |         clib::special_function();
   |               ^^^^^^^^^^^^^^^^ not found in `clib`
   |
note: found an item that was configured out
  --> feat_test/clib/src/lib.rs:6:8
   |
6  | pub fn special_function() {
   |        ^^^^^^^^^^^^^^^^
note: the item is gated behind the `feat` feature
  --> feat_test/clib/src/lib.rs:5:7
   |
5  | #[cfg(feature = "feat")]

But if I run the tests in workspace — it will pass! Even if the blib doesn’t specify feature for clib.

feat_test % cargo test
   Compiling clib v0.1.0 (feat_test/clib)
   Compiling blib v0.1.0 (feat_test/blib)
   Compiling alib v0.1.0 (feat_test/alib)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 3.78s
     Running unittests src/lib.rs (target/debug/deps/alib-3bee22d47c660527)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/lib.rs (target/debug/deps/blib-0cf6f6ebe5a2a988)

running 2 tests
test tests::it_works ... ok
test tests::test_feature_additivity ... ok

If we’d make a step back to mocks provided & replaced via feature flags, we can realise a potential disaster:

if any misguided fool adds a new crate to the workspace, e.g. to implement some testing cli utility, and enables the feature in default dependencies (not a [dev-dependencies]) in that new crate only. IT WILL BE ENABLED IN ALL CRATES! It will be fun to find: you are truing to deploy and test the code, but its critical dependency... doesn’t work because it was accidentally replaced with Mock.

Don’t do that. Seriously. Never do such feature-flag based replacement. It may safely work only if the replacement happens on [cfg(test)] Test configuration doesn’t propagate beyond crate boundary.

Also such approach drastically degrades developer experience:

  • Feature may be not enabled by default, RustAnalyzer will highlight everything with errors. You need to spend time to properly configure the feature flags
  • Integrated test buttons in your favourite IDE (with RustAnalyzer) will not work — tests will not compile, as we had seen above — when they runs outside of workspace, for specific crate & specific test — feature may not be enabled

Let’s do it in the right way

We are engineers, we can solve any problem by introducing yet another abstraction (expect of the problem of too many abstractions). For the mock testing — it’s a right way. We should operate on interfaces if we want to be able to easily test our logic with some fake pre-scripted implementations. An Rust as programming language has traits — natural way to define abstract interfaces. Depends on the rustc version, traits have their own caveats, when async or generic parameters are involved.

  1. Define the trait:
pub trait ExternService: Send + Sync { // Most likely, if there's async method, you'll need both Send & Sycn
   fn new(cfg: &str) -> Self where Self::Sized;
   fn foo(&self) -> Result<(), Err>;
   // async fn in traits misses +Send. For multi threaded execution it will be required
   fn bar(&self) -> impl Future<Output = Result<i32, Err>> + Send; 
}
  1. mock it, manually or via mockall
  mockall::mock!{
    pub ExternService {}
    
    impl ExternService for ExternService { // will generate type MockExternService
        fn new(cfg: &str) -> Self;
        fn foo(&self) -> Result<(), Err>;
        // thanks to the Rust feature -- we can use async syntax sugar here
        // don't need to repeat impl Future and workaround corresponding mockall issues
        async fn bar(&self) -> Result<i32, Err>;
    }
}

For the latest rustc version (1.86 at the time of writing this post), most serious (for me) issue with traits usage for dependency injection in tests were resolved:

built-in async traits — no unnecessary Future allocations, like it was when async_trait macro is used. If the dependency API exposes some async function, wrapping it into Box (by async_trait) layer just to allow comfortable testing — it was inappropriate solution for hot paths, when every microsecond matters.

  1. Update your functions:
// impl Trait syntax feature can save you a lot of time for such refactoring
// just replace concrete struct name with impl TraitName
fn do_this(app: &MyApp, srvc: &impl ExternService)
fn do_that(retry: bool, srvc: impl ExternService)

and structs

// Yeah, this is downside -- if you want to have the same codegen as it was before,
// when the concrete structs were used, you have to infect your structs with generics
// Another following downside -- compilation time may become slower 
struct SubTask<E: ExternalService> {
   srvs: Arc<E>
}

// Yeah, we need to repeat it again
impl <E: ExternalService> SubTast<E> {
   ....
}

// and if your struct had some derived traits (like Clone), you'll need to workaround them
// #[derive(Clone)]
// struct SubTask<E: ExternalService> {
//  srvs: Arc<E> // derive(Clone) proc macro is dumb: it will require E: Clone, even if the Arc itself can be cloned
// }


// If you are fine with dynamic dispatch
struct SubTask {
   srvs: Arc<dyn ExternalService>
}
// but be aware: dyn Trait doesn't directly support "static" methods (like new() in our example) 
// and generic parameters

And then, hell yeah! If everything is updated, you can configure mock structure and pass it in tests only. Explicitly. Without risk of breaking functionality by accident & without strange feature manipulation.

To be honest, and it’s clear: this approach is complicated to apply to the large existing code base. You need to follow it from the start of the project, as I stated at the beggining, otherwise — yeah, you will spend couple of weeks in refactoring. Actually, I had spent a week refactoring 30k LOC project that followed the first approach. And it had a lot of dependencies mocked this way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment