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 scenarios where things don't go as expected, and still have our program 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 held by some non-technical managers 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 you a question 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 focus on a very well-defined use case. And that also means you most likely have not 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 reintroduces 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 so than testing your code to ensure 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 you are handle 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 (unwinding and cleaning up the stack, and exiting). 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, 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 fewer than two words, and the result might not be as desired if it contained 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, which will verify that a panic is generated when 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 missing from 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 includes " - should panic" appended to the test name.

    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 to use the From trait and 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 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 will add the #[ignore] attribute to the test that verifies 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 they aren't executed. Yet its name is printed, it returns a result ("ignored"), and it is 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 handle 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 notice two things:

  • Even though we aren't returning any errors 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 so it can do its job.
  • The test we marked to be ignored is still being compiled. And it fails because we no longer have the associated function Supervillain::from(&str). 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 don't compile, but commenting out tests 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 than 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 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, which allows us to define our own error types, while keeping the amount of boilerplate to a bare minimum. The only valid reason for not using thiserror here would be to reduce the number of external dependencies. Breathe easy.

Our evil software deserves its own errors, and we don't want to put more effort than necessary. For this reason, we will use thiserror to define our error type, which I have decided to call EvilError.

We will start by defining the enum for the different variants.

  pub enum EvilError {}

Inside, we define the variants and their associated data. Initially, we will 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 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 address 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 variant, we define the error message we want displayed. 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 fewer than 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 implementing our custom error type, and we would like to confirm that the correct variant –there might be more in the future– with the appropriate 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 errors 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 defining 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 test function's signature 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 temporarily re-enable some tests, 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 these "never used" warnings, you can put #![allow(unused)] in the first line of supervillain.rs, as I also did.