Rust unit testing: the not so happy path
Testing panics and errors

So far, we have chosen to test things that matched our expectations of how the code was supposed to work and how the user and the environment were supposed to behave. But, we should also plan for the scenarios in which things don't go as expected, and still we want our program to react properly. We should also test for these situations and those tests are as important as the ones that check the successful use cases.
Now that I am on the topic, I would like to argue against a misconception that some non-technical managers have about tests. If you are on the technical side of software development and you have one of those managers, you will be able to identify them when they ask a question to you that is usually very similar to: "If you have (fully) tested the code, how come we still have bugs?" 🤯 Tests aren't omniscient! On the contrary, they tend to be very narrow in scope and they focus on a very well defined use case. And that also means that, most likely, you have not have written a test for each possible use case. The good news is that if you detect anything that wasn't covered by your tests and that use case isn't working properly, i.e., your code is buggy, you can write a test to verify that use case, fix the code and avoid any future regressions. And by regression, I mean any change to the codebase that re-introduces an error that had been previously fixed. Do you feel better now? I do.
Testing the not so happy path is as important, if not more as testing your code to know that it implements the desired features. In this case, you are testing the edge cases. What happens when things deviate from the desired scenario. And while it is hard to guess and anticipate everything a hideous user could do, you can at the very least start by ensuring that you are handling the errors properly.
Expecting Panics 😱
A panic in Rust is the only exit from a situation when there is no way to recover. It prints a message and performs
housekeeping tasks (unwind and clean up the stack, and exit.) However, sometimes, we exercise our poetic license and
panic!
in other situations. Most of them –let's be honest– out of laziness. And, yes, I am referring to those
shameful unwrap
that we do now and then. Whatever the case might be, we still want to test when we expect a panic to
be triggered.
Remember when I told you that our initial implementation of Supervillain.set_full_name()
wasn't very robust? I meant
that things would go wrong at run-time if the provided name argument contained less than two words, and the result might
not be as desired if we had more. Let's implement what to do when set_full_name()
receives an argument that doesn't
contain two words. We will panic when there aren't two components in the provided string.
if components.len() != 2 {
panic!("Name must have first and last name");
}
After this long and tiring change to our implementation 😅, we can work on the tests. And we can start by creating a
new test for that use case, in which we will test that a panic is generated if an empty name is used with that method.
This new test will use the context that I introduced in my previous article and invoke the method with an empty &str
.
#[test_context(Context)]
#[test]
fn set_full_name_panics_with_empty_name(ctx: &mut Context) {
ctx.sut.set_full_name("");
}
The only thing that is missing in this test is the assertion. But, how do we assert that we expect to receive a panic?
This is indeed challenging, if you consider that the assert!
macro itself invokes panic!
to signal that the assertion
failed and the test didn't pass. Fortunately, there is a test attribute that we can use to express that a panic is expected:
#[should_panic]
If we add it to the test and run cargo t
now, all the tests should pass. Easy peasy. Also, notice that the output for
that test has " - should panic" appended to the name of the test.
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s Running unittests src/main.rs (target/debug/deps/detestable_me-acb40a67b9bc1bd9) running 5 tests test supervillain::tests::attack_shoots_weapon ... ok test supervillain::tests::from_str_slice_produces_supervillain_full_with_first_and_last_name ... ok test supervillain::tests::full_name_is_first_name_space_last_name ... ok test supervillain::tests::set_full_name_sets_first_and_last_names ... ok test supervillain::tests::set_full_name_panics_with_empty_name - should panic ... ok test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
But we can be even more specific about the panic message that we expect. We simply modify the attribute to include the message.
#[should_panic(expected = "Name must have first and last name")]
Run the tests again, and they should pass. Sweet!
Testing for Errors
Ignoring tests (on purpose) 😅
Fortunately, Rust developers have less disruptive ways to deal with the undesired or unexpected. And I really like the
way this is implemented in Rust. Functions can return Result<T,E>
, which is an enum with just two variants. One for
when it comes up with the goods (Ok(T)
) and another for the moments when you might need a shoulder cry on (Err(E)
).
The snobby programmer in me wants to point out that this is known as an algebraic data type, but I won't.
Let's change our implementation, using the From
trait, to allow it to fail. From
doesn't allow conversions to fail,
but TryFrom
does, so we will use it, instead of From
, in our Supervillain
.
And while we are at it, we are going to experiment with the ability to skip tests. That is something that you may want to do when you know that a test –well, maybe some– is broken and you want to run the rest without the background noise. Preferably, this would happen in the process of refactoring some code, rather than due to having a test that you can't get to work.
In this case, we are going to add the #[ignore]
attribute to the test that checks the implementation of the From
trait.
#[test]
#[ignore]
fn from_str_slice_produces_supervillain_full_with_first_and_last_name() {
If we save and run the tests, we will notice that it is not executed. Yet, its name is printed, it comes with a result ("ignored"), and it gets accounted for.
running 5 tests test supervillain::tests::from_str_slice_produces_supervillain_full_with_first_and_last_name ... ignored test supervillain::tests::attack_shoots_weapon ... ok test supervillain::tests::full_name_is_first_name_space_last_name ... ok test supervillain::tests::set_full_name_sets_first_and_last_names ... ok test supervillain::tests::set_full_name_panics_with_empty_name - should panic ... ok test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
Remember that you can re-enable all the ignored tests from the command line using cargo t -- --include-ignored
. You
will be ignoring the #[ignore]
, not changing the file, though.
Let's change the implementation of the From
trait to TryFrom
.
impl TryFrom<&str> for Supervillain {
fn try_from(name: &str) -> Result<Self, Self::Error> {
And we take care of the successful case by wrapping the Supervillain
in the Ok
variant.
Ok(Supervillain {
first_name: components[0].to_string(),
last_name: components[1].to_string(),
})
If we try to run the tests, we may take notice of two things:
- Even though, we aren't returning any error yet, the implementation of the
TryFrom
trait doesn't compile because we haven't specified the associated type that we want to use for its errors. We must provide that information to the compiler to let it do its job. - The test that we marked to be ignored, is still compiled. And it fails, because we don't have an associated function
Supervillain::from(&str)
anymore. Interestingly enough, if the code is just syntactically correct,cargo build
will compile. When I say "syntactically correct", I do it in opposition to semantically correct, i.e. the code "looks correct" (e.g., no missing trailing semicolons), but the associated functionSupervillain::from(&str)
isn't defined. The takeaway here is that#[ignore]
won't help if any of your tests doesn't compile, but commenting tests out or just building the main source while you get the tests again under control, can still do wonders.
Returning useful errors
We wanted to return an error and this is what we have to do now. But, which kind of error should we return? Well, we have at least, four options:
- The simplest option is to use
String
as the associatedError
type. This is quite straightforward, but then we will have a harder time when we want to differentiate among distinct error types or use?
for converting errors. This could be a temporary solution, but not one we want to take for building high-quality software. - The next option is using
anyhow
,eyre
, or a similar crate. It is a better option that using strings. Out of the box, you get a single error type that can be used anywhere, a macro to produce those errors, automatic conversion from existing errors, and additional information. They are great options when you want to manage the errors at the application level, but not so useful if you want the different errors produced by your (library) code to be handled in different ways. - A third and much more sophisticated option is to define our own error types. This allows for a more granular and
flexible error handling. However, this is much more tedious than the previous options. We have to define an enum
with all the variants for the different error types, describe the associated data for each variant, implement or
derive
Debug
, implementDisplay
, and use the blanket implementation of theError
trait. - Finally, we could use the
thiserror
crate that allows us to define our own error types, while keeping the amount of boilerplate required to the bare minimum. The only valid reason for not usingthiserror
here would be to reduce the number of external dependencies. Breath easy.
Our evil software deserves its own errors and we don't want to put more effort than necessary. For this reason, we are
going to use thiserror
to define our error type, which I have decided to call EvilError
.
We are going to start by defining the enum for the different variants.
pub enum EvilError {}
Inside, we define the variants and its associated data. Initially, we are going to use just one: ParseError
. And it
will contain the purpose of this parsing and a reason. We can add more variants later, when we need them.
ParseError { purpose: String, reason: String },
Despite of the name, this is not a valid error yet. It doesn't implement the Error
trait and its requirements. We
are going to add thiserror
to take care of that.
cargo add thiserror
Back in the code, we import thiserror
.
use thiserror::Error;
And we can now derive Error
(and Debug
) for our type.
#[derive(Error, Debug)]
pub enum EvilError {
It is important that for each of the variants, we define the error message that we want to be shown. We do that by
adding the #[error(...)]
attribute to every variant. For our single variant, this will be:
#[error("Parse error: purpose='{}', reason='{}'", .purpose, .reason)]
And that is all that's needed to define our custom error.
We tell the compiler that the associated type for the Error in the implementation of TryFrom
is our new error.
type Error = EvilError;
And we can now use it to return an error from our Supervillain::try_from(name: &str)
associated function, when we
receive a name with less that two words.
if components.len() < 2 {
Err(EvilError::ParseError {
purpose: "full_name".to_string(),
reason: "Too few arguments".to_string(),
})
} else {
// Existing Ok goes here
}
cargo b
should work fine with this code.1
Testing the custom errors
Let's go with the existing test now. We have to change it to use TryFrom
instead of From
. We update its name
accordingly and use let-else pattern matching to check that the operation was successful, aborting the test with a
panic!
otherwise.
#[test]
fn try_from_str_slice_produces_supervillain_full_with_first_and_last_name() {
let result = Supervillain::try_from(test_common::SECONDARY_FULL_NAME);
let Ok(sut) = result else { panic!("Unexpected error returned by try_from"); };
assert_eq!(sut.first_name, test_common::SECONDARY_FIRST_NAME);
assert_eq!(sut.last_name, test_common::SECONDARY_LAST_NAME);
}
All this code and we haven't tested the not-so-happy path yet! Let's write another test that checks if the expected
error is received when TryFrom
is invoked with an empty string. It is similar, but easier than the previous one.
#[test]
fn try_from_str_slice_produces_error_with_less_than_two_substrings() {
let result = Supervillain::try_from("");
let Err(_) = result else { panic!("Unexpected value returned by try_from"); };
}
Running the tests with cargo will show six shining tests passing. But we would like to go a step beyond. We have put some, admittedly not much, effort into having our custom error type and we would like to check that the right variant –there might be more in the future– with the right associated data is returned.
We only have to make a small change to the current test and capture the error in the less else pattern match.
let Err(error) = result else { panic!("Unexpected value returned by try_from"); };
Then we can use this value to compare it to our expectations, including the associated data. We can use the matches!
macro for that purpose. I have been waiting forever for the assert_matches!
macro to be available in the stable Rust
version, but at the moment it is a nightly-only feature. Bummer!
assert!(
matches!(error, EvilError::ParseError { purpose, reason } if purpose =="full_name" && reason == "Too few arguments")
)
We run this new version with cargo t
and we will have tests that include checking the error that are passing.
Bubbling up Errors
We can simplify some of the tests, so let's do it.
The try_from
associated function returns a Result
and we know that Result::expect(&self)
panics if it is an Err
.
Then, in the test try_from_str_slice_produces_supervillain_full_with_first_and_last_name
, we can use expect()
and assign
the return of Supervillain::try_from(&str)
directly to the sut
variable. We can get rid of the let-else statement,
which should be removed.
let sut = Supervillain::try_from(test_common::SECONDARY_FULL_NAME)
.expect("Unexpected error returned by try_from");
If we are testing for the successful case of functions that return results, we can bubble errors up. That is available since the 2018 edition and it just requires to define a return type in the test function with the expected error type, or one that it can be converted to.
In that same test, replace expect()
with the ?
operator.
let sut = Supervillain::try_from(test_common::SECONDARY_FULL_NAME)?;
If a function returns an Err
, the ?
operator will bubble up the error and the test will fail, so this is useful when
we are testing the happy path. In that case, we don't need a panic/assert to fail the test; returning an error will
suffice, but the signature of the test function must return a Result
to allow using the ?
operator.
fn try_from_str_slice_produces_supervillain_full_with_first_and_last_name() -> Result<(), EvilError>
This change forces us to return Ok(())
at the bottom to fulfill the commitment expressed in the signature. That's
it. Run cargo t
and they will all pass again. Yippie ki-yay, Rust developer!
Final code
We have made some changes to the supervillain.rs
file, and, it should look like this after all those changes. You can
also find the repo with all the code in this series here with several commits for this article.
#![allow(unused)]
use thiserror::Error;
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<_>>();
if components.len() != 2 {
panic!("Name must have first and last name");
}
self.first_name = components[0].to_string();
self.last_name = components[1].to_string();
}
pub fn attack(&self, weapon: &impl Megaweapon) {
weapon.shoot();
}
}
impl TryFrom<&str> for Supervillain {
type Error = EvilError;
fn try_from(name: &str) -> Result<Self, Self::Error> {
let components = name.split(" ").collect::<Vec<_>>();
if components.len() < 2 {
Err(EvilError::ParseError {
purpose: "full_name".to_string(),
reason: "Too few arguments".to_string(),
})
} else {
Ok(Supervillain {
first_name: components[0].to_string(),
last_name: components[1].to_string(),
})
}
}
}
#[derive(Error, Debug)]
pub enum EvilError {
#[error("Parse error: purpose='{}', reason='{}'", .purpose, .reason)]
ParseError { purpose: String, reason: 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_context(Context)]
#[test]
#[should_panic(expected = "Name must have first and last name")]
fn set_full_name_panics_with_empty_name(ctx: &mut Context) {
ctx.sut.set_full_name("");
}
#[test]
fn try_from_str_slice_produces_supervillain_full_with_first_and_last_name()
-> Result<(), EvilError> {
let sut = Supervillain::try_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);
Ok(())
}
#[test]
fn try_from_str_slice_produces_error_with_less_than_two_substrings() {
let result = Supervillain::try_from("");
let Err(error) = result else {
panic!("Unexpected value returned by try_from");
};
assert!(
matches!(error, EvilError::ParseError { purpose, reason } if purpose =="full_name" && reason == "Too few arguments")
)
}
#[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 how to go beyond the happy path and test the edge cases of your Rust code. I have covered how to write tests both when you expect a panic and when you are willing to get errors. You can now write tests that check for better and for worse.
I have also covered how to ignore and temporary re-enable some test, explaining what that does and doesn't do. This is a useful tool for your tool belt.
Stay curious. Hack your code. See you next time!
Footnotes
BTW, if you get tired of this "never used" warnings, you can put #![allow(unused)]
in the first line of
supervillain.rs
, as I also did.