Contents

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!

Footnotes


1

I couldn't help myself to have this movie subtitle: "Adventures of a spy and a dummy." Coming to a movie theater near you… soon.

2

We can also derive Default for the Sidekick double and simplify its initialization if new fields are added.