Rust unit testing: simplify your tests
Make your tests more robust and maintainable

Now that we understand the building blocks for writing unit tests in Rust, you probably feel the urge to apply that knowledge and fully cover your application with tests. Don't you? Well, I would ask you to hold your horses and apply it gradually. We still have a lot of ground to cover, but the next sections will probably simplify your life right away.
Again, in this article, you'll find the final version of the supervillain.rs file at the bottom, and the repo with the
incremental commits is available here. In case of doubt, check the code or feel free to reach out to me on any of my
social media accounts.
Reorganize Testing Code
There is a concept behind everything that I am going to cover in the next few lines: refactoring. Refactoring is changing the code with a specific purpose while maintaining exactly the same functionality. When we refactor our production code, we use tests to verify that its functionality hasn't changed: we introduce the changes and check that the tests still pass. In this case, we are going to refactor our tests and, since we don't have tests to verify our tests, we are going to be extra-careful with the changes and assume that if they still pass, they do their job.
Code organization is a very personal matter. If you have been doing this for a while, you know how obvious it is to write your code in a certain way… to you. Other people will prefer to do things differently and rightfully so. But the same problem applies to properly formatting your code, and we still have linters. There is a set of rules that most developers of a language agree upon. In the case of the tests, I would claim that robustness, i.e., lack of fragility, and avoiding repetition are common goals for all of us.
In any case, the quality of the testing code is as important as that of the production code. We have to work with it as much as with the rest of the code, and we want our tests to be readable and maintainable. Keep them clean and tidy.
Constants to the rescue
Let's start with robustness. We want to write tests that we can trust both when they pass and when they fail. If I write a test and have to spend some non-trivial amount of time understanding why it ain't passing, only to find out it was a typo I introduced in a string of the test, that is not very productive.
But we can do better; we can minimize the chances that a typo spoils the tests. If you take a look at the latest version of the code in the last article, you will see that some strings appear several times. You may be the most accurate typist in the world. I simply am not. I don't have ten thumbs, but I do make mistakes more often than I would like to admit.
Could I reduce the number of times I have to type/fix each one of those strings? Yes, I could if I used constants. Then, if I make a typo with the name of my constants, the compiler will let me know right away that the constant names don't match, and I won't spend valuable time trying to find which of the different instances of my string is wrong. This is much better than using different strings with the same value.
Let's apply this to the existing code. We can start by defining constants for the first and last names.
const PRIMARY_FIRST_NAME: &str = "Lex";
const PRIMARY_LAST_NAME: &str = "Luthor";
Then, another one for the full name. Let me remind you what I said in the previous article: favor literals over
expressions (like const fn) to define your constants, because if the test fails, you will have to spend time figuring
out if the wrong value comes from the code that you want to test or from the one that you use to define the constant.
const PRIMARY_FULL_NAME: &str = "Lex Luthor";Regarding the names of these constants, I prefer to start with their role (primary or secondary) and then their purpose (first name, last name, full name …). That way, I can use my editor's auto-completion feature to find the one I am looking for and avoid typing the long name or introducing typos. But if you want to reverse them, it would also be fine as long as you are consistent with your naming. You do you.
Replace the newly defined constants in all the tests. You will have finished this refactoring step only when you run the tests with the changes, and they all pass again.
When you are done, the strings "Lex", "Luthor", and "Lex Luthor" will be defined in just one place in your code. Should you have made a mistake when typing any of them, you can fix that by editing this only instance.
#[test]
fn full_name_returns_first_name_space_last_name() {
let sut = Supervillain {
first_name: PRIMARY_FIRST_NAME.to_string(),
last_name: PRIMARY_LAST_NAME.to_string(),
};
let full_name = sut.full_name();
assert_eq!(full_name, PRIMARY_FULL_NAME);
}
//...
You can and should do the same for the secondary values (SECONDARY_FIRST_NAME, SECONDARY_LAST_NAME, and
SECONDARY_FULL_NAME).
Breathe in. Smell your code. Don't you notice that it is much better now? Good job! 😄
Reuse initialization manually
Throughout the tests, we created several instances of the supervillain, most of which used the exact same code. As Master Yoda used to say: "Repetition leads to anger, anger leads to error, error leads to broken tests" (or something similar.) This kind of repetition could be simplified by sharing the initialization code among all the tests before they run. In other languages that have object oriented capabilities, we would use a type (most often a class or struct) to act as a test suite, its fields or properties to refer to the shared data, and make use of a couple of predefined methods (most often setUp and tearDown) to put the code that needs to be run before and after each test.
However, this is not the way tests are organized in Rust. A test in Rust is a top-level function annotated with the
test attribute. They aren't methods of a struct or share state. And, although you can use a crate like suitest that
adheres to that structure, here I am going to start with plain Rust code.
What we want to achieve is to share the initialization of type instances. A first approach would be to define a static variable in the module and initialize it lazily with OnceCell. But a much simpler option is to use function composition to get our instances passed to each test.
Let's start by defining a struct at the bottom of the tests module to hold the non-Copy instances that are used in
most/all tests. In our code, that is just the sut, i.e., the Supervillain instance.
struct Context {
sut: Supervillain,
}
Add an implementation block that defines set_up(), which creates the instance of the Supervillain, and teardown(),
which, for the moment, we leave empty.
impl Context {
fn setup() -> Context {
Context {
sut: Supervillain {
first_name: PRIMARY_FIRST_NAME.to_string(),
last_name: PRIMARY_LAST_NAME.to_string(),
},
}
}
fn teardown(self) {}
}We need an instance of this passed to each test, however tests don't take any arguments. So how can we do that? Well, we are going to use an auxiliary function to run the tests with this context instance. This function will perform the following tasks:
- It will take the actual test as a parameter. We will pass a closure to the function with the code that we want to run in the test. That closure will accept a single parameter: a mutable reference to the context that contains the resources the test needs.
- Now, in the function, it creates an instance of the context using the associated function
setup()that we had defined above. Then it executes the closure, passing it the context instance. Finally, it executes theteardown()method to clean up, if needed. - That would be enough if the mechanism for triggering a failure within a test wasn't a panic. But if the test fails,
a panic occurs within the closure, and
teardown()will not be invoked. So we need a way to catch the panic when it happens, perform cleanup, and let it finish its process. We can usestd::panic::catch_unwindto execute the closure. This requires that the closure isUnwindSafeand, just in case, we wrap the context in anAssertUnwindSafe.
This is the code for the function:
fn run_test<T>(tst: T)
where
T: FnOnce(&mut Context) -> () + panic::UnwindSafe,
{
let mut ctx = Context::setup();
let mut wrapper = panic::AssertUnwindSafe(&mut ctx);
let result = panic::catch_unwind(move || tst(*wrapper));
ctx.teardown();
if let Err(err) = result {
std::panic::resume_unwind(err);
}
}
We can use that function in each test. In the repo code, I haven't used it for the From trait test because it doesn't
need a previously existing sut; it uses one created with its associated function. This is just one of the tests using
this shared initialization.
#[test]
fn full_name_returns_first_name_space_last_name() {
run_test(|ctx| {
let full_name = ctx.sut.full_name();
assert_eq!(full_name, PRIMARY_FULL_NAME);
});
}Reuse initialization with a crate
This code is so useful that there is a crate that does that, and some more. Its name is test-context, and we can use
it in our code to reduce boilerplate. This change would be particularly significant if we had more than one tests
module.
First, we have to add it as a dependency, but only for the tests. We don't need it for the production code.
cargo add --dev test-context
The crate doesn't define the Context struct because it will be different for every tests module, yet it defines
the TestContext trait that has to be implemented for the struct that will hold the common data. This trait contains
an associated function setup() and method teardown()1. So we replace the default implementation block of
Context with the TestContext trait implementation that contains the same code.
impl TestContext for Context {
The crate allows us to create tests that receive our struct Context as an argument. We have to add this argument to
each test that uses it and remove the auxiliary function.
fn full_name_returns_first_name_space_last_name(ctx: &mut Context) {
let full_name = ctx.sut.full_name();
assert_eq!(full_name, PRIMARY_FULL_NAME);
}
The magic of having tests that receive that ctx argument needs some help to take place. We have to precede each test
that needs this data with the following macro and keep the existing #[test] one.
#[test_context(Context)] // This is new
#[test] // Already existing
Finally, delete the auxiliary function and rerun the tests. They should pass.
Reusing fixtures
The data that we use to create tests may be used in more than one tests module. It also happens when we define
auxiliary functions for tests we may want to reuse. Since we have already stated our intent to avoid repetition, it
would be useful to have a way to share those values. Rust's module system comes to the rescue here.
We are going to create a new file to hold the data and functions that we want to share. Let's call it test_common.rs.
We can copy the constants to this new file and make them public.
We don't want this file to produce any production code. We just want it for the tests, so when we import it into the
main file (main.rs), we use the conditional compilation attribute to get it compiled only for tests.
#[cfg(test)]
mod test_common;
We can now use this module from any of the tests modules by importing it into their namespaces. So far, we have just
created a single tests module, so we add it there.
use crate::test_common;We use the shared constants, qualifying their use with the module name. For example, instead of writing this:
assert_eq!(full_name, PRIMARY_FULL_NAME);we have to write this:
assert_eq!(full_name, test_common::PRIMARY_FULL_NAME);
Once we have done this for each constant, we can remove them from the tests module and stick with the shared ones.
Running the tests again should bring you happiness and calm. 😎
Final code
This is the final version –for this article– of the supervillain file. You can also find the repo with all the
incremental commits for this code series here.
pub struct Supervillain {
pub first_name: String,
pub last_name: String,
}
pub trait Megaweapon {
fn shoot(&self);
}
impl Supervillain {
pub fn full_name(&self) -> String {
format!("{} {}", self.first_name, self.last_name)
}
pub fn set_full_name(&mut self, name: &str) {
let components = name.split(" ").collect::<Vec<_>>();
self.first_name = components[0].to_string();
self.last_name = components[1].to_string();
}
pub fn attack(&self, weapon: &impl Megaweapon) {
weapon.shoot();
}
}
impl From<&str> for Supervillain {
fn from(name: &str) -> Self {
let components = name.split(" ").collect::<Vec<_>>();
Supervillain {
first_name: components[0].to_string(),
last_name: components[1].to_string(),
}
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use test_context::{TestContext, test_context};
use crate::test_common;
use super::*;
#[test_context(Context)]
#[test]
fn full_name_is_first_name_space_last_name(ctx: &mut Context) {
let full_name = ctx.sut.full_name();
assert_eq!(
full_name,
test_common::PRIMARY_FULL_NAME,
"Unexpected full name"
);
}
#[test_context(Context)]
#[test]
fn set_full_name_sets_first_and_last_names(ctx: &mut Context) {
ctx.sut.set_full_name(test_common::SECONDARY_FULL_NAME);
assert_eq!(ctx.sut.first_name, test_common::SECONDARY_FIRST_NAME);
assert_eq!(ctx.sut.last_name, test_common::SECONDARY_LAST_NAME);
}
#[test]
fn from_str_slice_produces_supervillain_full_with_first_and_last_name() {
let sut = Supervillain::from(test_common::SECONDARY_FULL_NAME);
assert_eq!(sut.first_name, test_common::SECONDARY_FIRST_NAME);
assert_eq!(sut.last_name, test_common::SECONDARY_LAST_NAME);
}
#[test_context(Context)]
#[test]
fn attack_shoots_weapon(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon);
assert!(*weapon.is_shot.borrow());
}
struct WeaponDouble {
pub is_shot: RefCell<bool>,
}
impl WeaponDouble {
fn new() -> WeaponDouble {
WeaponDouble {
is_shot: RefCell::new(false),
}
}
}
impl Megaweapon for WeaponDouble {
fn shoot(&self) {
*self.is_shot.borrow_mut() = true;
}
}
struct Context {
sut: Supervillain,
}
impl TestContext for Context {
fn setup() -> Context {
Context {
sut: Supervillain {
first_name: test_common::PRIMARY_FIRST_NAME.to_string(),
last_name: test_common::PRIMARY_LAST_NAME.to_string(),
},
}
}
fn teardown(self) {}
}
}Summary
In this article, I have shown ways to improve the basic tests we created in the previous article. I have shared three basic techniques to reduce repetition and increase robustness, which we will be using again in future tests.
You should feel confident with the basics now, and to prove yourself, you can do some exercises, adding production code and tests to our program. Maybe the dark side will reward you for this.
If you run out of ideas on what to test, here are some suggestions:
- Return test: Implement the Display trait and test it.
- Implement a method that stores first and last names properly capitalized and test it.
- Change the attack method to receive several weapons, but fire only the first one. Test it.
Get ready, because my next article is about testing the not-so-happy path.
Stay curious. Hack your code. See you next time!
Footnotes
Does this sound familiar? Yep, exactly what we had in our Context implementation.