Contents

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 function Supervillain::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:

  1. The simplest option is to use String as the associated Error 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.
  2. 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.
  3. 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, implement Display, and use the blanket implementation of the Error trait.
  4. 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 using thiserror 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


1

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.