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 becomes useful and solves real use cases, you realize that code quality 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 lets 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 will contain and extend the material 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 on this in future articles, but at the very least, I hope this is a gentle introduction to the concepts behind testing, starting with 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 that is all we need to understand and write good tests. Sharpen your favorite editor –I will be using Emacs here– and your mind. And, if this is 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 about your background in 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 instances of types of our program.
Don't worry if this seems too abstract or not obvious right away. It will be clearer once we start writing code, but you can use this taxonomy later to understand how to approach each test type. 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 project's name accordingly and I hope that you like it.
cargo new 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 setting the instance's state, and check whether the value produced and returned is the one expected.
Writing our first type
We are going to start by creating 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.rsfile 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 isn't much to test yet, so let's add a public string field for the supervillain's first name and reconsider. Should we test that field? No! The act of setting and getting the value of a field 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 supervillain's full name1. 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 will put our tests for the supervillain, and we will need access to the tested
module's public interface from within it. We obtain access to the public methods of the supervillain module by
importing the main code of this file into the tests module's namespace.
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 using snippets to simplify the test creation and have a scaffold to 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 will open a file named rust.json, that contains comments explaining how to create a snippet. First, we will
add a snippet to create the test module, as we did manually.
{ // Already existing 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 add comments to make the test’s structure explicit with a comment for each of 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 existing 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
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 that meets our functional requirements, i.e., the full name is the concatenation of 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 tell about what we are testing and what we
expect. The reason is that when we run the tests, if any 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's test runner to identify this function as a test and run it when told to do so.
Now, let's write what we need for each stage, starting with "arrange". In the arrange section, we set up the test scenario. If you still remember your latest science laboratory, this is 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 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 testYou 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 the 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 message similar to the one below.
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, which 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 a method that changes the state of an instance of the type. We set the instance to a known state, call the method, and check whether the instance's state has changed as expected.
Test state: setter sets first name and last name
We have a method that returns a supervillain's full name from 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 will not work properly unless the provided full name contains two words separated by a single 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 test scaffold with 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. 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 latter, 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 include an arrange stage. And that is fine, because nothing needs to be arranged before testing the desired method.
Behavior Test
In a behavior test, we work with a method or function that interacts with another type instance. We set the instance's state using the method and its arguments. 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 a scenario in which our supervillain interacts with another type. Bad guys are good at attacking and using 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 give them 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
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 test skeleton.
#[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.
Since this weapon is meant only for testing, we will define the type in 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 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 tell 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 with 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, on 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
Megaweapontrait 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 mutable reference 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 by 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 is actually what an assert macro does when the assertion is not true. It signals 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 remove the assertion in this test because is handled automatically when the
weapon test double goes 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 type 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 Drop trait implementation 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 code from scratch and tested its functionality using unit tests. On the way, I have introduced some best practices for writing tests, such as their structure or how to name them. Last but not least, I have highlighted some nuances of Rust and how they affect writing unit tests.
Stay curious. Hack your code. See you next time!
Footnotes
Admittedly, I have been lazy here and assumed that the full name is the result of putting the first name first followed by last/family name, 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 a Spaniard, it doesn't work that way because we use two last names. But this should serve as an example, so please bear with me.
“What's in a name? That which we call a rose by any other name would smell just as sweet.”, William Shakespeare
I have plenty of material about test doubles. Stay tuned!