Contents

Rust unit testing: file writing

File writing tests

This is the third and last article on file I/O testing, and yet again, the focus is on the technique for injecting a dependency so we can use it in the tests. In this article, I will cover a writing scenario that requires us to access the test double after invoking our code to check the written contents.

Writing to a file

If you are just a regular villain, your henchmen are close to you. You have recruited them personally, know what they can do, and call each one of them by their name… Well, not really, because you don't care about their names. But you don't have any issues letting them know what you want from them, because they are always around you. Those times are long gone and deeply missed.

But time passes, you do (many) evil things, and get promoted from villain to supervillain. Then things aren't that easy. You need to coordinate henchmen across many countries to take down the multiple wannabe secret agents. And that is a precision task that requires all of your henchmen to read their orders from the same file.

Our supervillain wants to write a file containing their orders, and we have been tasked with writing the code that generates the file and its corresponding tests. Let's start with the new code.

Our new method will take two parameters: the path of the file where we will store the orders. If everything goes well, it will return the number of orders written; otherwise, it will report an error.

  pub fn spread_orders_by_file<P: AsRef<Path>>(
      &self,
      path: P,
      orders: Vec<String>,
  ) -> Result<usize, io::Error> { }

We will use that file path to create the file and write to it. File implements the Write trait, so we could write to that instance directly. However, if we perform many write operations –and, trust me, there will be a lot of orders– that would be very inefficient due to all of the syscalls that it involves. Instead, we will use the instance to create a BufWriter.

  let file_orders = File::create(path)?;
  let mut buf_orders = BufWriter::new(file_orders);

Unlike BufReader, which implements BufRead (on top of Read), BufWriter only implements Write. There is no BufWrite. BufRead provides additional reading functionality over Read, but the writing operations provided by BufWriter are already exposed by Write. They also offer their implementation of Seek, but that is outside of this discussion.

We can use the write!() macro to write to the file. First, the "header" and then the orders. This can be the complete method, and remember to import std::io::Write to make it work.

  pub fn spread_orders_by_file<P: AsRef<Path>>(
      &self,
      path: P,
      orders: Vec<String>,
  ) -> Result<usize, io::Error> {
      let file_orders = File::create(path)?;
      let mut buf_orders = BufWriter::new(file_orders);

      let mut orders_written = 0;
      write!(
          buf_orders,
          "I, {}, as your leader, tell you to:\n",
          &self.full_name()
      )?;

      for order in orders {
          write!(buf_orders, "- {}\n", order)?;
          orders_written += 1;
      }

      Ok(orders_written)
  }

This implementation should keep our supervillain happy. With the core code ready, let's work on our survival as their developer by writing some tests.

Testing file writing

We can write at least two tests for our new method:

  • The method returns an error when the file cannot be opened for writing.
  • The method writes the expected content to the file and reports the number of orders written.

Create a dependency injection point

A first look at the method and the first test that we want to write suggests that we should create a dependency injection point in a replaceable free function, as I explained in my previous article. Let's take the same steps.

Let's "refactor" the code and extract the creation of the BufWriter to a free function. That function is in the same aux module. Notice that I have chosen to return the abstraction Write instead of the actual type, so we can replace it with a test double, as we did with BufRead.

  pub fn open_write<P: AsRef<Path>>(path: P) -> Result<impl Write, io::Error> {
      File::create(path).map(|file| BufWriter::new(file))
  }

We call this function from the method, instead of creating the BufWriter in place.

  let mut buf_orders = open_write(path)?;

The free function has to be imported for the main code:

  #[cfg(not(test))]
  use aux::{open_buf_read, open_write};

We can successfully compile the code with cargo b --lib, but the tests won't run because we haven't created a test double of open_write() yet.

Test when the file cannot be created

We can start by writing the test. Notice that the invocation of the method uses a nonexistent path and an empty vector of orders. We aren't going to use any of the arguments in this case, so this is fine.

  #[test_context(Context)]
  #[test]
  fn spread_orders_by_file_returns_error_on_failure(ctx: &mut Context) {
      FILE_CAN_OPEN.set(false);
      assert_err!(ctx.sut.spread_orders_by_file("some/path", vec![]));
  }

