Rust unit testing: mock test doubles
The quest of the mockingcrab

This is the third article on test doubles. So far, I have covered stubs –that are used to control the responses to the methods of the type they double,– dummies –that fill "holes" that are required for some tests without any functionality,– and spies –that remember the interactions with the type they double. It is time to talk about the face of the band: mocks.
For the price of a mock, you get everything that was included in a stub and a spy, plus some intelligence. And I am not talking about AI. Mocks are a little smarter than stubs and spies, because they include the code to verify themselves. All those ugly asserts that used the fields in the guts of the spies will be no longer necessary.
Scenario for Mocking
Our supervillains have started their world domination by building their headquarters. It is time to move on and take the next step: world domination stage 2.
Stage 2 is a little bit more aggressive. The supervillains tell their henchmen to do hard things and fight enemies.
We start by making the supervillain declare start_world_domination_stage2(). In that method, the supervillain takes
ownership of the henchman, because it is well known that stage2 consumes them.
pub fn start_world_domination_stage2<H: Henchman>(&self, henchman: H) {
}
We also prepare our henchmen to take orders by adding two new methods to the Henchman trait, in henchman.rs.
fn do_hard_things(&self);
fn fight_enemies(&self);Now, the supervillains can use their henchman to do both things in stage 2.
henchman.do_hard_things();
henchman.fight_enemies();
A cargo b --lib should work flawlessly at this point.
Test Mocks
Violating the Interface Segregation Principle (ISP) has a cost. The Henchman abstraction includes the method for the
spy and the new methods for the mock. Any implementation of the trait will have to include the three methods, although
their purpose is quite different. We could have split the abstraction into two (HenchmanStage1 and HenchmanStage2)
which could be implemented separately. But for the sake of simplicity, we are going to keep them together. Those
methods can be implemented using void implementations in HenchmanSpy or default implementations in the trait. We will
go with the former option.
fn do_hard_things(&self) {}
fn fight_enemies(&self) {}
Another thing we should do is to re-purpose the henchman double to be used both as a spy and as a mock, by renaming it
to HenchmanDouble.
Our henchman double was able to retain the location provided to build the headquarters. Let's add two fields to keep
track of the invocation of the new methods and we use Cell<T> to implement interior mutability. As a –simplified–
reminder, we should use Cell<T> when T implements Copy and RefCell<T> when it doesn't.
I opted for using RefCell<T> in the first article, which wasn't the best option. I thought that there were too many
concepts to keep in mind and RefCell<T> is applicable –although not always the best choice– for most cases. Let's
fix that first in WeaponDouble.
struct WeaponDouble {
pub is_shot: Cell<bool>,
}
impl WeaponDouble {
fn new() -> WeaponDouble {
WeaponDouble {
is_shot: Cell::new(false),
}
}
}
impl Megaweapon for WeaponDouble {
fn shoot(&self) {
self.is_shot.set(true);
}
}We have to change the test accordingly.
#[test_context(Context)]
#[test]
fn attack_shoots_weapon(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon);
assert!(weapon.is_shot.get());
}
Run the tests with cargo t --lib to check that our change hasn't spoiled our tests.
We should apply the same pattern to the HenchmanDouble.
#[derive(Default)]
struct HenchmanDouble {
hq_location: Option<String>,
done_hard_things: Cell<bool>,
fought_enemies: Cell<bool>,
}
We then use those fields in the implementations of the new methods of the Henchman trait. We are also going to
derive Default for the type, as I suggested in the last article
impl Henchman for HenchmanDouble {
fn build_secret_hq(&mut self, location: String) {
self.hq_location = Some(location);
}
fn do_hard_things(&self) {
self.done_hard_things.set(true);
}
fn fight_enemies(&self) {
self.fought_enemies.set(true);
}
}
So far, there is no difference between what we did with the spy and what we are doing with the mock. So what is the
difference then? It is time to use the secret sauce to produce a mock: the verify() method.
Spies had memory to recall their interactions during the tests. However, assertions are typically implemented directly within the test itself and rely on the those fields to check what was expected. We could instead define a new method for the verification and encapsulate all the logic there.
In this testing scenario, our verification will ensure that both methods have been called. To achieve this, we create a
new verify() method that performs the necessary checks. This method returns nothing as its only purpose is to
validate our expectations.
impl HenchmanDouble {
fn verify(&self) {
assert!(self.done_hard_things.get() && self.fought_enemies.get());
}
}
And since we are in the process of doing some home improvement, we can simplify the initialization of HenchmanDouble
by using Default that we introduced above.
let mut hm_spy = HenchmanDouble::default();Now that we have all the parts ready, let's write the new test.
#[test_context(Context)]
#[test]
fn world_domination_stage2_tells_henchman_to_do_hard_things_and_fight_with_enemies(ctx: &mut Context) {
}
Inside, we create a new instance of the henchman and use it to invoke the method we are interested in:
start_world_domination_stage2().
let henchman = HenchmanDouble::default();
ctx.sut.start_world_domination_stage2(henchman);
Finally, we invoke the verify() method of the double in the "assert" part of the test.
henchman.verify();And that should be it… if only the compiler liked what we wrote. But it obviously doesn't.
The compiler complains that the Supervillain has taken ownership of the Henchman and, thus, we cannot use it later
to invoke the verify method. It doesn't belong to us anymore. Cloning, as suggested by the compiler, is not an option,
because then we will have two different objects –the original and the clone– and the assertion will be done on the one
that hasn't being used by the Supervillain. Is Rust making this test impossible? Well, not really. But we are
required to find a different way to do this, the Rust way.
The Rust way is what we did with the double of the megaweapon. We leveraged the Drop trait to ensure that the
assertion was executed as the last step before releasing the double at the end of its scope. The implementation is
straightforward, and requires only calling the verify() method.
impl Drop for HenchmanDouble {
fn drop(&mut self) {
self.verify();
}
}
Before running the tests, remove the invocation to verify from within the test, because the implicit Drop will take
care of this. This should make the current test pass, but it has also affected the other test that uses the henchman
double 😲. That ain't exactly what I wanted. We should be able to run only the desired assertions for each instance of
the test double1. So let's make the assertions part of the instance configuration using a vector of closures that
contain them.
assertions: Vec<Box<dyn Fn(&HenchmanDouble) -> () + Send>>,And use them when the instance is dropped.
impl Drop for HenchmanDouble {
fn drop(&mut self) {
for a in &self.assertions {
a(self);
}
}
}
We also make the instance of the henchman double mutable, change the name of the method from verify to
verify_two_things_done, and include it in the assertions vector for our test2.
henchman.assertions = vec![Box::new(move |h| h.verify_two_things_done())];We end up our morning exercises by compiling and running the tests, and then do savasana. I sincerely hope that you aren't surprised that they all pass. Congratulations!
Knowledgeable verifications
By now you may be wondering: "What's the benefit of embedding verifications within test doubles?" Well, as in many other situations it is a trade-off.
On the one hand, having your verification defined alongside the test double can lead to more cohesive code, as the test double has intimate knowledge of the business logic it's replacing. This can result in tighter integration between the test and the system being tested.
On the other hand, your verification method is either less flexible, because it is hardcoded to specific conditions, or requires a more complex implementation, like the vector of closures that we used above.
Since we have already done the hard part of having a vector with assertions, let's cover an example of how to incorporate that business logic into our tests doubles. It is pretty clear to a henchman –and to us too– that they should take care of fighting the enemies first and do hard things later. If their enemies start the fight while they are doing hard things, they might not have the opportunity to succeed.
However, in our current version of the test, we are just checking that both methods are invoked without paying any
attention to the order. But, if order matters, we should modify the test double fields to be able to track invocation
order. The fields to remember invocations of each method will now become u32, where 0 means that they haven't been
invoked yet, 1 means that they were invoked first3, etc. This allows us to keep track of both the existence and
sequence of invocations.
#[derive(Default)]
struct HenchmanDouble {
hq_location: Option<String>,
current_invocation: Cell<u32>,
done_hard_things: Cell<u32>,
fought_enemies: Cell<u32>,
assertions: Vec<Box<dyn Fn(&HenchmanDouble) -> () + Send>>,
}
Alternatively, if we had to remember every invocation and their sequence, we could use vectors of u32. Similarly, if
we require recording multiple arguments for each invocation, a vector of tuples would be the better choice.
We use current_invocation to keep track of the latest one and increment it accordingly.
impl Henchman for HenchmanDouble {
// ...
fn do_hard_things(&self) {
self.current_invocation
.set(self.current_invocation.get() + 1);
self.done_hard_things.set(self.current_invocation.get());
}
fn fight_enemies(&self) {
self.current_invocation
.set(self.current_invocation.get() + 1);
self.fought_enemies.set(self.current_invocation.get());
}
}Our verification can now take the sequence of invocations into account.
impl HenchmanDouble {
fn verify_two_things_done(&self) {
assert!(self.done_hard_things.get() == 2 && self.fought_enemies.get() == 1);
}
}
We run the tests with cargo t --lib and realize that we are doing stage 2 wrong. We change the order, run the tests
again, and everything should be fine again.
In this implementation, the verification is part of the henchman test double, which is where the business logic of the task order belongs. Not only that. If I have to do the same verification for several tests, we won't have to write this logic again.
Flexible verification
This was better, but there are situations in which we want to adjust the verification without having to rewrite it from scratch. Let's explore another way to do this.
Do you recall the attack method that we tested at the beginning of these series? Well, I'd like to share that our
supervillain has been training Tai-Chi for a few weeks, inspired by some YouTube ad and is now looking to incorporate
different intensity levels into their attacks. The attack() method will now accept a boolean parameter to determine
whether the attack should be intense or not. In reality, however, our supervillain is more likely to simply shoot
repeatedly if the attack is intense –no fancy Tai-Chi moves here.
pub fn attack(&self, weapon: &impl Megaweapon, intense: bool) {
weapon.shoot();
if intense {
let mut rng = rand::rng();
let times = rng.random_range(1..3);
for _ in 0..times {
weapon.shoot();
}
}
}
In order to test this new version, let's first modify the WeaponDouble to be a mock. We could invoke verify() in
the implementation of Drop, but it isn't required in this case, because the parameter used in the attack() method is
a shared reference.
struct WeaponDouble {
pub times_shot: Cell<u32>,
}
impl WeaponDouble {
fn new() -> WeaponDouble {
WeaponDouble {
times_shot: Cell::default(),
}
}
fn verify(&self) {
assert!(self.times_shot.get() == 1);
}
}
impl Megaweapon for WeaponDouble {
fn shoot(&self) {
self.times_shot.set(self.times_shot.get() + 1);
}
}
The existing test for the original use case must be updated to add the intensity and use verify(). Notice that I have
changed the name too.
#[test_context(Context)]
#[test]
fn non_intense_attack_shoots_weapon_once(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon, false);
weapon.verify();
}
We run the tests with cargo t --lib and our refactored test should all be passing. So far, so good.
So, let's deal with the new use case. We want to check that when the supervillian performs an intense attack, it shoot the megaweapon twice or more.
#[test_context(Context)]
#[test]
fn intensive_attack_shoots_weapon_twice_or_more(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon, false);
// ???
}We could obviously write a second verify method. But we are looking for a better option that is more flexible and reusable. Let's use functions to change the verification.
To implement this approach, we can create a top-level function, within of the tests module that returns a closure.
This closure will take number of times that the method has been invoked as a parameter. And it will use the parameter
passed to the function to return a boolean, that states whether the check has passed. If we want to specify a minimum
number of invocations required for the method, we can define a function like the following one.
fn at_least(min_times: u32) -> impl Fn(u32) -> bool {
return (move |times: u32| (times >= min_times));
}
We can modify the verify() method of the test double to take this function and use it with the number of times that
the shoot() method was invoked.
fn verify<T: Fn(u32) -> bool>(&self, check: T) {
assert!(check(self.times_shot.get()));
}We also need another assertion function for our previously existing test, the one with no intensity.
fn once() -> impl Fn(u32) -> bool {
return (move |times: u32| (times == 1));
}
And these are the tests updated to use this more powerful and flexible verify() method.
#[test_context(Context)]
#[test]
fn non_intensive_attack_shoots_weapon_once(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon, false);
weapon.verify(once());
}
#[test_context(Context)]
#[test]
fn intensive_attack_shoots_weapon_twice_or_more(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon, true);
weapon.verify(at_least(2));
}Final code
As in previous articles, I have included here the final version of the supervillain.rs code file. Remember that you
can always take a look –or abuse, if you prefer– to the corresponding commit of the repo with all the code in this
series.
//! Module for supervillains and their related stuff
#![allow(unused)]
use std::time::Duration;
use rand::Rng;
use thiserror::Error;
#[cfg(not(test))]
use crate::sidekick::Sidekick;
use crate::{Gadget, Henchman};
#[cfg(test)]
use tests::doubles::Sidekick;
/// Type that represents supervillains.
#[derive(Default)]
pub struct Supervillain<'a> {
pub first_name: String,
pub last_name: String,
pub sidekick: Option<Sidekick<'a>>,
}
pub trait Megaweapon {
fn shoot(&self);
}
impl Supervillain<'_> {
/// Return the value of the full name as a single string.
///
/// Full name is produced concatenating first name, a single space, and the last name.
///
/// # Examples
/// ```
///# use evil::supervillain::Supervillain;
/// let lex = Supervillain {
/// first_name: "Lex".to_string(),
/// last_name: "Luthor".to_string(),
/// };
/// assert_eq!(lex.full_name(), "Lex Luthor");
/// ```
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<_>>();
println!("Received {} components.", components.len());
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, intense: bool) {
weapon.shoot();
if intense {
let mut rng = rand::rng();
let times = rng.random_range(1..3);
for _ in 0..times {
weapon.shoot();
}
}
}
pub async fn come_up_with_plan(&self) -> String {
tokio::time::sleep(Duration::from_millis(100)).await;
String::from("Take over the world!")
}
pub fn conspire(&mut self) {
if let Some(ref sidekick) = self.sidekick {
if !sidekick.agree() {
self.sidekick = None;
}
}
}
pub fn start_world_domination_stage1<H: Henchman, G: Gadget>(
&self,
henchman: &mut H,
gadget: &G,
) {
if let Some(ref sidekick) = self.sidekick {
let targets = sidekick.get_weak_targets(gadget);
if !targets.is_empty() {
henchman.build_secret_hq(targets[0].clone());
}
}
}
pub fn start_world_domination_stage2<H: Henchman>(&self, henchman: H) {
henchman.fight_enemies();
henchman.do_hard_things();
}
}
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(),
sidekick: None,
})
}
}
}
#[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::Cell;
use test_context::{AsyncTestContext, 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 non_intensive_attack_shoots_weapon_once(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon, false);
weapon.verify(once());
}
#[test_context(Context)]
#[test]
fn intensive_attack_shoots_weapon_twice_or_more(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon, true);
weapon.verify(at_least(2));
}
#[test_context(Context)]
#[tokio::test]
async fn plan_is_sadly_expected(ctx: &mut Context<'_>) {
assert_eq!(ctx.sut.come_up_with_plan().await, "Take over the world!");
}
#[test_context(Context)]
#[test]
fn keep_sidekick_if_agrees_with_conspiracy(ctx: &mut Context) {
let mut sk_double = doubles::Sidekick::new();
sk_double.agree_answer = true;
ctx.sut.sidekick = Some(sk_double);
ctx.sut.conspire();
assert!(ctx.sut.sidekick.is_some(), "Sidekick fired unexpectedly");
}
#[test_context(Context)]
#[test]
fn fire_sidekick_if_doesnt_agree_with_conspiracy(ctx: &mut Context) {
let mut sk_double = doubles::Sidekick::new();
sk_double.agree_answer = false;
ctx.sut.sidekick = Some(sk_double);
ctx.sut.conspire();
assert!(
ctx.sut.sidekick.is_none(),
"Sidekick not fired unexpectedly"
);
}
#[test_context(Context)]
#[test]
fn conspiracy_without_sidekick_doesnt_fail(ctx: &mut Context) {
ctx.sut.conspire();
assert!(ctx.sut.sidekick.is_none(), "Unexpected sidekick");
}
#[test_context(Context)]
#[test]
fn world_domination_stage1_builds_hq_in_first_weak_target(ctx: &mut Context) {
let gdummy = GadgetDummy {};
let mut hm_spy = HenchmanDouble::default();
let mut sk_double = doubles::Sidekick::new();
sk_double.targets = test_common::TARGETS.map(String::from).to_vec();
ctx.sut.sidekick = Some(sk_double);
ctx.sut.start_world_domination_stage1(&mut hm_spy, &gdummy);
assert_eq!(
hm_spy.hq_location,
Some(test_common::FIRST_TARGET.to_string())
);
}
#[test_context(Context)]
#[test]
fn world_domination_stage2_tells_henchman_to_do_hard_things_and_fight_with_enemies(
ctx: &mut Context,
) {
let mut henchman = HenchmanDouble::default();
henchman.assertions = vec![Box::new(move |h| h.verify_two_things_done())];
ctx.sut.start_world_domination_stage2(henchman);
}
pub(crate) mod doubles {
use std::marker::PhantomData;
use crate::Gadget;
pub struct Sidekick<'a> {
phantom: PhantomData<&'a ()>,
pub agree_answer: bool,
pub targets: Vec<String>,
}
impl<'a> Sidekick<'a> {
pub fn new() -> Sidekick<'a> {
Sidekick {
phantom: PhantomData,
agree_answer: false,
targets: vec![],
}
}
pub fn agree(&self) -> bool {
self.agree_answer
}
pub fn get_weak_targets<G: Gadget>(&self, _gadget: &G) -> Vec<String> {
self.targets.clone()
}
}
}
struct GadgetDummy;
impl Gadget for GadgetDummy {
fn do_stuff(&self) {}
}
#[derive(Default)]
struct HenchmanDouble {
hq_location: Option<String>,
current_invocation: Cell<u32>,
done_hard_things: Cell<u32>,
fought_enemies: Cell<u32>,
assertions: Vec<Box<dyn Fn(&HenchmanDouble) -> () + Send>>,
}
impl HenchmanDouble {
fn verify_two_things_done(&self) {
assert!(self.done_hard_things.get() == 2 && self.fought_enemies.get() == 1);
}
}
impl Henchman for HenchmanDouble {
fn build_secret_hq(&mut self, location: String) {
self.hq_location = Some(location);
}
fn do_hard_things(&self) {
self.current_invocation
.set(self.current_invocation.get() + 1);
self.done_hard_things.set(self.current_invocation.get());
}
fn fight_enemies(&self) {
self.current_invocation
.set(self.current_invocation.get() + 1);
self.fought_enemies.set(self.current_invocation.get());
}
}
impl Drop for HenchmanDouble {
fn drop(&mut self) {
for a in &self.assertions {
a(self);
}
}
}
struct WeaponDouble {
pub times_shot: Cell<u32>,
}
impl WeaponDouble {
fn new() -> WeaponDouble {
WeaponDouble {
times_shot: Cell::default(),
}
}
fn verify<T: Fn(u32) -> bool>(&self, check: T) {
assert!(check(self.times_shot.get()));
}
}
impl Megaweapon for WeaponDouble {
fn shoot(&self) {
self.times_shot.set(self.times_shot.get() + 1);
}
}
struct Context<'a> {
sut: Supervillain<'a>,
}
impl<'a> AsyncTestContext for Context<'a> {
async fn setup() -> Context<'a> {
Context {
sut: Supervillain {
first_name: test_common::PRIMARY_FIRST_NAME.to_string(),
last_name: test_common::PRIMARY_LAST_NAME.to_string(),
..Default::default()
},
}
}
async fn teardown(self) {}
}
fn at_least(min_times: u32) -> impl Fn(u32) -> bool {
return (move |times: u32| (times >= min_times));
}
fn once() -> impl Fn(u32) -> bool {
return (move |times: u32| (times == 1));
}
}Summary
In this article, I have explained that mocks have self-verification capabilities, making them a super-charged spy. This feature allows us to effectively contain the business logic of the type being doubled.
Furthermore, I've delved into the details of making these verifications flexible and extensible using top-level
functions that return closures. By creating as many of these functions as needed and putting them in the test_common
module, we can share them across different tests and improve their flexibility and readability.
This time I haven't cheated having a mutable parameter in the method. Instead, I have revisited interior mutability
using the proper tool for the job when things can be copied –Cell<T>.
Get ready for the only test double that is left: fakes.
Stay curious. Hack your code. See you next time!
Footnotes
Notice this would have been easier if we have enforced the Interface segregation principle. And I asked you to humor me with the easier implementation so you could see this other effect.
The old test will not have anything in the assertions vector, so the Drop implementation will effectively be a no-op.
But it only keeps the last time it happened.