tldr: conditional #[cfg(test)] can be used as a homegrown solution for mocking clients that call to other services.
I am trying to convince my team at work to write one of our webservices in Rust. I have developed a proof of concept that is cleanly written and works as expected. However, at my company (and hopefully all companies) unit tests are a requirement for production grade software.
“Unit tests are easy!” I thought to myself, thanks to Rust’s type system I already have all information about the structs and methods I need to work with, all I need to do is mock out this database call…
Suddenly, I realized that I had never actually mocked anything out in Rust. My first approach was to search for existing mocking solutions, and I came across two libraries for mocking, that may have promise in the future, but do not suit my current needs. Unfortunately, they seem to work only with traits, and have several limitations (how do I see the call history for methods I am mocking, etc?).
I am writing this in case it can help anyone that is dealing with the same problems I was.
In order to explain how I came to my solution (which may not be the best, but it works for me), let us consider the following struct that represents an incoming request to search for people in a geographic region based on several criteria. It will search a database and then take the results and map them to a more friendly format:
use clients::db_client;
pub struct ExecutableRequest {
pub address: Address,
pub gender: Gender
}
impl ExecutableRequest {
fn execute(&self) -> Result<PrettySearchRecords, ()> {
match db_client::find_people(self.address, self.gender) { // how do we mock this call in unit test?
Err(_) => Err(()),
Ok(raw_search_results) => Ok(map_to_pretty_search_format(raw_search_results))
}
}
}
If I were to unit test this purposefully simple example, I would want to make sure that given a successful db response, I would want to make sure that execute
returns some expected mapped object.
The simplest approach is probably to use trait objects, and simply inject it into the execute method as a parameter such as:
fn execute(&self, db_client: &DbClient)
That way in our test we can simply create a dummy struct that implements the DbClient trait, and then use it as desired. However, this implies a performance penalty due to dynamic dispatch and forces me to add a parameter to a calling method. If I only have one db_client that I will ever use, why do I need to pass it into the method every time?
Another way is to use generics. When we are running the program normally, we can instantiate the struct with the concrete db client generic impl, and when running in a test environment, we can instantiate it with a mock impl. This was the first approach I tried, but I did not like the complexity that introducing generics created, especially since the generic solution was not needed by the program and only existed to serve the unit tests.
Before continuing, I changed my executable struct to the following. Notice how I am no longer using db_client as a module with standalone functions, I have created a unit struct (with no fields) called DbClient that I am placing inside my request.
struct DbClient;
impl DbClient {
pub fn new() -> Self {
DbClient
}
pub find_people(&self, address: Address, gender: Gender) -> DbResult {}
}
pub struct ExecutableRequest {
pub address: Address,
pub gender: Gender,
pub client: DbClient
}
When executing the call, I simply use self.client.find_people
.
I ultimately decided to use conditional compilation to my advantage in order to avoid using generics. Recall the db_client from the earlier code. To make my code testable, I modified the clients/mod.rs file to the following:
pub mod db_client {
#[cfg(not(test))]
pub use super::db_client_concrete::*;
#[cfg(test)]
pub use tests::mocks::db_client::*;
}
That way, when I run unit tests, I can guarantee myself that a mock implementation will be used. However, this still does not solve the issue of viewing the client’s call history, as well as forcing the client to return expected_results for testing purposes. This is where the artisanal mocking comes into play. Unfortunately, this is very adhoc and requires a custom implementation for every client I am mocking, which is slightly annoying, but ultimately not a serious issue:
// changed from a unit struct to contain testing information about how it is called
struct DbClient {
pub expected_result: DbResult,
pub call_history: RefCell<Option<CallArgs>>
}
pub struct CallArgs {
pub address: Address,
pub gender: Gender
}
impl DbClient {
// notice the new impl has changed
pub fn new() -> Self {
DbClient {
should_fail: false,
call_history: None
}
}
// new method added to client in order to get the call history after execute is returned
pub fn get_call_history(&self) -> CallArgs {
let call_history = self.call_history.clone();
let call_history = call_history.borrow();
call_history.clone().unwrap()
}
pub find_people(&self, address: Address, gender: Gender) -> DbResult {
let call_args = CallArgs {
address: address,
gender: Gender
};
*self.call_history.borrow_mut() = Some(call_args);
self.expected_result.clone()
}
}
Now when I am performing unit testing, I am able to mock the returned response from the db client, as well as confirm that it was passed in the appropriate args.
I hope that this is helpful. It is certainly not perfect by any means, but I am posting it here since I was unable to find another solution that was comfortable for me.