Contents

Rust unit testing: test doubles & stubs

Manual test doubles

Unit testing is about writing tests for individual units (Duh!), most frequently these units are the types that we define in our code, i.e., structs / enums / unions, or first-class functions. But the types that we want to test usually have dependencies on other types and we would like to test them in isolation without having to worry about the details of those dependencies. That's why you need test doubles.

Test doubles provide us with the following advantages:

Avoid collateral effects
By using a test double instead of the actual type, we avoid or, at least control, any actions performed by the actual type, like writing to a file or communicating through the network.
Control the behavior of the replaced type and its responses
We can then trigger the responses that allows us to test different scenarios. You can get responses from them that would be hard to obtain under normal circumstances, including errors that would require a lot of work to make them happen.
Be aware of the interactions with the replaced instance
The test doubles are able to register when its methods are invoked and the parameters used each time. We can use that information to check that our expectations are correct.

However, for this replacement to take place, we need to have a mechanism for providing the test double instead of the actual type. We refer to this as "dependency injection", meaning that the dependency can be injected into the unit that we are testing1. If the type that we want to replace is created inside of the code that we want to test, for example inside of a method, and this creation is hard-coded, it would be very much harder or even not possible to replace it. In that case, your code ain't water, my friend πŸ₯‹.

There are several ways in which we can inject the dependency in the instance of our type. According to Mark Seemann, these are the main ones:

Constructor injection
The dependency is provided as a parameter to the constructor (associated function new() or similar) of the type that we want to test.
Field injection
The dependency is in a field of the type that we are testing and it can be replaced by our test double.
Method injection
The dependency is taken from one of the arguments of the method or function that we want to exercise in our test.
Ambient context
The dependency is accessed from a static item, that can also be replaced by the test double.

Even if the code of the type that we are testing offers any of those injection points, to use a test double, they have to be replaceable. If a dependency can be provided as constructor argument, a field, a method argument, or a static variable, we need that the type of that dependency has some flexibility. Rust doesn't allow inheritance, so passing a subtype of the dependency where we modify the way it works by overriding its methods is not an option. But Rust allows instances to be replaced if the type is defined as an implementation of a trait (for example, fn do(x: impl SomeTrait), fn do<X: SomeTrait>(x: X), or a trait object fn do(x: &dyn SomeTrait)).

Test doubles can help us simplify the architecture of the code, but we should only alter the interface of the type being tested if we cannot replace the dependency in any other way. In most cases, the usability of the interface gets along with the testability of the type.

Finally in this introduction to test doubles, I would like to introduce the different kinds of tests doubles as described by Martin Fowler2. I provide a brief description of each kind, but I will extend them when I implement each one of them, and its implementation will clarify their purpose and differences.

Dummy
An instance of a type that is required to make our test code work –for example, an argument of the constructor or the method that we want to test,– but that is not actually used in the test.
Fake
A type that has a simpler, but working implementation that makes more sense for the test. For example, it can be computationally cheaper.
Stub
A type that can be controlled to provide the desired answer to the invocations of the methods used in the test.
Spy
A type that does the same as the stub, but also keeps track of the methods that have been invoked and can be interrogated later.
Mock
A spy that contains the logic for the verification of the expectations of its usage and can react to unexpected interactions.

Notice that a dummy in one test might become a full-fledged mock in another.

In this article and the following ones, I will introduce a series of scenarios that have to be tested and we will write the required test double from scratch, i.e. no mocking library. It is time to warm up your finger muscles and start typing.

Add sidekick and gadget: A wicked scenario

The power of the wicked is growing around us, and supervillains don't like doing everything by themselves. Having a sidekick is one of the perks of becoming being promoted from villain to supervillain. Let's start by defining the Sidekick type in a new module, i.e., a new file sidekick.rs.

  //! Module for sideckicks and all the related functionality
  #![allow(dead_code)]

  /// Type that represents a sidekick.
  pub struct Sidekick {}

