Contents

Rust unit testing test types

Understand the building blocks for automated testing in Rust

So you have finally learned Rust and are writing cool projects with it? Awesome! However, as soon as your project starts being useful and solving some actual use cases, you realize that the quality of your code is important. Not only that. The larger and more complex your project becomes, the harder it is to manually test every bit of functionality after a change.

Your first temptation might be: "Why should I test everything if my changes only affect this small part of the program?" And, while in some situations that might be true, more often than not our changes affect unexpected parts of our program.

Writing automated tests is one of the most amusing and intellectually rewarding aspects of software development. It helps you break the problem into the right bite-sized parts and let you know when you have properly solved them. Thus, for a long while, I have wanted to write a book on this topic. However, I never find the time to put the content together and I have decided to release a series of articles on Rust automated testing that contain and extend the contents that I have delivered in some of my courses and workshops.

In this first article, I want to cover the basics of unit testing in Rust; the building blocks. I will briefly explain the syntax, but I'd like to go further and get you into the mindset of writing automated tests for the win. I will expand it to some other aspects in future articles, but, at the very least, I hope this is a gentle introduction to the concepts behind testing, starting by what to test and how to test it.

Although I have some material on the tools used for testing, I will start with plain vanilla Rust, because this is all that we need to understand and write good tests. Sharpen your favorite editor –I will be using Emacs here– and your mind and, if this your first experience with automated testing, get ready to change the way you look at your code. There is no way back.

Basic Unit Testing

First things first. I'm not going to make any assumptions on your background on unit testing. Hence, the safest way to start is by describing the "building blocks" for adding tests to your code and then we will write one of each type, step by step.

There are three types of tests that we can write in a language like Rust. Those are:

Return tests
where we test the return of a function or a method.
State test
where we test the state of a type instance after interacting with it.
Behavior test
where we test the interactions between different type instances of our program.

Don't worry if this seems too abstract or not obvious right away. It will be clearer when we start writing the code, but you can use this taxonomy later to understand how to approach each type of test later on. So, let's begin with some actual code.

Project creation

We will start by creating a new project using cargo. We all have learned from movies that working for the bad guys can be very profitable, so we are going to devote our new project to evil plans to rule the world. I have chosen the name of the project accordingly and I hope that you like it.

  cargo new detestable-me
Detestable me

Return Test

In a return test we work with a function or method that returns something. We use that function or method with a set of values for the arguments, most times even set the state of the instance, and check if the value produced and returned is the one that was expected.

Writing our first type

We are going to start by creating of a type, named "Supervillain" in its own Rust module, that will be placed in its own (new) file called supervillain.rs.

  pub struct Supervillain {}

In case of doubt, you have the final version of the supervillain.rs file at the bottom of this article.

But, as you already know, that module won't be included in the compilation of this project unless we include it at the beginning of main.rs:

  mod supervillain;

There is not much to test yet, so let's add a public string property for the first name of the supervillain and reconsider. Should we test that property? No! Setting and getting the value of a property is part of the functionality provided by the language. We haven't added any functionality, so there is nothing to test yet. The same happens when we add a second property for the last name. No test either.

  pub first_name: String,
  pub last_name: String,

Let's add some actual functionality in an implementation block and define a method to return the full name of the supervillain1. That, we can test.

  impl Supervillain {
      pub fn full_name(&self) -> String {
          format!("{} {}", self.first_name, self.last_name)
      }
  }

Define a (sub)module for tests

The first step for having tests is to create a submodule for them and we put it in the same file that contains the Supervillain:

  mod tests {}

But we don't want this to be part of the production code when it gets released and deployed. Rather, we would like the compiler to include this module only for the tests, so we let it know that by using the conditional compilation attribute preceding the module declaration:

  #[cfg(test)]

This newly created submodule is where we are going to put our tests for the supervillain and we will need access from within it to the public interface of the module that we are testing. We obtain access to the public methods of the supervillain module by importing the main code of this file into the namespace of the tests module.

  use super::*;

Ok! The groundwork is done. But before we start writing our tests, let's add some tools to our toolbox.

Snippets for VS Code

If you use VS Code, I recommend you to use snippets to simplify the creation of the tests and have a scaffold that will help you approach their implementation using a predefined structure. VS Code already has built-in snippets for test modules (tmod) and test functions (tfn). I will show you how to create a snippet that you can customize to your taste and needs.

Select Help => Show all commands (or Shift-Command-P in macOS) and choose "Snippets: Configure user snippets" and then "rust":

VS Code configure user snippets

