Rust unit testing: spy and dummy test doubles
Adventures of a spy and a dummy

In my last article, I explained what test doubles are and why we need them. In this one, I am going to focus on two different types of test doubles: the spy and the dummy.1
A spy is a test double that remembers if its methods have been invoked and the arguments used with each invocation. It spies on the activity of the caller instance. A dummy does nothing, but it is required to create or use something else.
Building the scenario for the Adventures of a spy and a dummy
Let me put you in context. Supervillains aren't super because they can fly –most they can't,– but because they can reach far when spreading evil. And if they want to be really effective, a sidekick ain't enough. They also need some henchmen. Henchmen are useful and disposable. Not very smart, though. So the orders issued to them by the supervillian must be clear and concise.
We are going to start by defining a henchman, which we are going to model as a trait. We put it in a new file
(henchman.rs
) and add it to lib.rs
. The trait is going to have a single method that will be used to tell a henchman
to build the headquarters.
//! Module to define henchmen.
#![allow(dead_code)]
/// Henchman trait.
pub trait Henchman {
fn build_secret_hq(&mut self, location: String);
}
We add it to the lib.rs
file.
pub mod henchman;
pub use henchman::Henchman;
Once the supervillains have started to work, they need to establish themselves, and, since housing prices have gone to
the roof, they need their henchmen to build their headquarters. We have to admit that supervillians use top-notch
architects to design their headquarters and that they somehow get bargains when aquiring the land. In any case, the
supervillian can start world domination by using a new method, start_world_domination_stage1()
. In it, the
supervillian will give a gadget to their sidekick and ask them to provide a list of cities that can be used as targets,
because they are weak. Then they will ask the henchman to build the headquarters in the first city of the list, which
should probably be the weakest.
Let's start by defining the new Supervillain
method and import Henchman
and Gadget
. We had already defined
Gadget
in a previous article.
pub fn start_world_domination_stage1<H: Henchman, G: Gadget>(&self, henchman: &mut H, gadget: &G) {
}
In the implementation of that method, we need to have a sidekick that gets the list of weak targets from the sidekick using the gadget.
if let Some(ref sidekick) = self.sidekick {
let targets = sidekick.get_weak_targets(gadget);
}
We can only continue if the vector with the list of weak cities isn't empty. Otherwise, we should finish working on the first stage of world domination and return.
if !targets.is_empty() {
}
But if there is at least one entry in the list of weak cities, the supervillain can tell the henchman that was passed
onto this method to build_secret_hq()
in the selected target.
henchman.build_secret_hq(targets[0].clone());
There is still a missing piece. Our Sidekick
needs to have the new method declared, but we don't need anything else than a
void implementation.
pub fn get_weak_targets<G: Gadget>(&self, _gadget: &G) -> Vec<String> {
vec![]
}
That should be enough, but let's compile it to check that everything is fine. We first compile the library with cargo
b --lib
. So far, so good. And we also run the tests with cargo t --lib
and… the new method is missing in the test
double of the Sidekick
that we created in the last article. We need to define it in tests::doubles
and import
Gadget
in that module.
pub fn get_weak_targets<G: Gadget>(&self, _gadget: &G) -> Vec<String> {
vec![]
}
The scenario is now ready.
Test dummies and test spies
We want to test the method that triggers the first stage of the world domination, and that method requires a henchman and a gadget. We will take care of the henchman later, but we realize that the gadget that we need to use with the method is directly passed onto the sidekick. The sidekick of the test that we will write is going to be a test double, so it doesn't have to do anything with it.
That is the typical use case for a dummy: we can't write the test without it, but it can be an empty shell because it won't be used.
We create a GadgetDummy
in the tests
module of the Supervillain
. We will reserve tests::doubles
for the doubles
that have to be replaced at compile time using configuration conditional checks, as we did in my last article.
struct GadgetDummy;
impl Gadget for GadgetDummy {
fn do_stuff(&self) {}
}
In the previous article, we implemented a stub for the Sidekick
in which we could control what the Sidekick
double
returned when its agree()
method was invoked. We are now going to do the same for another method:
get_weak_targets()
. We add a field to the Sidekick
double that will hold the values that will be used to reply to
its invocation and initialize it in the constructor associated function.2
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![],
}
}
We want to define some constants that will help us make the test more readable and reduce the mistakes attributable to
typos, as I explained in the second article of this series. These constants will contain the first target city and the
whole list. We are only going to use these constants in only one test, but we will put them in the test_common
module
(test_common.rs
file) just for consistency.
pub const FIRST_TARGET: &str = "Tampa";
pub const TARGETS: [&'static str; 3] = [FIRST_TARGET, "Pamplona", "Vilnius"];
And we implement the method in the Sidekick
double to return what we have stored in its targets
field.
pub fn get_weak_targets<G: Gadget>(&self, _gadget: &G) -> Vec<String> {
self.targets.clone()
}
Now that the rest is in place, it is time to lower the lights and play "The Spy Who Loved Me" in your favorite music
streaming platform. We are going to implement a test double for the Henchman
and it is going to be a spy, because we
want to know if the supervillain tells them to build the secret headquarters and, if so, what is the location provided
to do so.
We create a HenchmanSpy
type in the tests
module of the Supervillain
. That type has to implement the Henchman
trait, so it can be used in the method that we are testing.
struct HenchmanSpy;
impl Henchman for HenchmanSpy {
fn build_secret_hq(&mut self, location: String) {
}
}
In order to fulfill the purpose of a (test) spy, we add a field to the HenchmanSpy
to remember the argument used to
invoke build_secret_hq()
. This field is going to be an Option<String>
because it will contain None
if the method
hasn't been invoked.
struct HenchmanSpy {
hq_location: Option<String>,
}
And in the implementation of the method that we want to monitor, we store the argument in the field that we have just defined.
impl Henchman for HenchmanSpy {
fn build_secret_hq(&mut self, location: String) {
self.hq_location = Some(location);
}
}
Now that we have prepared all the test doubles, we can write the test. Let's start with its skeleton. We want to test that when stage 1 of world domination is used, the headquarters are built in the weakest city.
#[test_context(Context)]
#[test]
fn world_domination_stage1_builds_hq_in_first_weak_target(ctx: &mut Context) {
}
On the arrangements, we create an instance of GadgetDummy
, HenchmanSpy
(including the initialization of its field,)
and the Sidekick
double. Also we tell the sidekick double the targets that we want they to provide when requested for
a list. And we assign that sidekick double to the supervillain instance that we are testing: our system under test
(SUT.)
let gdummy = GadgetDummy{};
let mut hm_spy = HenchmanSpy {
hq_location: None,
};
let mut sk_double = doubles::Sidekick::new();
sk_double.targets = test_common::TARGETS.map(String::from).to_vec();
ctx.sut.sidekick = Some(sk_double);
The act part of the test is the simplest one. We invoke the method that triggers the first stage of world domination, providing our henchman spy and the gadget dummy as arguments.
ctx.sut.start_world_domination_stage1(&mut hm_spy, &gdummy);
And the assert checks that the headquarters location stored in the henchman spy is the first city in the targets list,
which can only happen if the method of Henchman
was invoked with the right argument.
assert_eq!(hm_spy.hq_location, Some(test_common::FIRST_TARGET.to_string()));
We compile, correct the seasoning, and run the tests (cargo t --lib
). They should all pass. Hallelujah!
Final code
We have changed several files in this article and I would advise you to check the corresponding commit of the repo with
all the code in this series. However, this is the final version of the supervillain.rs
file, as of this article, so
you can have a better understanding of the changes that we did.
//! Module for supervillains and their related stuff
#![allow(unused)]
use std::time::Duration;
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) {
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());
}
}
}
}
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::RefCell;
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 attack_shoots_weapon(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon);
assert!(*weapon.is_shot.borrow());
}
#[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 = HenchmanSpy { hq_location: None };
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())
);
}
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) {}
}
struct HenchmanSpy {
hq_location: Option<String>,
}
impl Henchman for HenchmanSpy {
fn build_secret_hq(&mut self, location: String) {
self.hq_location = Some(location);
}
}
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<'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) {}
}
}
Summary
This time, I have introduced and used a couple of test doubles: spies and dummies.
The former is very useful when you want to know if the system under test has interacted with another type in the expected way: expected method, expected arguments, and even expected number of times. In fact, this is what I used in the first article of this series to verify that the supervillain attacked used the megaweapon.
The latter doesn't do much, but invoking some methods or creating some instances would be impossible without them.
In this case, we have replaced instances because they implemented the required type. It is certainly easier than what
we did for replacing the Sidekick
stub, but not always possible. While one can argue that having a mutable reference
to a Henchman
is a little bit self-serving –and I may agree,– I already explained in the megaweapon double how to
use interior mutability as a workaround when the reference is not mutable.
In my next article, I will explain mocks and we will write together one. Three tests doubles types explained, two more to go.
Stay curious. Hack your code. See you next time!