And we add it to the library (lib.rs).

  pub mod sidekick;
  //...
  pub use sidekick::Sidekick;

A Supervillain may have a Sidekick, but it doesn't require one to exist. Remember: it is a perk, not an obligation. We encode this by adding a field to Supervillain. It must be an Option<>, because it might not have one.

  use crate::sidekick::Sidekick;

  /// Type that represents supervillains.
  pub struct Supervillain {
      pub first_name: String,
      pub last_name: String,
      pub sidekick: Option<Sidekick>,
  }

That change requires making a modification to the implementation of the TryFrom trait so the code can continue compiling (cargo b --lib3):

  Ok(Supervillain {
      first_name: components[0].to_string(),
      last_name: components[1].to_string(),
      sidekick: None,
  })

When we compile the tests (cargo t --lib) we realize that there is another case of an incomplete initialization in the creation of the context for the tests. We could solve them adding the new field and its value, but a better solution would be to complete the rest of the fields with their defaults. We need to derive Default for Supervillain.

  #[derive(Default)]
  pub struct Supervillain {

And use it to complete the struct. Notice that we have to do this just once, because the context is used for all (most) of the tests.

  sut: Supervillain {
      first_name: test_common::PRIMARY_FIRST_NAME.to_string(),
      last_name: test_common::PRIMARY_LAST_NAME.to_string(),
      ..Default::default()
  },

At this point, our tests should compile and pass. Ta-da!

A sidekick is worth nothing unless they have a gadget, and a gadget can be many things. We can define a gadget as "something that does stuff". Yes, I know that this is not very specific, but life ain't either and outlaws, even less. So, we define a trait for gadgets with just one associated function (do_stuff()) in a new file (gadget.rs).

  //! Module for gadgets and all the related functionality
  #![allow(dead_code)]

  /// Trait that represents a gadget.
  pub trait Gadget {
      fn do_stuff(&self);
  }

We have to add gadget to the library (lib.rs), too.

  pub mod gadget;
  //...
  pub use gadget::Gadget;

Sidekicks require a Gadget to exist (to be constructed), and they store them in a field. However, a trait can not be used as the type of a parameter because a trait it is not a type in itself. We could use a type constraint as we did in the method Supervillain::attack(&self, weapon: &impl Megaweapon), but instead, we can pass a reference or smart pointer to a trait object and store it. Assuming that we want the sidekick to be the owner of the gadget, we are going to use a Box to store it.

  use crate::Gadget;

  /// Type that represents a sidekick.
  pub struct Sidekick {
      gadget: Box<dyn Gadget>,
  }

  impl Sidekick {
      fn new<G: Gadget>(gadget: G) -> Sidekick {
          Sidekick {
              gadget: Box::new(gadget),
          }
      }
  }

If we compile the code as it is now (cargo b --lib), the compiler will complain the lifetime of the gadget. Let's add explicit lifetimes to the Sidekick.

  pub struct Sidekick<'a> {
      gadget: Box<dyn Gadget + 'a>,
  }

  impl<'a> Sidekick<'a> {
      fn new<G: Gadget + 'a>(gadget: G) -> Sidekick<'a> {
          Sidekick {
              gadget: Box::new(gadget),
          }
      }
  }

This change must be propagated to the Supervillian.

  pub struct Supervillain<'a> {
      pub first_name: String,
      pub last_name: String,
      pub sidekick: Option<Sidekick<'a>>,
  }

  //...

  impl Supervillain<'_> {
      //...
  }

  impl TryFrom<&str> for Supervillain<'_> {
      //...
  }

This new version should build without errors. But, if we try to run the tests (cargo t --lib), we will notice that it also must be propagated to the Context test helper and to its implementation of AsyncContext.

  struct Context<'a> {
      sut: Supervillain<'a>,
  }

  impl<'a> AsyncTestContext for Context<'a> {
      async fn setup() -> Context<'a> {
  	// ...
      }

      // ...
   }