VS Code will open a file named rust.json, containing comments that explain how to create a snippet. First, we will add a snippet for creating the test module, as we just did manually.

  { // Already exising curly brackets
	"Test modules": {
		"prefix": "tmod",
		"body": [
			"#[cfg(test)]",
			"mod tests {",
			"$0",
			"}"
		],
		"description": "Module for tests"
	}
  }

And then one for creating new tests. In this second snippet, we will have comments to make the structure of the test explicit with a comment for each the three parts. Some people like to refer to them by (1) given, (2) when, (3) then, but I feel more comfortable remembering the AAA: (1) arrange, (2) act, (3) assert. Feel free to change it to use the one that resonates better with you.

  { // Already exising curly brackets
	"Test function with comments": {
		"prefix": "tfnwc",
		"body": [
			"#[test]",
			"fn ${1:test_name} {",
			"// Arrange",
			"$0",
			"// Act",
			"// Assert",
			"}"
		],
        	"description": "Template for tests with comments for the different parts"
	}
  }

Testing return: full name is first name space last name

Wine tasting

This is where the rubber meets the road: we are going to write our first test. We will test that the method full_name() returns a string according to our functional requirements, i.e., the full name is the result of concatenating the first name, a space, and the last name.

We use the snippet for the test and provide the test name. Although this might seem irrelevant, it isn't. Don't be fooled by Shakespeare2 and use a meaningful name. We want our name to be telling about what we are testing and what we expect. The reason for this is that when we run the tests, should any of them fail, we want to know what is wrong with the code without having to review what that test was checking. Hence, "test1", "test_supervillain", "test_full_name", or "full_name_is_ok" aren't good enough. In this case, I will stay with full_name_is_first_name_space_last_name.

If you decide to apply this rule, but still curse me for all the time wasted on figuring out good test names and the money spent on additional characters, please remember to also bless me for all the time you save by not having to re-read your tests when they don't pass. 😄

This is the skeleton of our first test:

  #[test]
  fn full_name_is_first_name_space_last_name() {
      // Arrange
      // Act
      // Assert
  }

The #[test] attribute allows Rust test runner to identify this function as a test and run it when told to do so.

Now, let's write what we need in each of the stages, starting with "arrange". In the arrange part, we set up the scenario for testing. If you still remember your latest science laboratory, this where we set the controlled environment, defining the scenario that allows us to execute what we want and to know what to expect.

In this case, we want to create an instance of the supervillain, which we will call sut. You can choose any other name, but there is a long tradition of using that name to refer to the System Under Test. This is all that we need to arrange for this first test.

  let sut = Supervillain {
      first_name: "Lex".to_string(),
      last_name: "Luthor".to_string(),
  };

In the "act" stage, we want to exercise the function/method that we want to test. This is the full_name() method and we store the return value from that function in a(n immutable) variable.

  let full_name = sut.full_name();

Finally, in the assert stage, we declare our expectations. We use the assert_eq! macro to state that the return value should be equal to "Lex Luthor".

  assert_eq!(full_name, "Lex Luthor");

I would like to highlight the importance of using a literal for the expected value rather than computing it. Even though it would be possible to use the following assertion instead of the previous one, it wouldn't be a good idea because, in case it fails, we would have a hard time figuring out if the problem took place in the method being tested or in the expression that we used to obtain the expected value.

  // Don't do this
  assert_eq!(full_name, format!("{} {}", sut.first_name, sut.last_name));

Our first test is ready. Let's try it. We use cargo for that.

  cargo test

You can always turn it up a notch by adding a spoken message with the result.

  cargo test && say "Tests passed" || say "Tests failed"

A test ain't good until you have seen it fail. For the sake of completeness, let's make it fail by removing the space between first and last name in the format! expression of the method and run it again.

  format!("{}{}", self.first_name, self.last_name)

You should get a me

running 1 test
test supervillain::tests::full_name_is_first_name_space_last_name ... FAILED

failures:

---- supervillain::tests::full_name_is_first_name_space_last_name stdout ----

thread 'supervillain::tests::full_name_is_first_name_space_last_name' panicked at src/supervillain.rs:27:9:
assertion `left == right` failed
  left: "LexLuthor"
 right: "Lex Luthor"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    supervillain::tests::full_name_is_first_name_space_last_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--bin detestable-me`

Although the message is pretty clear in this case, we can make it more explicit by adding information to the assertion.

  assert_eq!(full_name, "Lex Luthor", "Unexpected full name");

Running it again produces the following output, that contains the additional information:

running 1 test
test supervillain::tests::full_name_is_first_name_space_last_name ... FAILED

failures:

---- supervillain::tests::full_name_is_first_name_space_last_name stdout ----

thread 'supervillain::tests::full_name_is_first_name_space_last_name' panicked at src/supervillain.rs:28:9:
assertion `left == right` failed: Unexpected full name
  left: "LexLuthor"
 right: "Lex Luthor"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    supervillain::tests::full_name_is_first_name_space_last_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--bin detestable-me`