We don't need to return a type that implements Write yet, since we are testing the case where the file cannot be opened, so let's return an error.

  pub fn open_write<P: AsRef<Path>>(path: P) -> Result<impl Write, io::Error> {
      Err(io::Error::new(ErrorKind::Other, "Unable to create file"))
  }

And we import this function for the tests.

  #[cfg(test)]
  use tests::doubles::{open_buf_read, open_write};

Nevertheless, Rust wants to do its homework and needs to figure out which type implements Write. But there isn't enough information to achieve that. We can try to be explicit with the opaque type, but this isn't allowed.

  Err::<impl Write, io::Error>(io::Error::new(ErrorKind::Other, "Unable to create file")) // Doesn't work

Another option would be to return a BufWriter and replace it later. Yet, BufWriter needs a type parameter because it is a generic type too, and using our File test double won't help because it doesn't implement all of its methods and associated functions.

We will have to choose a test double to complete this test. So, again, we review the implementors of Write and stumble upon our old friend, Cursor, and in different versions. I am going to go with the one that uses a Vec<u8>, because it seems like a good option for holding new content of unknown size.

  Err::<Cursor<Vec<u8>>, io::Error>(io::Error::new(ErrorKind::Other, "Unable to create file"))

We run the tests with cargo t --lib and get our first one to pass.

Test the file is written with the orders

One more time, we can start by writing the test. We tell our function test double to "succeed" and return a test double that implements Write. We also create a list of orders and, finally verify the value returned by the function.

  #[test_context(Context)]
  #[test]
  fn spread_orders_by_file_writes_provided_orders(ctx: &mut Context) {
      FILE_CAN_OPEN.set(true);
      let orders = vec![
          "Build headquarters".to_string(),
          "Fight enemies".to_string(),
          "Conquer the world".to_string(),
      ];
      assert_ok_eq_x!(ctx.sut.spread_orders_by_file("some/path", orders), 3);
  }

We use the same thread-local variable for both test double functions (FILE_CAN_OPEN), because we aren't going to use both functions in the same test. If you feel that this is confusing, you can rename the existing one to FILE_CAN_OPEN_READ and use FILE_CAN_OPEN_WRITE for our new function test double. I kept the same name and changed the function test double to use it.

  pub fn open_write<P: AsRef<Path>>(path: P) -> Result<impl Write, io::Error> {
      if FILE_CAN_OPEN.get() {
          Ok(Cursor::new(Vec::<u8>::new()))
      } else {
          Err(io::Error::new(ErrorKind::Other, "Unable to create file"))
      }
  }

Notice that in the new implementation of this function, I don't need to use the turbofish operator for the error variant, because the compiler can now infer the full type using the other branch of the conditional.

Let's run the tests with cargo t --lib and dance on the streets 💃.

Testing written content

Was this too easy? Well, somehow. But that is because we haven't finished the job. We are indeed testing that the function's return value matches our expectations, indicating that 3 orders have been written. However, we aren't checking that the method wrote what it should. We aren't checking the contents of the file.

That brings us back to the same dilemma we faced in the last two articles: should we let the code write the contents to a "controlled" file and then read it to make assertions? You might, but I won't. We already use the Write implementor as a dependency that gets replaced with a test double, and we just have to check its contents after it is used. Let's see how to do that.

open_write() returns our test double, i.e., the Cursor<Vec<u8>>, but:

  • We don't have access to that function from the tests. It is actually the production code that calls it.
  • More importantly, the cursor is dropped by the end of the method that we are testing.

To address this limitations, we can refactor the method again1. Rather than letting open_write() return the Write test double and losing access to it, we can pass a closure as an additional parameter. This closure takes the Write test double, allowing us to interact with it after the code in the closure has executed, so we can make the needed assertions.

