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 are probably going to simplify your life right away.
Again in this article, you have 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 me through any of my
social media accounts.
Reorganize Testing Code
There is a concept behind all of the things that I am going to cover in the next few lines: refactoring. Refactoring is changing the code with some purpose while keeping exactly the same functionality. When we want to refactor our production code, we use the 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 formatting your code properly and we still have linters. There are 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 I have to spend some non-trivial amount of time to understand why it ain't passing, only to find out that it was a problem with some typo that 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 of 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 use 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 on 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 name.
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 on 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 the auto-completion feature of my editor to find the one I am looking for and avoid typing the long name or introduce 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 of your code. Should you have made a mistake when typing any of them, you can fix that 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 have created several instances of the supervillain, most of them using 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 is something we could simplify by having the initialization code shared by all the tests before they get 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 sharing the initialization of the type instances. A first approach would be to define a static variable in the module and initialize it lazily with once_cell. 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()
, where we create the instance of the Supervillain
, and
teardown()
, that, 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 things that the test needs.
- Now in the function, it creates that instance of the context using the associated function
setup()
that we had defined above. Then, it executes the closure passing it the instance of the context. 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 the invocation of
teardown()
will not be executed. So we need to have a way to catch the panic when it happens, perform the cleanup, and let the panic finish its process. We can use, std::panic::catch_unwind to execute the closure. This requires that the closure isUnwindSafe
and, 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 of the tests. In the code of the repo, I haven't used it for the test of the From
trait, because it doesn't need a previously existing sut, but one created with its associated function. This is just
one of the test 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 and reduce the boilerplate code. 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 implementation of the TestContext
trait containing 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 of the tests that use it and remove the usage of 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 of
the tests that need 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 run the tests again. They should pass.
Reusing fixtures
The data that we use to create tests may be used in more than one tests
module. It so happens if we define auxiliary
functions for the tests that 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 in 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 usage with the name of the module. 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 of the constants, we can remove them from the tests
module and stay 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 of the code in this 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 some ways of improving the basic tests that we created in the previous one. I have shared three basic techniques to reduce repetition and increase robustness, that 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 Display trait and test it.
- Implement a method that stores first and last name 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 implementation of Context
.