Please remember to add the space back to the format! expression and make the test pass again.

State Test

In a state test we work with some method that changes the state of an instance of the type. We set the instance to some known state, use the method, and check if the state of the instance has changed as expected.

Test state: setter sets first name and last name

A box with some content

We have a method that returns the full name of supervillain using the information contained in the fields of an instance. We can add one that reverses the process: takes a full name and sets the values of the first and the last names. We are going to start with a not very robust implementation that would not work properly unless the provided full name contains two words separated by one space. It is far from perfect, but it will do for writing the corresponding test.

  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();
  }

Let's write the test step by step again. We create the scaffold for the test using a meaningful name.

  #[test]
  fn set_full_name_sets_first_and_last_names() {
      // Arrange
      // Act
      // Assert
  }

In the arrange stage, we create an instance of Supervillain. The same one we were using for our previous test, only this time we make it mutable so we can change its state.

  let mut sut = Supervillain {
      first_name: "Lex".to_string(),
      last_name: "Luthor".to_string(),
  };

The act part is as simple as invoking the new method with a new supervillain name. "Darth Vader" in this case.

  sut.set_full_name("Darth Vader");

And then, the assert stage must state the expected value for the two fields of the instance. While in general, it is a bad idea to make more than one assertion per test, there are exceptions to that. In this case, since both assertions point to the same part of the code, it is ok to use more than one,

  assert_eq!(sut.first_name, "Darth");
  assert_eq!(sut.last_name, "Vader");

If we run the tests again, we will get the two tests passing. Hooray!

Yo hablo Rust

I can hear you thinking: "but this isn't the way we do things in Rust". While it certainly works, this is ersatz. It isn't very idiomatic. A more common way to implement the same or similar functionality would be to create a constructor (fn new(name: &str) -> Option<Supervillain>) or an implementation of the TryFrom trait, but those need dealing with optionals or error handling and I don't want to go there yet.

  impl TryFrom<&str> for Supervillain {
      type Error = anyhow::Error;

      fn try_from(name: &str) -> Result<Self, Self::Error> {
          let components = name.split(" ").collect::<Vec<_>>();
          if components.len() == 2 {
              Ok(Supervillain {
                  first_name: components[0].to_string(),
                  last_name: components[1].to_string(),
              })
          } else {
              Err(anyhow!("String must contain first and last name."))
          }
      }
  }

A middle ground option is to use an associated function without error handling or an implementation of the From trait. Let's implement the later, although that would be testing return instead of state:

  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 super::*;

      #[test]
      fn from_str_slice_produces_supervillain_full_with_first_and_last_name() {
          // Act
          let sut = Supervillain::from("Darth Vader");
          // Assert
          assert_eq!(sut.first_name, "Darth");
          assert_eq!(sut.last_name, "Vader");
      }
  }

Notice that this new test doesn't have an arrange stage part. There is nothing that needs to be arranged before testing the desired method.

Behavior Test

In a behavior test we work with a method or a function that interacts with another type instance. We set the state of the instance with the method and the arguments for calling it. That includes providing the instance that it will interact with. Then, we use the method or function and check if it has interacted with the latter instance as expected.

The simplest interaction

We need to have an scenario in which our supervillain interacts with another type. Bad guys are good at attacking and interacting with weapons and supervillains do the same with megaweapons.

Let's define what a megaweapon is with a trait. It basically is anything that can be shot, so, it contains a single method, shoot(), that takes no parameters and returns nothing:

  pub trait Megaweapon {
      fn shoot(&self);
  }

As for our supervillain, we add it a new method to provide them with the ability to attack. The method takes a weapon as a parameter, and shoots the weapon:

  pub fn attack(&self, weapon: impl Megaweapon) {
      weapon.shoot();
  }

Test behavior: Supervillain attacks using weapon

Mouse trap

What we want to test in this scenario is that when a supervillain attacks, they shoot the megaweapon. As in the previous examples, let's start with the skeleton of the test.

  #[test]
  fn attack_shoots_weapon() {
      // Arrange
      // Act
      // Assert
  }

The arrange part is quite straightforward: we create an instance of our system under test. The same one we have used before.

  let sut = Supervillain {
      first_name: "Lex".to_string(),
      last_name: "Luthor".to_string(),
  };