We can create a new function to replace open_write() (initially in production code) with the following signature. This function takes the path and a closure containing the rest of the code of the method we are testing. The closure takes a parameter that implements Write and returns the same type as the original method. The function also returns that type, making the replacement quite easy. Remember to include it in the production code imports.

  pub fn open_write_execute<P, F>(path: P, operations: F) -> Result<usize, io::Error>
  where
      P: AsRef<Path>,
      F: FnOnce(impl Write) -> Result<usize, std::io::Error>,
  { }

We can compile the code with cargo b --lib and realize that this is going to be another of those battles with the compiler. It doesn't like that we use an opaque type (impl Trait) as an argument for anything that isn't a function or a method2. We will have to use dynamic dispatching to please the compiler, and the most obvious choice is to use a Box<T>.

  pub fn open_write_execute<P, F>(path: P, operations: F) -> Result<usize, io::Error>
  where
      P: AsRef<Path>,
      F: FnOnce(Box<dyn Write>) -> Result<usize, std::io::Error>,
  { }

Much better! Let's write the rest of the function. We create the File instance and the BufWriter and put it into a Box<T>, explicitly coerced to the type the closure accepts.

  pub fn open_write_execute<P, F>(path: P, operations: F) -> Result<usize, io::Error>
  where
      P: AsRef<Path>,
      F: FnOnce(Box<dyn Write>) -> Result<usize, std::io::Error>,
  {
      let mut buffer = File::create(path)
            .map(|file| Box::new(BufWriter::new(file)) as Box<dyn Write>)?;
      operations(buffer)
  }

This change allows us to refactor the method that we are testing. This method is functionally identical to its original implementation3.

  pub fn spread_orders_by_file<P: AsRef<Path>>(
      &self,
      path: P,
      orders: Vec<String>,
  ) -> Result<usize, io::Error> {
      open_write_execute(path, |mut buf_orders: Box<dyn Write>| {
          let mut orders_written = 0;
          write!(
              buf_orders,
              "I, {}, as your leader, tell you to:\n",
              &self.full_name()
          )?;

          for order in orders {
              write!(buf_orders, "- {}\n", order)?;
              orders_written += 1;
          }

          Ok(orders_written)
      })
  }

And our production code should compile properly with cargo b --lib. Let's work on the function test double for the tests.

We need to keep a reference to the Cursor while the closure is being executed. Then we can use that reference to extract the written content and store it in a safe place for use in the assertion. We put this other implementation in the tests::doubles module.

The first thing we realize is that Box<dyn Write> won't work either, because we need to send a reference to the closure while keeping another one for the function test double. Rc<T> is a better choice for that, and we have to put the Writer into a RefCell<T> to allow writing to it. After executing the closure and preserving its result for later use, we can use the reference and extract the Cursor and then its contents. We store the contents in a new thread-local variable we haven't defined yet to make them available to the test's assertion.

  pub fn open_write_execute<P, F>(path: P, operations: F) -> Result<usize, io::Error>
  where
      P: AsRef<Path>,
      F: FnOnce(Rc<RefCell<dyn Write>>) -> Result<usize, std::io::Error>,
  {
      if !FILE_CAN_OPEN.get() {
          return Err(io::Error::new(ErrorKind::Other, "Unable to create file"));
      }
      let mut buffer = Rc::new(RefCell::new(Cursor::new(vec![])));
      let mut writer = Rc::clone(&buffer) as Rc<RefCell<dyn Write>>;
      let result = operations(writer);
      let output = Rc::into_inner(buffer)
          .expect("Unexpected empty Rc")
          .take()
          .into_inner();
      BUF_WRITTEN.set(output);

      result
  }

The thread-local variable is also a RefCell<T> containing the vector with the bytes that conform the characters.

    thread_local! {
      // ...
      static BUF_WRITTEN: RefCell<Vec<u8>> = RefCell::new(vec![]);
  }

