Make your Rust code unit testable with dependency inversion
It was an epiphany moment when I figured out Java classes can be mocked by extending them and overriding their public methods.
“Well, duh”, you’re probably thinking. Of course you can do that, and that’s how half the mocking libraries work anyway! (Also, why not just use a mocking library?) But it really was an epiphany moment for me. It’s like when you’re driving around town and you go down a street you haven’t been down before, but then it pops you out at a familiar intersection. A new part of the map has been revealed in your mind. This was the same for me: it was a moment that tied together dependency injection, inheritance, and mocking for unit tests. I grasped what it meant to write testable code.
Since then, I’ve worked in Ruby and Rust, as well as a few other languages. Ruby makes this whole process stupidly easy—it doesn’t care what you pass in as an argument or a dependency, so long as you have methods with the same names. In Rust however, this is much trickier. Rust is very picky about types and imports and method signatures. It also does not have inheritance (unlike Java). So what are we supposed to do? Even if we inject our dependencies, it’s not straightforward to mock them out.
Turns out, mocking is still possible in Rust, and not only that, writing mockable code also means writing loosely-coupled code. This is a win-win! In order to write testable code, we also end up writing well-architected code. It all hinges on this principle called dependency inversion.
Unit testing basics
Let’s briefly lay out some terms to get on the same page, starting with the term “unit test” itself. A unit test is a test of a single “unit” of logic; typically this unit is a function. In Rust there is an idiomatic way of writing and running these unit tests. They live in the same file as your source code to simplify code organization. Plus, this gives them permission to run private functions that would be inaccessible outside the file. You can run them very easily with cargo using the command cargo test
. The Rust book explains setting up your unit tests in more detail.
It’s a good idea to have excellent unit test coverage. This helps you verify new logic as you write it, as well as prevent regressions to any logic you’ve already written. The more coverage you have, the better protection you have from regressions. It takes some time up front to write tests, but it can save a lot of time down the road, since bugs get more expensive to fix the further they go along the SDLC. On top of that, you need good unit test coverage to enable continuous delivery. Amazon for instance recommends that 70% of all the tests you write be unit tests since they’re quick to run. They enable fast feedback.
To really be successful, unit tests should also be deterministic. This is where mocking comes in: you can get rid of any network calls or other indeterministic behavior by controlling all of the dependency code that your code is executing. This also allows you to track information going into and out of the dependency. However, mocking can be a challenge. It requires your test to be able to mock a dependency and get the dependent code to use your mocked version. This is why “dependency injection” is also commonly used (not to be confused with “dependency inversion”!), which is the practice of passing dependencies into an object through its constructor. This means dependencies are instantiated outside the object, which is perfect for your test! You can instantiate a mocked version and then pass it into the class you’re testing.
What is Dependency Inversion?
With the unit testing basics covered, let’s talk about dependency inversion. Dependency inversion is one of the five SOLID principles, where SOLID is an acronym meaning:
- Single Responsibility
- Open / Closed Principle
- Liskov Substitution Principle
- Interface Segregation
- Dependency Inversion
I won’t go into all of those here, but I will mention that SOLID is a set of principles that apply at the class level (if we’re thinking about something like Java or Ruby). For some languages, like C, it might make more sense to say it applies at the file level. For Rust, there isn’t a clearly-defined notion of a class, but when we make a struct
and give it an impl
with methods, and we have a new()
method that we’re passing dependencies into, then we basically have a class. This is the level the SOLID principles apply at in Rust.
So what is dependency inversion? As a principle it states that classes should depend on abstractions, not concretions. Said another way, depend on a Trait
in Rust, not on a struct
or other concrete type. This allows you to swap out the underlying implementation of that Trait
without affecting your code.
Imagine for a second that you’re writing some code that will use a database to persist certain data. The database has a simple CRUD API. You have two options: you can either depend on a concrete type that talks to the database, or you can depend on a Trait
that has the same CRUD API. Of the two options, it’s better for you to depend on the Trait
. Why? Well, let’s say you want to swap the database out in a couple years for cost savings or scale or something like that. If you’ve depended on the concrete type, now you need to go in and change all the places in the code that ever touched the class that talked to the database. You might need to update the type in method signatures, update import statements, update unit tests, and so on. But if you depend on the Trait
instead, then none of your code even needs to know that the database changed! Instead, you create a new implementation of the Trait
, swap out the two implementations where your app is being initialized, and then you’re done! You had to change one or two spots in the code instead of dozens.
This same idea works for your unit tests too. If your code depends on a Trait
instead of a concrete type, your unit tests can simply make a mock implementation and pass it in. You don’t need to do any fancy import hacking to inject mocked types, nor do you have to settle for a half-baked test that includes dependent code. You can get exactly the dependent behavior that you want.
Achieving dependency inversion in Rust
Let’s go through a simple example to see this in action.
Pretend you’ve got a CRUD service with a REST API and a relational database. Given that setup, you might have a file for your API endpoint handlers and a separate type that communicates with your database. We could have the handler use the database type directly, like so:
struct Item {
id: u32,
}
struct Datastore {
connection: DatabaseConnection,
}
impl Datastore {
fn new(connection: DatabaseConnection) -> Self {
Self {
connection,
}
}
fn create(&self, item: Item) -> Result<(), ()> {
self.connection.execute("...some sql here...")
}
fn read(&self, id: u32) -> Result<Option<Item>, ()> {
self.connection.query("...some sql here...")
}
fn update(&self, item: Item) -> Result<(), ()> {
self.connection.execute("...some sql here...")
}
fn delete(&self, id: u32) -> Result<(), ()> {
self.connection.execute("...some sql here...")
}
}
fn handle_get(req: Request, datastore: Datastore) -> Response {
let maybe_id = req.params().get("id");
if maybe_id.is_none() {
return Response::BadRequest;
}
let maybe_item = datastore.read(maybe_id.unwrap());
match maybe_item {
Some(item) => Response::Ok(item),
None => Response::NotFound,
}
}
fn handle_post(req: Request, datastore: Datastore) -> Response {
// code here to create
}
fn handle_patch(req: Request, datastore: Datastore) -> Response {
// code here to update
}
fn handle_delete(req: Request, datastore: Datastore) -> Response {
// code here to delete
}
// etc...
This isn’t a fully fleshed-out example, but the idea is there’s a Datastore
struct holding onto a database connection, and it uses that connection to operate on Item
records. We then have some handler functions that take an HTTP request and an instance of our datastore, and use the datastore to complete their respective operations. They then return a Response
with an HTTP status code. What happens if we try to unit test one of our handlers?
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_handle_get() {
// oh no, to call handle_get() I need an instance of Datastore,
// but to get that I need an instance of a DatabaseConnection!
let datastore = Datastore::new(???)
// that means I need to provide config for an actual database to my unit tests,
// and worse, when the test runs it'll make actual calls to that database!
}
}
We can see we run into an issue right away. If we naively have the handler depend directly on the database type, we’ll have a lot of trouble unit testing. That’s because the database type wants to actually make calls over the wire to a database! The key insight (and I’m channeling Uncle Bob here) is that the way the data is stored is just an implementation detail. From the perspective of the logic, it doesn’t matter what data storage you use, whether that’s a database, a file, or just an in-memory data structure. Since the handler only cares about the behavior and not the actual implementation, we should have the handler depend only on the behavior. That’s what dependency inversion is in a nutshell. You’re making the behavior a first class construct and then depending on it (and the implementation is depending on it as well, hence the inversion part). In Rust, that looks like making a Trait
and only depending on the Trait
, rather than the implementation of the Trait
.
Using our example again, here’s how that might look:
trait Datastore {
fn create(&self, item: Item) -> Result<(), ()>;
fn read(&self, id: u32) -> Result<Option<Item>, ()>;
fn update(&self, item: Item) -> Result<(), ()>;
fn delete(&self, id: u32) -> Result<(), ()>;
}
struct DatastoreImpl {
connection: DatabaseConnection,
}
impl DatastoreImpl {
fn new(connection: DatabaseConnection) -> Self {
Self {
connection,
}
}
}
impl Datastore for DatastoreImpl {
fn create(&self, item: Item) -> Result<(), ()> {
self.connection.execute("...some sql here...")
}
fn read(&self, id: u32) -> Result<Option<Item>, ()> {
self.connection.query("...some sql here...")
}
fn update(&self, item: Item) -> Result<(), ()> {
self.connection.execute("...some sql here...")
}
fn delete(&self, id: u32) -> Result<(), ()> {
self.connection.execute("...some sql here...")
}
}
// Note that our Datastore type is wrapped in a Box now. This is because the Trait by itself
// doesn't have a known size at compile time, since it just captures behavior. Implementations
// of the trait can use different amounts of memory, depending on the fields an
// implementation uses. We need to Box the argument coming in so the compiler has a consistent
// size it can rely on to allocate the right amount of space on the stack
fn handle_get(req: Request, datastore: Box<dyn Datastore>) -> Response {
let maybe_id = req.params().get("id");
if maybe_id.is_none() {
return Response::BadRequest;
}
let maybe_item = datastore.read(maybe_id.unwrap());
match maybe_item {
Some(item) => Response::Ok(item),
None => Response::NotFound,
}
}
fn handle_post(req: Request, datastore: Box<dyn Datastore>) -> Response {
// code here to create
}
fn handle_patch(req: Request, datastore: Box<dyn Datastore>) -> Response {
// code here to update
}
fn handle_delete(req: Request, datastore: Box<dyn Datastore>) -> Response {
// code here to delete
}
// etc...
Datastore
is now a Trait
, rather than a struct
. The concrete implementation of the Trait
is called DatastoreImpl
. An instance of DatastoreImpl
will likely be instantiated when your program starts up, and that instance can then be passed into the handlers, since it implements Datastore
. This means the flow of control still has the handler calling and using DatastoreImpl
at runtime, but the handler doesn’t explicitly know that. As far as it knows, the implementation of Datastore
is totally arbitrary, and that frees us up to use whatever implementation we want at test time. As such, we can easily mock Datastore
to use in our unit tests like so:
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_handle_get() {
let datastore = mock_datastore();
let request = fake_request();
let response = handle_get(request, datastore);
// do some assertions on the response here
}
struct MockDatastore {
map: HashMap<u32, Item>,
}
impl Datastore for MockDatastore {
fn create(&self, item: Item) -> Result<(), ()> {
// insert into the map
}
fn read(&self, id: u32) -> Result<Option<Item>, ()> {
// get from the map
}
fn update(&self, item: Item) -> Result<(), ()> {
// insert into the map
}
fn delete(&self, id: u32) -> Result<(), ()> {
// remove from map
}
}
fn mock_datastore() -> Box<dyn Datastore> {
Box::new(MockDatastore {
map: HashMap::new(),
})
}
fn fake_request() -> Request {
// generate some fake request here with the inputs you want for your test
}
}
I didn’t fill out all of the code since it’s just working with a HashMap
at that point, but I hope the idea of creating a mock implementation of a Trait
for your unit test makes sense! This is a pretty basic example of dependency inversion at work in a simple CRUD application. As your service grows and you start adding additional logic and entry points into the service, you can use dependency inversion to keep your code unit testable. For instance, if you wanted to add a Kafka publisher to start publishing events, you would create a generic Publisher
trait and then have your Kafka publisher be a concrete implementation of it. But at test time, you can mock out a Publisher
and have it do whatever you’d like locally. This same idea applies for HTTP clients, gRPC clients, or any other kind of client you can think of. Any time you want to use a dependency, whether it’s external, over the network, in another package, or even just another class in your own code, you can use dependency inversion.
Final warnings and summary
To close, I have one practical tip: you will be tempted to make beautiful, generic, reusable interfaces when you start doing dependency inversion. I would resist that urge. You don’t need to create the perfect Trait
for wrapping all of your different database interactions if you have multiple types of data you’re trying to store. You can simply create a Trait
per item, like we did in this example. We explicitly called out Item
as the data type we were taking in and returning. We didn’t jump right into making it generic. You should start out very specific as well. Abstract the behavior away by making a Trait
, but continue to use concrete data types as long as they’re simple types that just store data (like our Item
struct). We often jump in and try to make these lovely generic abstractions right away, but from my own experience, that practice is usually more harmful than helpful. Instead, make the abstractions you actually need by building Traits
around the behavior that affects your application’s concrete data types. Wait until you start to see patterns emerge before getting any fancier with generics or other advanced techniques.
It takes a little extra effort to set these Traits
up every time you add a new major dependency, but it will help keep your code loosely-coupled and unit testable in the long run! Your future coworkers (and your future self honestly) will thank you for the easy-to-update code and the wonderful unit test coverage that will continue to detect and prevent regressions for years to come.
I'd also recommend Clean Architecture to learn more about Dependency Inversion and the SOLID principles (along with a lot of other useful info). Full disclosure: if you purchase either of those books through that link I'll get a small commission from Amazon. I have read both however, and I genuinely recommend them. Maybe give them a look if you've got an education stipend through work (that's how I got them originally). Happy coding!