The act part isn't that obvious. Yes, we want to use the attack() method of the sut, but that method requires something that implements the Megaweapon trait and we haven't defined any type that does that, yet. Does this mean that we cannot write this test? No way! On the contrary, we can create our own megaweapon and use it to our advantage. Don't worry. We don't need to become a lord of war for that.

A lord of war at work

Since this weapon is meant only for testing purposes, we are going to define the type inside of the tests module and call it WeaponDouble3. The only important thing is that it must implement the Megaweapon trait. We will take care of the details later.

  struct WeaponDouble {}
  impl Megaweapon for WeaponDouble {
      fn shoot(&self) {
          todo!()
      }
  }

With this new "weapon" available, we can take a step forward and create an instance in the arrange part and use it in the act part for attacking.

  let weapon = WeaponDouble {};
  // Act
  sut.attack(weapon);

We are almost there. We are only missing the assertion. But, what should we assert? Anything about the sut? Clearly not! Let's take another look at the name of our test: attack_shoots_weapon. It says it all. When the sut attacks, we expect the weapon to be shot. So the assertion should be that the weapon is shot, but how do we do that?

Forget about the code for a moment. Imagine that you have your terrific supervillain in front of you and you want to ensure that when he attacks, he shoots the megaweapon. You don't want the real thing to happen. You don't want the supervillain to shoot a real megaweapon. Even less so when you are around. So you hand him a fake gun. One that registers when the trigger is pushed, but does no harm. That should be enough to know if our supervillain does what he is expected to do.

Back to code then. We add a field to the WeaponDouble struct to keep track of when it is shot (is_shot). For convenience, we add an associated function that creates a WeaponDouble setting its initial value to false. And in the implementation of the shoot() method, we change it to true.

  struct WeaponDouble {
      pub is_shot: bool,
  }
  impl WeaponDouble {
      fn new() -> WeaponDouble {
          WeaponDouble { is_shot: false }
      }
  }
  impl Megaweapon for WeaponDouble {
      fn shoot(&self) {
          self.is_shot = true;
      }
  }

We can replace the creation of the weapon to use the associated function.

  let mut weapon = WeaponDouble::new();

And finally assert that shoot() has been called.

  assert!(weapon.is_shot);

And… that would be it if we were using most other programming languages.

But Rust doesn't like this. We compile and run the tests with cargo and we get this output:

warning: variable does not need to be mutable
  --> src/supervillain.rs:83:13
   |
83 |         let mut weapon = WeaponDouble::new();
   |             ----^^^^^^
   |             |
   |             help: remove this `mut`
   |
   = note: `#[warn(unused_mut)]` on by default

error[E0382]: use of moved value: `weapon`
  --> src/supervillain.rs:87:17
   |
83 |         let mut weapon = WeaponDouble::new();
   |             ---------- move occurs because `weapon` has type `WeaponDouble`, which does not implement the `Copy` trait
84 |         // Act
85 |         sut.attack(weapon);
   |                    ------ value moved here
86 |         // Assert
87 |         assert!(weapon.is_shot);
   |                 ^^^^^^^^^^^^^^ value used here after move
   |
note: consider changing this parameter type in method `attack` to borrow instead if owning the value isn't necessary
  --> src/supervillain.rs:21:34
   |