However, when we try to compile (cargo t --lib), we still get an error. This time, it is because Gadget is not Send. We can politely force it to be Send.

  pub trait Gadget: Send {

Finally, we need to provide a placeholder lifetime for the async context that is provided as test_context so that it gets inferred and everything compiles fine.

  async fn plan_is_sadly_expected(ctx: &mut Context<'_>) {

Our tests are working and passing again. Inhale, exhale, and relax.

Test Stub

Now that the code compiles and the tests pass again, we can implement one of the most important functions of a sidekick: agreeing. We could add an argument that explains what they have to agree to, but that would be unrealistic. πŸ˜„

  pub fn agree(&self) -> bool {
      true
  }

Notice that in this implementation we are returning true, because either true or false must be returned, and the former makes more sense. We could have more (business? wicked?) logic in this method, but we should be able to test the supervillain independently of this implementation.

Supervillain can now implement the conspire() method. In it, they will ask their sidekick, if any, to agree with them. Supervillain uses the method Sidekick::agree(&self) of the Sidekick and strongly expects a positive response (true). If the answer is affirmative, the supervillain keeps their sidekick, otherwise they fire them (sets the field to None).

  pub fn conspire(&mut self) {
      if let Some(ref sidekick) = self.sidekick {
          if !sidekick.agree() {
              self.sidekick = None;
          }
      }
  }

We want to test three scenarios. The first one is that when the sidekicks agree to the conspiracy, the supervillain keeps them. We could use the original Sidekick for that, but we would be conditioning this test of the Supervillain to the current implementation of the Sidekick, which doesn't seem like a great idea.

  #[test_context(Context)]
  #[test]
  fn keep_sidekick_if_agrees_with_conspiracy(ctx: &mut Context) {
      // Arrange

      // Act

      // Assert
      unimplemented!();
  }

The second scenario is that, if the sidekicks don't agree to the conspiracy, the supervillian fires them. We need to convince the sidekick to answer negatively, but that might be really hard because it is part of his essence and hard-coded in our code.

  #[test_context(Context)]
  #[test]
  fn fire_sidekick_if_doesnt_agree_with_conspiracy(ctx: &mut Context) {
      // Arrange

      // Act

      // Assert
      unimplemented!();
  }

The third one is trivial in respect to the sidekick, because we want to be sure that everything goes well when there is no sidekick.

  #[test_context(Context)]
  #[test]
  fn conspiracy_without_sidekick_doesnt_fail(ctx: &mut Context) {
      // Arrange

      // Act

      // Assert
      unimplemented!();
  }

Let's get the last one out of the way. Delete the line with unimplemented!() and add this code.

  ctx.sut.conspire();
  assert!(ctx.sut.sidekick.is_none(), "Unexpected sidekick");

We run it and it should pass. So far, so good!

In order to test the method properly, we need a test double for the sidekick. Otherwise, we won't be able to control the answer from the sidekick and, consequently, we won't be able to test the case in which the sidekicks answer that they don't agree and the supervillain fires them. Also, our test would be depending on the current implementation of the Sidekick and future changes of it may affect the outcome of the test.

When using languages that support inheritance, if a dependency uses a concrete type, it is generally replaced by a test double by taking advantage of the inheritance. The subclass can be used instead of the original type and the behavior is changed by overriding the desired methods.

However, Rust doesn't support inheritance, so a different method must be used to replace those types. The mechanism used by the mockall crate is to modify the path to the actual object using the namespace of the test double. Don't worry if you don't fully understand what I have just said, because we are going to implement the same thing manually.

Let's start by defining a new module for the test doubles within the existing test module in the supervillain.rs file.

  pub(crate) mod doubles {
  }

In that module, we have to create a struct with the same name of the type that we want to replace. It doesn't have to be exactly the same structure with the same fields, but it must contain the fields and methods that are used by the regular code. This will be our doppelganger.

  pub struct Sidekick {}

We can now take advantage of the namespaces to use one or the other. Basically we will tell the compiler to use the Sidekick that is the sidekick module of this crate (crate::sidekick::Sidekick), except when it is producing the tests. In that case, we want it to use the one in the doubles module of our tests module (tests::doubles::Sidekick).

  #[cfg(not(test))]
  use crate::sidekick::Sidekick;
  #[cfg(test)]
  use tests::doubles::Sidekick;

But, if we try to compile the tests (cargo t --lib), we will get errors about the lifetime. However, adding one to the struct won't be enough unless we use it. Since we don't want the test double to depend on a Gadget, or else we will need a gadget dummy, we can use phantom data to include the lifetime without having a real field. Notice that we only import the PhantomData type inside of the doubles module.

  use std::marker::PhantomData;

  pub struct Sidekick<'a> {
      phantom: PhantomData<&'a ()>,
  }

The implementation only has to contain the associated functions and methods that are used by Supervillian or our tests.

  impl<'a> Sidekick<'a> {
      pub fn new() -> Sidekick<'a> {
          Sidekick {
              phantom: PhantomData,
          }
      }
      pub fn agree(&self) -> bool {
          true
      }
  }