We have to modify the method being tested to use a closure that takes an Rc<RefCell<dyn Write>> instead of a Box<dyn Write>.

  pub fn spread_orders_by_file<P: AsRef<Path>>(
      &self,
      path: P,
      orders: Vec<String>,
  ) -> Result<usize, io::Error> {
      open_write_execute(path, |mut buf_orders: Rc<RefCell<dyn Write>>| {
          let mut buf_orders = buf_orders.borrow_mut();

As well as the function for the production code.

  pub fn open_write_execute<P, F>(path: P, operations: F) -> Result<usize, io::Error>
  where
      P: AsRef<Path>,
      F: FnOnce(Rc<RefCell<dyn Write>>) -> Result<usize, std::io::Error>,
  {
      let mut buffer = File::create(path)
          .map(|file| Rc::new(RefCell::new(BufWriter::new(file))) as Rc<RefCell<dyn Write>>)?;
      operations(buffer)
  }

At this point, the tests should compile and pass, but we haven't checked that the method wrote what it was meant to. Let's change the test to include that.

  #[test_context(Context)]
  #[test]
  fn spread_orders_by_file_writes_provided_orders(ctx: &mut Context) {
      FILE_CAN_OPEN.set(true);
      let orders = vec![
          "Build headquarters".to_string(),
          "Fight enemies".to_string(),
          "Conquer the world".to_string(),
      ];
      let expected_message = concat!(
          "I, Lex Luthor, as your leader, tell you to:\n",
          "- Build headquarters\n",
          "- Fight enemies\n",
          "- Conquer the world\n"
      );

      assert_ok_eq_x!(ctx.sut.spread_orders_by_file("some/path", orders), 3);
      let actual_message = BUF_WRITTEN.take();
      assert_ok_eq_x!(str::from_utf8(&actual_message), expected_message);
  }

The time has come. Run the tests with cargo t --lib and drop some tears of joy for your success. You can throw away your pens and pencils too because you won't have to write again. Well, forget about that last part.

Final code

The last version of the code in the supervillain.rs file is provided below for your reference. It includes the last version of the two tests and the dependency injection functions, but you can check the whole project and the individual commits, as always, in the repo with all the code in this series.

  //! Module for supervillains and their related stuff
  #![allow(unused)]
  use std::{
      cell::RefCell,
      io::{self, BufRead, Read, Write},
      path::Path,
      rc::Rc,
      time::Duration,
  };
  #[cfg(not(test))]
  use std::{
      fs::File,
      io::{BufReader, BufWriter},
  };
  #[cfg(test)]
  use tests::doubles::File;

  #[cfg(test)]
  use mockall::automock;
  #[cfg(test)]
  use mockall_double::double;
  use rand::Rng;
  use thiserror::Error;

  #[cfg_attr(test, double)]
  use crate::sidekick::Sidekick;
  use crate::{Cipher, Gadget, Henchman};
  #[cfg(not(test))]
  use aux::{open_buf_read, open_write_execute};
  #[cfg(test)]
  use tests::doubles::{open_buf_read, open_write_execute};

  const LISTING_PATH: &str = "tmp/listings.csv";

  /// Type that represents supervillains.
  #[derive(Default)]
  pub struct Supervillain<'a> {
      pub first_name: String,
      pub last_name: String,
      pub sidekick: Option<Sidekick<'a>>,
      pub shared_key: String,
  }

  #[cfg_attr(test, automock)]
  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();
      }

      pub fn tell_plans<C: Cipher>(&self, secret: &str, cipher: &C) {
          if let Some(ref sidekick) = self.sidekick {
              let ciphered_msg = cipher.transform(secret, &self.shared_key);
              sidekick.tell(&ciphered_msg);
          }
      }

      pub fn are_there_vulnerable_locations(&self) -> Option<bool> {
          let mut listing = String::new();
          let Ok(mut file_listing) = File::open(LISTING_PATH) else {
              return None;
          };
          let Ok(n) = file_listing.read_to_string(&mut listing) else {
              return None;
          };

          for line in listing.lines() {
              if line.ends_with("weak") {
                  return Some(true);
              }
          }
          Some(false)
      }

      pub fn are_there_vulnerable_locations_efficient(&self) -> Option<bool> {
          let Some(buf_listing) = open_buf_read(LISTING_PATH) else {
              return None;
          };
          let mut list_iter = buf_listing.lines();
          while let Some(line) = list_iter.next() {
              if let Ok(line) = line
                  && line.ends_with("weak")
              {
                  return Some(true);
              }
          }
          Some(false)
      }

      pub fn spread_orders_by_file<P: AsRef<Path>>(
          &self,
          path: P,
          orders: Vec<String>,
      ) -> Result<usize, io::Error> {
          open_write_execute(path, |mut buf_orders: Rc<RefCell<dyn Write>>| {
              let mut buf_orders = buf_orders.borrow_mut();
              let mut orders_written = 0;
              write!(
                  buf_orders,
                  "I, {}, as your leader, tell you to:\n",
                  &self.full_name()
              )?;

              for order in orders {
                  write!(buf_orders, "- {}\n", order)?;
                  orders_written += 1;
              }

              Ok(orders_written)
          })
      }
  }

  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(),
                  ..Default::default()
              })
          }
      }
  }

  #[derive(Error, Debug)]
  pub enum EvilError {
      #[error("Parse error: purpose='{}', reason='{}'", .purpose, .reason)]
      ParseError { purpose: String, reason: String },
  }

  mod aux {
      use std::{
          cell::RefCell,
          fs::File,
          io::{self, BufRead, BufReader, BufWriter, Write},
          path::Path,
          rc::Rc,
      };

      pub fn open_buf_read(path: &str) -> Option<impl BufRead> {
          let Ok(mut file) = File::open(path) else {
              return None;
          };
          Some(BufReader::new(file))
      }

      pub fn open_write_execute<P, F>(path: P, operations: F) -> Result<usize, io::Error>
      where
          P: AsRef<Path>,
          F: FnOnce(Rc<RefCell<dyn Write>>) -> Result<usize, std::io::Error>,
      {
          let mut buffer = File::create(path)
              .map(|file| Rc::new(RefCell::new(BufWriter::new(file))) as Rc<RefCell<dyn Write>>)?;
          operations(buffer)
      }
  }

  #[cfg(test)]
  mod tests {
      use std::cell::{Cell, RefCell};

      use assertables::{
          assert_err, assert_matches, assert_none, assert_ok_eq_x, assert_some, assert_some_eq_x,
      };
      use mockall::{Sequence, predicate::eq};
      use test_context::{AsyncTestContext, TestContext, test_context};

      use crate::{cipher::MockCipher, gadget::MockGadget, henchman::MockHenchman, test_common};

      use super::*;

      thread_local! {
          static FILE_IF_CAN_OPEN: RefCell<Option<doubles::File>> = RefCell::new(None);
          static FILE_CAN_OPEN: Cell<bool> = Cell::new(false);
          static BUF_CONTENTS: RefCell<String> = RefCell::new(String::new());
          static BUF_WRITTEN: RefCell<Vec<u8>> = RefCell::new(vec![]);
      }

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

          // assert2::check!(ctx.sut.first_name == "A");
          // assert2::assert!(ctx.sut.last_name == "B");
          assert2::check!(ctx.sut.first_name == test_common::SECONDARY_FIRST_NAME);
          assert2::assert!(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 mut weapon = MockMegaweapon::new();
          weapon.expect_shoot().once().return_const(());

          ctx.sut.attack(&weapon, false);
      }

      #[test_context(Context)]
      #[test]
      fn intensive_attack_shoots_weapon_twice_or_more(ctx: &mut Context) {
          let mut weapon = MockMegaweapon::new();
          weapon.expect_shoot().times(2..=3).return_const(());

          ctx.sut.attack(&weapon, true);
      }

      #[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 mock_sidekick = Sidekick::new();
          mock_sidekick.expect_agree().once().return_const(true);
          ctx.sut.sidekick = Some(mock_sidekick);

          ctx.sut.conspire();

          assert_some!(&ctx.sut.sidekick, "Sidekick fired unexpectedly");
      }

      #[test_context(Context)]
      #[test]
      fn fire_sidekick_if_doesnt_agree_with_conspiracy(ctx: &mut Context) {
          let mut mock_sidekick = Sidekick::new();
          mock_sidekick.expect_agree().once().return_const(false);
          ctx.sut.sidekick = Some(mock_sidekick);

          ctx.sut.conspire();

          assert_none!(&ctx.sut.sidekick, "Sidekick not fired unexpectedly");
      }

      #[test_context(Context)]
      #[test]
      fn conspiracy_without_sidekick_doesnt_fail(ctx: &mut Context) {
          ctx.sut.conspire();

          assert_none!(&ctx.sut.sidekick, "Unexpected sidekick");
      }

      #[test_context(Context)]
      #[test]
      fn world_domination_stage1_builds_hq_in_first_weak_target(ctx: &mut Context) {
          let gdummy = MockGadget::new();
          let mut mock_henchman = MockHenchman::new();
          mock_henchman
              .expect_build_secret_hq()
              .with(eq(String::from(test_common::FIRST_TARGET)))
              .return_const(());
          let mut mock_sidekick = Sidekick::new();
          mock_sidekick
              .expect_get_weak_targets()
              .once()
              .returning(|_| test_common::TARGETS.map(String::from).to_vec());
          ctx.sut.sidekick = Some(mock_sidekick);

          ctx.sut
              .start_world_domination_stage1(&mut mock_henchman, &gdummy);
      }

      #[test_context(Context)]
      #[test]
      fn world_domination_stage2_tells_henchman_to_do_hard_things_and_fight_with_enemies(
          ctx: &mut Context,
      ) {
          let mut mock_henchman = MockHenchman::new();
          let mut sequence = Sequence::new();
          mock_henchman
              .expect_fight_enemies()
              .once()
              .in_sequence(&mut sequence)
              .return_const(());
          mock_henchman
              .expect_do_hard_things()
              .once()
              .in_sequence(&mut sequence)
              .return_const(());

          ctx.sut.start_world_domination_stage2(mock_henchman);
      }

      #[test_context(Context)]
      #[test]
      fn tell_plans_sends_ciphered_message(ctx: &mut Context) {
          let mut mock_sidekick = Sidekick::new();
          mock_sidekick
              .expect_tell()
              .with(eq(String::from(test_common::MAIN_CIPHERED_MESSAGE)))
              .once()
              .return_const(());
          ctx.sut.sidekick = Some(mock_sidekick);
          let mut mock_cipher = MockCipher::new();
          mock_cipher
              .expect_transform()
              .returning(|secret, _| String::from("+") + secret + "+");

          ctx.sut
              .tell_plans(test_common::MAIN_SECRET_MESSAGE, &mock_cipher);
      }

      #[test_context(Context)]
      #[test]
      fn vulnerable_locations_with_no_file_returns_none(ctx: &mut Context) {
          FILE_IF_CAN_OPEN.replace(None);
          assert_none!(ctx.sut.are_there_vulnerable_locations());
      }

      #[test_context(Context)]
      #[test]
      fn vulnerable_locations_with_file_reading_error_returns_none(ctx: &mut Context) {
          FILE_IF_CAN_OPEN.replace(Some(doubles::File::new(None)));
          assert_none!(ctx.sut.are_there_vulnerable_locations());
      }

      #[test_context(Context)]
      #[test]
      fn vulnerable_locations_with_weak_returns_true(ctx: &mut Context) {
          FILE_IF_CAN_OPEN.replace(Some(doubles::File::new(Some(String::from(
              r#"Madrid,strong
                 Las Vegas,weak
                 New York,strong"#,
          )))));
          assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations(), true);
      }

      #[test_context(Context)]
      #[test]
      fn vulnerable_locations_without_weak_returns_false(ctx: &mut Context) {
          FILE_IF_CAN_OPEN.replace(Some(doubles::File::new(Some(String::from(
              r#"Madrid,strong
                 Oregon,strong
                 New York,strong"#,
          )))));
          assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations(), false);
      }

      #[test_context(Context)]
      #[test]
      fn efficient_vulnerable_locations_with_no_file_returns_none(ctx: &mut Context) {
          FILE_CAN_OPEN.set(false);
          assert_none!(ctx.sut.are_there_vulnerable_locations_efficient());
      }

      #[test_context(Context)]
      #[test]
      fn efficient_vulnerable_locations_with_weak_returns_true(ctx: &mut Context) {
          FILE_CAN_OPEN.set(true);
          BUF_CONTENTS.replace(String::from(
              r#"Madrid,strong
               Las Vegas,weak
               New York,strong"#,
          ));
          assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations_efficient(), true);
      }

      #[test_context(Context)]
      #[test]
      fn efficient_vulnerable_locations_without_weak_returns_false(ctx: &mut Context) {
          FILE_CAN_OPEN.set(true);
          BUF_CONTENTS.replace(String::from(
              r#"Madrid,strong
               Oregon,strong
               New York,strong"#,
          ));
          assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations_efficient(), false);
      }

      #[test_context(Context)]
      #[test]
      fn spread_orders_by_file_returns_error_on_failure(ctx: &mut Context) {
          FILE_CAN_OPEN.set(false);
          assert_err!(ctx.sut.spread_orders_by_file("some/path", vec![]));
      }

      #[test_context(Context)]
      #[test]
      fn spread_orders_by_file_writes_provided_orders(ctx: &mut Context) {
          FILE_CAN_OPEN.set(true);
          let orders = vec![
              "Build headquarters".to_string(),
              "Fight enemies".to_string(),
              "Conquer the world".to_string(),
          ];
          let expected_message = concat!(
              "I, Lex Luthor, as your leader, tell you to:\n",
              "- Build headquarters\n",
              "- Fight enemies\n",
              "- Conquer the world\n"
          );

          assert_ok_eq_x!(ctx.sut.spread_orders_by_file("some/path", orders), 3);
          let actual_message = BUF_WRITTEN.take();
          assert_ok_eq_x!(str::from_utf8(&actual_message), expected_message);
      }

      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) {}
      }

      pub mod doubles {
          use std::{
              io::{self, Cursor, Error, ErrorKind, Write},
              path::Path,
              rc::Rc,
          };

          use super::*;

          pub struct File {
              read_result: Option<String>,
          }

          impl File {
              pub fn new(read_result: Option<String>) -> File {
                  File { read_result }
              }

              pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
                  if let Some(file) = FILE_IF_CAN_OPEN.take() {
                      Ok(file)
                  } else {
                      Err(Error::from(ErrorKind::NotFound))
                  }
              }
              pub fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> {
                  if let Some(ref content) = self.read_result {
                      ,*buf = content.to_owned();
                      Ok(buf.len())
                  } else {
                      Err(Error::from(ErrorKind::Other))
                  }
              }
          }

          pub fn open_buf_read(path: &str) -> Option<impl BufRead> {
              if FILE_CAN_OPEN.get() {
                  Some(Cursor::new(BUF_CONTENTS.take()))
              } else {
                  None
              }
          }

          pub fn open_write_execute<P, F>(path: P, operations: F) -> Result<usize, io::Error>
          where
              P: AsRef<Path>,
              F: FnOnce(Rc<RefCell<dyn Write>>) -> Result<usize, std::io::Error>,
          {
              if !FILE_CAN_OPEN.get() {
                  return Err(io::Error::new(ErrorKind::Other, "Unable to create file"));
              }
              let mut buffer = Rc::new(RefCell::new(Cursor::new(vec![])));
              let mut writer = Rc::clone(&buffer) as Rc<RefCell<dyn Write>>;
              let result = operations(writer);
              let output = Rc::into_inner(buffer)
                  .expect("Unexpected empty Rc")
                  .take()
                  .into_inner();
              BUF_WRITTEN.set(output);

              result
          }
      }
  }

Summary

I have explained how to create a dependency injection point and preserve the data modified in the test doubles so we can make assertions. I have used this technique to replace a BufWriter and avoid performing any actual I/O in the tests, while improving testability and preserving the value of the tests themselves.

In implementing these two tests, I have also addressed some of Rust's restrictions on what is valid code and developed solutions that achieve the goals while maintaining memory safety.

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

Footnotes


1

This time, with some tests in place, yet not full coverage.

2

In case you wonder, a function pointer won't do either.

3

That is what "refactoring" should be.