21 |     pub fn attack(&self, weapon: impl Megaweapon) {
   |            ------ in this method ^^^^^^^^^^^^^^^ this parameter takes ownership of the value
note: if `WeaponDouble` implemented `Clone`, you could clone the value
  --> src/supervillain.rs:90:5
   |
85 |         sut.attack(weapon);
   |                    ------ you could clone this value
...
90 |     struct WeaponDouble {
   |     ^^^^^^^^^^^^^^^^^^^ consider implementing `Clone` for this type

error[E0594]: cannot assign to `self.is_shot`, which is behind a `&` reference
   --> src/supervillain.rs:100:13
    |
100 |             self.is_shot = true;
    |             ^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be written
    |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
    |
7   ~     fn shoot(&mut self);
8   | }
...
98  |     impl Megaweapon for WeaponDouble {
99  ~         fn shoot(&mut self) {
    |

Some errors have detailed explanations: E0382, E0594.
For more information about an error, try `rustc --explain E0382`.
warning: `detestable-me` (bin "detestable-me" test) generated 1 warning
error: could not compile `detestable-me` (bin "detestable-me" test) due to 2 previous errors; 1 warning emitted

And while in most cases, the advice from the Rust compiler is between a useful magic crystal ball and a mind reader that knows your real intentions, in this occasion the suggestion to make the reference to self mutable for fn shoot(&mut self); is not something we want to do. You don't want to make parameters mutable for the sake of testing. That is way beyond designing your public interface to be testable. So much for our attempt to test behavior. Should we call it a day?

Once more with feeling

Our previous attempt presents two problems that wouldn't be an issue with many other programming languages:

  • On the one hand, the Megaweapon trait method takes a shared reference to self, i.e., the test double. Its state cannot be changed.
  • On the other hand, functions/methods that consume the argument are hard to test, because the test double cannot be queried after being moved to the function/method.

The first problem can be solved using interior mutability, i.e., references with rules checked at run-time. We can use a RefCell and borrow a reference with mutable access to it in the shoot() method.

  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;
      }
  }

After doing this, the reference to the weapon test double that we create doesn't have to be mutable.

  let weapon = WeaponDouble::new();

The second one can be solved implementing the Drop trait that defines what to do before the instance of our test double is released. There we will check if it has been shot or panic otherwise. Panicking might seem a little extreme, but this actually what an assert macro does when the assertion is not true. It is a way to signal the test runner that this test didn't pass.

  impl Drop for WeaponDouble {
      fn drop(&mut self) {
          if *self.is_shot.borrow() != true {
              panic!("Failed to call shoot()");
          }
      }
  }

By implementing the Drop trait, we can get rid of the assertion in this test, because it happens automatically when the weapon test double gets out of scope. Use cargo test and all the tests should pass.

Still, if you own the design of the Supervillain interface and passing a reference instead of ownership is a valid option for your problem at hand, it is always easier to make the API of the object testable. We can make attack() take a shared reference to something that implements Megaweapon.

  pub fn attack(&self, weapon: &impl Megaweapon) {
      weapon.shoot();
  }

In this way, we can remove the implementation of the Drop trait and use a regular assertion.

  let weapon = WeaponDouble::new();
  sut.attack(&weapon);
  assert!(*weapon.is_shot.borrow());

Final code

This is the final version –for this article– of the supervillain file. You can also find the repo with all 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 super::*;

    #[test]
    fn full_name_is_first_name_space_last_name() {
        // Arrange
        let sut = Supervillain {
            first_name: "Lex".to_string(),
            last_name: "Luthor".to_string(),
        };
        // Act
        let full_name = sut.full_name();
        // Assert
        assert_eq!(full_name, "Lex Luthor", "Unexpected full name");
    }

    #[test]
    fn set_full_name_sets_first_and_last_names() {
        // Arrange
        let mut sut = Supervillain {
            first_name: "Lex".to_string(),
            last_name: "Luthor".to_string(),
        };
        // Act
        sut.set_full_name("Darth Vader");
        // Assert
        assert_eq!(sut.first_name, "Darth");
        assert_eq!(sut.last_name, "Vader");
    }

    #[test]
    fn from_str_slice_produces_supervillain_full_with_first_and_last_name() {
        // Act
        let sut = Supervillain::from("Darth Vader");
        // Assert
        assert_eq!(sut.first_name, "Darth");
        assert_eq!(sut.last_name, "Vader");
    }

    #[test]
    fn attack_shoots_weapon() {
        // Arrange
        let sut = Supervillain {
            first_name: "Lex".to_string(),
            last_name: "Luthor".to_string(),
        };
        let weapon = WeaponDouble::new();
        // Act
        sut.attack(&weapon);
        // Assert
        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;
        }
    }
    impl Drop for WeaponDouble {
        fn drop(&mut self) {
            if *self.is_shot.borrow() != true {
                panic!("Failed to call shoot()");
            }
        }
    }
}

Summary

In this article, I have explained the three types of tests that we can use with Rust and many other programming languages. I have created a piece of code from scratch and tested its functionality using unit test. On the way, I have introducing some best practices for writing tests, like its structure or how to name them. Last but not least, I have highlighted some nuances of Rust and how they affect to writing unit tests.

Stay curious. Hack your code. See you next time!

Footnotes


1

Admittedly, I have been lazy here and assumed that the full name is the result of putting the first name first and the last/family name later separated by a space. Not all cultures do it like that. At the very least, I know some Asian cultures do it the other way around and I was taught that Hungarians do that too. Even in my own culture as an Spaniard, it doesn't work that way because we use two last names. But this should do as an example, so please bear with me.

2

“What's in a name? That which we call a rose by any other name would smell just as sweet.”, William Shakespeare

3

I have a plenty of material about test doubles. Stay tuned!