We have to modify the implementation slightly, because we want to control the response from the agree() method. That is what a stub is supposed to be good for.

  pub struct Sidekick<'a> {
      phantom: PhantomData<&'a ()>,
      pub agree_answer: bool,
  }

  impl<'a> Sidekick<'a> {
      pub fn new() -> Sidekick<'a> {
          Sidekick {
              phantom: PhantomData,
              agree_answer: false,
          }
      }

      pub fn agree(&self) -> bool {
          self.agree_answer
      }
  }

Now, we can write the test for the first scenario. We can be sure that the sidekick will answer positively independently of any future implementation of the agree() method.

  #[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");
  }

And also for the second scenario, where we can force the sidekicks to answer against what is coded in their DNA.

  #[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");
  }

Use cargo to run the tests. All should pass. Freakingcalifragilisticexpialidocious!

Final code

There are many changes in this article and I recommend you to visit the corresponding commit of the repo with all the code in this series. However, I have copied the final version of the supervillain.rs file so you can check it to understand where the changes take place.

  //! Module for supervillains and their related stuff
  #![allow(unused)]
  use std::time::Duration;

  use thiserror::Error;

  #[cfg(not(test))]
  use crate::sidekick::Sidekick;
  #[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;
              }
          }
      }
  }

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

      pub(crate) mod doubles {
          use std::marker::PhantomData;

          pub struct Sidekick<'a> {
              phantom: PhantomData<&'a ()>,
              pub agree_answer: bool,
          }

          impl<'a> Sidekick<'a> {
              pub fn new() -> Sidekick<'a> {
                  Sidekick {
                      phantom: PhantomData,
                      agree_answer: false,
                  }
              }

              pub fn agree(&self) -> bool {
                  self.agree_answer
              }
          }
      }

      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 has been a longer one, but I hope it was worth it. I started with a "theoretical" introduction to why we want to use test doubles and what each of the different kinds can do. Then, the rest of the article has been about when you need a stub, how to build it, and how to use it.

It is important to notice that, even though at the beginning of this article I was talking about replaceable dependencies and how traits define abstractions that can be use for that, we haven't used that in this case. We have taken the hardest path first and used namespaces to take care of the "dirty work". We have used field injection to get our dependency into the unit that we were testing. We could have used a dummy gadget and pass it to the constructor, but we have done better and fully replaced the type used as a dependency and remove the transitive dependency on the gadget.

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

Footnotes


1

Dependency injection goes beyond unit testing, but this explanation is good enough for our purposes.

2

Indisputably a rockstar.

3

If you omit the --lib option, it will not compile, because the module sidekick is not part of the binary.