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?
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.
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:
- 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>;
}
}
- 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/
- 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
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.
- 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;
}
- 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.
- 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.