Mock Objects (manually) in Rust
As I've been learning the Rust programming language and the Godot game engine (streaming at http://www.twitch.tv/paytonrules) I've been increasing the amount of testing I've been doing. I started by doing..well none.. and have been moving more and more into a TDD flow as I become more comfortable with the language and environment. One of the real challenges with TDD in Rust was figuring out how to use mock objects or substitutes.
Manual Mocks
Like we do with apprentices at 8th Light I decided to write my mock objects manually, before taking on a mock object framework. I do this to learn how mock objects would work before I use a framework, and to see if a framework is even necessary. As a refresher, mocks are used when you have a dependency that will screw up unit testing, such as accessing the network or file system. The traditional approach is to break up one object into multiple objects, and then replace the troublesome object under test with a mock that logs your calls or responds with canned responses. In this way you can test the behavior around the bad dependency, without using the dependency itself. I'm glossing over this because it's a complicated concept that I can probably better illustrate with an example.
In the "Capture" project that I've been working on in Godot and Rust I keep a list of reminders in a Gitlab repository, using their REST API. As of this writing the struct looks like this:
#[derive(Debug)]
pub struct Inbox<T: Storage> {
storage: T,
pub reminders: String,
}
The inbox struct owns two fields - a string representing the reminders (which should probably be a function) and a storage object of trait T
. That trait is key for creating a mock object, because I need to be able to create two different versions of the storage object. One is GitlabStorage which wraps the API calls that I don't want running in a unit test, and another is a dummy version - or the mock. It is in this regard that Rust creates a special challenge. I think I can illustrate that with an example of how I originally implemented the struct. The original version looked something like this:
pub struct Inbox<'a, T: Storage> {
storage: 'a& T,
pub reminders: String
}
Creating a Mock
If you go through the history of this project (http://www.github.com/paytonrules/Capture) it doesn't quite look like this, but it's close enough. In this implementation Inbox
borrows the storage
object and doesn't own it. Why? Because when you use mock object you need to be able to access it after the actual code executes to check for assertions. You can do this in a test like so:
let storage = MockStorage::new();
let mut inbox = Inbox::new(&storage);
inbox.save(&"note".to_string())?;
assert_eq!("\n- note".to_string(), storage.inbox());
In this test I've created a MockStorage
object, then when I initialize the Inbox
struct, I give it a borrowed version of the struct. In this case the test owns the MockStorage
object, and I'm lending it to Inbox
. You can see in the declaration of Inbox
it's borrowing storage
and it's doing so immutably. You might wonder how it can do that and have the storage object still be useful, and I'll explain that in a minute, but the key parts to understand about this implementation are that in order to check the storage.inbox
after calling Inbox::new(&storage)
both the test and Inbox
must share access to the storage
. This is true in every language, but in memory-managed languages (and Mock Objects were developed in memory managed languages) we just don't worry about it. Who owns the object? Who's responsible for destroying it? Who cares! The garbage collector handles it. That's the advantage of a garbage collected language.
Testing Fails
Unfortunately this implementation suffered from what DHH memorably called Test Induced Design Damage. I don't agree with everything DHH writes there, but I happen to think the it applies to my code here. In this case Inbox
has a borrowed reference to storage
but it shouldn't need to. It's only borrowing that reference, and dealing with the associated lifetimes, so that I can write a test. The fact that Inbox
and separated storage
is probably improved design, as they can change independently, but he fact that Inbox
doesn't own storage
is an unpleasant accident. Incidental complexity caused by testing. This became a problem when integrating this code into my Godot
app, because currently you cannot use lifetimes with Godot
nodes (which may not be fixable, due to the way Godot works).
When I found I couldn't integrate Inbox
into the Godot app I had to rethink my test implementation. I realized I wanted the implementation of Inbox
without a borrow, this one:
#[derive(Debug)]
pub struct Inbox<T: Storage> {
storage: T,
pub reminders: String,
}
but I didn't know how I could get that to work, or if it was even possible. I've glossed over how MockStorage
works up to now but it's time to take a look at it:
pub struct MockStorage {
inbox: RefCell<String>,
update_error: Option<String>,
load_error: Option<MockError>,
}
impl Storage for Rc<MockStorage> {
fn update(&self, inbox: &String) -> anyhow::Result<()> {
*self.inbox.borrow_mut() = inbox.to_string();
}
/* ... */
I've ripped out a lot of impl
for brevity so you can see the whole file if you want to here but I have to warn you that I'm likely to move that in the near future. What I've done here is create a MockStorage
object that implements the Storage
trait. If you're familiar with how mocks work in more common languages this should be pretty familiar, I've written an implementation of an interface. The MockStorage
struct has an inbox
that's holding a RefCell
to a String, representing the phony inbox that doesn't need the internet. Remember removing a dependency on the network was the point of these mocks.
The RefCell
can be thought of, in C++ terms, as a "Pointer to changeable memory spot" but this being Rust it uses runtime checks under the hood to make sure you don't screw it up. The reason we need a RefCell is because the trait function update
doesn't take a mutable version of self
, and we don't want it to because the real code doesn't require it.
Another oddity to this code is that we implement Storage
not for MockStorage
but for a Rc<MockStorage>
. This is a feature that I haven't seen in a language before, letting me write functions for a generic type, and on a type (Rc
) that is already defined in the standard library. Why do this instead of MockStorage
, because it allows this:
let storage = Rc::new(MockStorage::new());
let mut inbox = Inbox::new(Rc::clone(&storage));
inbox.save(&"note".to_string())?;
assert_eq!("\n- note".to_string(), storage.inbox());
}
You can see the test slightly. On the first line we create an Rc
of type MockStorage
, where Rc
means the type is reference counted so we can give ownership to the Inbox
while simultaneously keeping ownership in the test. Think of it like shared custody. Through the Rc
the object will now be destroyed when the reference count reaches zero, which happens when this test is finished. It means that the test is able to access the same storage as the inbox, without requiring a borrow in the production code. This means we can test our code (in this case we are testing save
on inbox
actually uses the storage
to save) without "damaging" the production code.
Writing A Mock
I've written all of this without really talking about how you write mock objects for testing. That's because when I wrote this code I learned a lot more about ownership than I did about mock objects, but you probably came here for that information so let's sum up how to write mock-based tests in Rust (with your own mocks).
Decide who owns the object
Before you even write a line of the test you need to decide who is going to own the object you're mocking out. Is it going to be owned by the caller, or object under test. If it's going to be the caller then you're going to either need to borrow that object in the struct under test, or the better solution is to probably pass that object directly to the functions that use it. After all if the struct doesn't own the object does it really need it at all?
Instantiate the mock
If the struct is going to own the mock object then you'll need to use an Rc
to create the object in the test, so that you'll have access to it after you pass it to the struct. That's these two lines:
let storage = Rc::new(MockStorage::new());
let mut inbox = Inbox::new(Rc::clone(&storage));
If the struct is borrowing the object you can simply instantiate the object in the test, and use borrow it when you pass it to the inbox, like my original implementation:
let storage = MockStorage::new();
let mut inbox = Inbox::new(&storage);
It's cleaner in the test for sure, but this isn't a very clean way to work with the struct
in your production code, most of the time.
Create a mock struct and trait
Create a mock struct that implements the trait you want to replace. If you're doing top down TDD the trait might not yet exist, which means you can pick the ideal trait you wish existed. Do so, but remember to focus on production code with that trait, as opposed to test code which may have different ownership requirements. In general I agree that using the tests to design your interface is a great idea, but when it comes to ownership tests may have different requirements.
Use RefCell(s) to track calls
In the case of MockStorage
I simply use a RefCell<String>
to store the data passed to the storage
object. You can use any data you want to track the calls, but the trick is to use the RefCell
and the Interior Mutability Pattern so that the trait your implementing isn't required to use mut
just for testing.
Use the mock
Now that you've set that up, you can write the rest of the test!
let storage = Rc::new(MockStorage::new());
let mut inbox = Inbox::new(Rc::clone(&storage));
inbox.save(&"note".to_string())?;
assert_eq!("\n- note".to_string(), storage.inbox());
}
Tidy up
Remember that your tests should be as clean and easy to follow as your real code. Mock objects can violate that by making it harder to tell what you're actually testing, so be careful and thorough when writing them. In addition to taking a look at naming, setup, etc make sure that when you actually use the object under test it is at least as easy to use in production as it is in tests. Otherwise it's time to tweak the test, like I did.