Every programming language has its own ways to let us abstract details away, and in Rust it's traits that give us the ability.
Traits in Rust enable lots of flexibilities such as extending functionalities of a data type or dynamic dispatching.
One of them that we'll be focusing on in this article is expressing common behavior of multiple implementations under the interface defined by a trait.
A perfect example of such implementations is a data store. We expect a data store to have the exact same behavior whether it's storing the data in memory or some advanced highly scalable database in the cloud.
On the one hand, we have traits to help us with coordination between the data stores to implement the common interface defined by a trait. On the other hand, while different implementations share the same trait interface, their internal behavior might differ. Therefore, we need to have a good test coverage in order to ensure that all the different implementations behave consistently from the perspective of their dependents.
To effectively achieve such test coverage without duplicating code or writing error prone boilerplate, we need test fixtures which Rust does not support by default. However, we can use rstest which is a great fixture-based test framework for Rust.
In this article we'll practice test driven development and use rstest to accomplish:
- Implementing a data store trait in two different ways
- Maintaining a good test coverage and ensuring different implementations behave the same
- Keeping code and test duplication minimized
TL;DR you can find the code here on GitLab.
Now let's get started by creating a new package.
Getting Started
First of all we need to run a few familiar commands to make a new package for our example and add rstest and rstest_reuse to the dev dependencies.
rstest lets us inject test dependencies (like the data stores we talked about earlier) into different test functions and rstest_reuse makes it possible to apply the same test template on multiple test functions.
$ cargo new rstest_multi_impl_trait
Created library `rstest_multi_impl_trait` package
$ cd rstest_multi_impl_trait
$ cargo add rstest rstest_reuse --dev
Updating crates.io index
Adding rstest v0.18.2 to dev-dependencies.
Features:
+ async-timeout
Adding rstest_reuse v0.6.0 to dev-dependencies.
Updating crates.io index
Context
Let's say we want to manage some Fruit
s.
struct Fruit {
pub id: usize,
pub name: String,
}
In order to do that, we'll need a FruitStore
to add
or remove
our Fruit
s for us and also be able to find
them. Occasionally, we need to know how many Fruit
s we have in the store . So, the store should be able to count
them.
pub trait FruitStore {
fn count(&self) -> usize;
fn add(&mut self, fruit: Fruit) -> Result<(), AddError>;
fn find(&self, id: usize) -> Option<Fruit>;
fn remove(&mut self, id: usize) -> Result<(), DeleteError>;
}
Of course, if we add
some Fruit
twice or try to remove
one that doesn't exist, then we expect an error.
enum AddError {
DuplicateId,
}
enum DeleteError {
IdNotFound,
}
VecFruitStore
We want to keep things simple and that's why we don't want to use a real database or a cloud storage service to implement our FruitStore
. Instead we're going to use a Vec<Fruit>
as our storage backend.
struct VecFruitStore {
storage: Vec<Fruit>,
}
impl VecFruitStore {
fn new() -> Self {
VecFruitStore {
storage: Vec::new(),
}
}
}
And surely VecFruitStore
has to implement FruitStore
.
impl FruitStore for VecFruitStore {
fn count(&self) -> usize {
todo!()
}
fn add(&mut self, fruit: Fruit) -> Result<(), AddError> {
todo!()
}
fn find(&self, id: usize) -> Option<Fruit> {
todo!()
}
fn remove(&mut self, id: usize) -> Result<(), DeleteError> {
todo!()
}
}
Empty VecFruitStore
It's always a better idea to keep things simple at the start and begin with coding the most simple functions with the most basic inputs. Speaking of data stores, the most simple state of them is surely when they're empty.
Probably the simplest function to test on an empty data store is count
since it just needs to return 0
. That's where we start then. Needless to say, we're going to write a test before writing any code and follow TDD as mentioned before.
#[cfg(test)]
mod tests {
#[test]
fn empty_store_counts_zero() {
let store = VecFruitStore::new();
assert_eq!(0, store.count());
}
}
If we run our new test with cargo test --lib
it fails because count
is not implemented yet and just includes a placeholder todo!()
; So, this is our Red step:
running 1 test
test tests::empty_store_counts_zero ... FAILED
failures:
---- tests::empty_store_counts_zero stdout ----
thread 'tests::empty_store_counts_zero' panicked at src/lib.rs:41:9:
not yet implemented
The easiest change to make the test pass is simply returning 0
from count
and that's what we do.
fn count(&self) -> usize {
0
}
running 1 test
test tests::empty_store_counts_zero ... ok
The next simple thing to test is trying to find
a Fruit
in an empty store. Certainly, an empty store has nothing to find
for you.
#[test]
fn empty_store_finds_none() {
let store = VecFruitStore::new();
let fruit = store.find(0);
assert!(fruit.is_none());
}
And making it pass
fn find(&self, id: usize) -> Option<Fruit> {
None
}
running 2 tests
test tests::empty_store_counts_zero ... ok
test tests::empty_store_finds_none ... ok
If we remove
a Fruit
, we should get a delete error.
#[test]
fn empty_store_remove_gives_id_not_found_error() {
let mut store = VecFruitStore::new();
let result = store.remove(0);
assert!(matches!(result, Err(DeleteError::IdNotFound)));
}
fn remove(&mut self, id: usize) -> Result<(), DeleteError> {
Err(DeleteError::IdNotFound)
}
running 3 tests
test tests::empty_store_finds_none ... ok
test tests::empty_store_remove_gives_id_not_found_error ... ok
test tests::empty_store_counts_zero ... ok
Refactoring tests
In the first line of every test we're creating a new VecFruitStore
right now. This is not ideal because:
1) We have duplication
2) How do we reuse our tests if we had another implementation of FruitStore
?
To fix these, we need fixture-based tests so it's time for rstest to enter.
#[cfg(test)]
mod tests {
use rstest::*;
...
The next step is to turn our tests from something like:
#[test]
fn empty_store_counts_zero() {
let store = VecFruitStore::new();
assert_eq!(0, store.count());
}
To
#[rstest]
fn empty_store_counts_zero(store: impl FruitStore) {
assert_eq!(0, store.count());
}
Instead of initializing a new FruitStore
in each test case, let's just receive one from somewhere and focus on the testing part. This way we have a test that doesn't care about what exactly is the store
that it's evaluating as long is it implements the FruitStore
trait.
The next question would be: How the tests receive their store
to run?
Well, rstest makes this is easy. All we need to do is throwing in a fixture function.
#[fixture]
fn store() -> impl FruitStore {
VecFruitStore::new()
}
Applied to all the tests, the result should be the following:
#[cfg(test)]
mod tests {
use crate::{DeleteError, FruitStore, VecFruitStore};
use rstest::*;
#[fixture]
fn store() -> impl FruitStore {
VecFruitStore::new()
}
#[rstest]
fn empty_store_counts_zero(store: impl FruitStore) {
assert_eq!(0, store.count());
}
#[rstest]
fn empty_store_finds_none(store: impl FruitStore) {
let fruit = store.find(0);
assert!(fruit.is_none());
}
#[rstest]
fn empty_store_remove_gives_id_not_found_error(mut store: impl FruitStore) {
let result = store.remove(0);
assert!(matches!(result, Err(DeleteError::IdNotFound)));
}
}
running 3 tests
test tests::empty_store_counts_zero ... ok
test tests::empty_store_remove_gives_id_not_found_error ... ok
test tests::empty_store_finds_none ... ok
Get ready to add another FruitStore
There's still a catch. If we add another implementation of FruitStore
like a HashMapFruitStore
which uses a HashMap<usize, Fruit>
as storage backend, then how can it be tested by the existing tests? Since the fixture function we wrote earlier only returns a VecFruitStore
.
Such situations can easily be handled by rstest_reuse. Just remember that you have to add the following code at the top of the crate (not the module).
#[cfg(test)]
use rstest_reuse;
First we need to get rid of the fixture function and replace it with a template
function.
#[template]
#[rstest]
fn fruit_store(store: impl FruitStore) {}
Secondly, we should change the #[rstest]
on the tests to #[apply(fruit_store)]
. This way rstest can recognize what template has to be applied on which tests.
#[apply(fruit_store)]
fn empty_store_counts_zero(store: impl FruitStore) {
assert_eq!(0, store.count());
}
Finally, we use case
on the template to inject our desired FruitStore
.
#[template]
#[rstest]
#[case::vec_fruit_store(VecFruitStore::new())]
fn fruit_store(#[case] store: impl FruitStore) {}
Adding another FruitStore
As you have already guessed adding another FruitStore
implementation to this test machine is as easy as adding another case
on the template like #[case::hashmap_fruit_store(HashMapFruitStore::new())]
.
#[template]
#[rstest]
#[case::vec_fruit_store(VecFruitStore::new())]
#[case::hashmap_fruit_store(HashMapFruitStore::new())]
fn fruit_store(#[case] store: impl FruitStore) {}
But another problem pops up:
running 6 tests
test tests::empty_store_counts_zero::case_1_vec_fruit_store ... ok
test tests::empty_store_counts_zero::case_2_hashmap_fruit_store ... FAILED
test tests::empty_store_finds_none::case_1_vec_fruit_store ... ok
test tests::empty_store_finds_none::case_2_hashmap_fruit_store ... FAILED
test tests::empty_store_remove_gives_id_not_found_error::case_1_vec_fruit_store ... ok
test tests::empty_store_remove_gives_id_not_found_error::case_2_hashmap_fruit_store ... FAILED
Imagine if we had 100 test cases, then we had to complete the implementation and get all of them passed in order to get a green build. However, the case
s also work if on top of the tests. Accordingly, we can add the new case
one by one on them until all are covered.
#[apply(fruit_store)]
#[case::hashmap_fruit_store(HashMapFruitStore::new())]
fn empty_store_counts_zero(store: impl FruitStore) {
assert_eq!(0, store.count());
}
Final words
Nothing can grow our confidence for our work like a great test coverage. Testing should not be something totally separate from our everyday workflow or an afterthought. In this article, we went through an example of implementing a trait of a data store with the same tests with help of rstest. I hope you find it useful.
I'm going to leave the code at the current stage so you'd have something to practice on. So please take a copy of this code from here and start adding more test cases and also continue the implementation.
Top comments (0)