Contents

Rust unit testing: asynchronous code

The art of awaiting the expected results

So far, we have only tested synchronous code. I could postpone talking about asynchronous code, but I don't want you to wait for it. ๐Ÿ˜„

Asynchronous code, apart from being an inexhaustible source of dad jokes, can be painful to reason about its behavior and not always recommended. We tend to think that, in a given scope, the lines above are executed before the lines below. It really takes experience and discipline to realize that some parts of the code can be executed at a later time and that we may not have yet their results available. This is important when we write our asynchronous code, but even more when we debug it .

Executing asynchronous code in tests

As I have done in previous articles, I am going to introduce code that we would like to test first, and then explain how to test it. We know that a plan is something that has to be thought out and supervillains take their time to come up with their malevolent plans. We can implement this functionality in our Supervillain and test it.

Although, async/await is part of the language, you need to have a runtime to be able to execute async functions. The designers of this functionality of the Rust language decided to separate mechanism and policy, and decided to define the Future trait in the standard library, while allowing developers to use the runtime that matches their needs for each project. It is fair to say that tokio is the de facto standard, yet there are others that focus on other aspects like being lightweight (smol) or available for embedded systems (embassy.)

We will be using Tokio for all the async code of this series and we need to add it as a dependency to our project. We will enable three features initially: macros, time, and rt.

  cargo add tokio -F macros,time,rt

We can now add async code to our project. So, let's add the method come_up_with_plan to the implementation block of the Supervillain.

  pub async fn come_up_with_plan(&self) -> String {
      tokio::time::sleep(Duration::from_millis(100)).await;
      String::from("Take over the world!")
  }

The first challenge that we must address to be able to test asynchronous code is to be able to execute it in our tests. Probably you have already read or heard about the function coloring problem, but if you haven't, the TLDR is that it is hard to mix async and non-async functions and, in our case, this results in needing an async context to execute async functions. While this would seem like a trivial problem, it becomes a stopper right away.

Our naive solution for testing that method is going to fail miserably.

  #[test_context(Context)]
  #[test]
  fn plan_is_sadly_expected(ctx: &mut Context) {
      assert_eq!(ctx.sut.come_up_with_plan().await, "Take over the world!");
  }

If we run cargo t, it complains that the test function "is not async", and that the await "only allowed inside `async` functions and blocks". And even though, that sounds like a catch-22, it is exactly what the Tokio crate provides us with. Let's use it.

  #[tokio::test]
  async fn plan_is_sadly_expected(ctx: &mut Context) {

We have an async test function that allows us to test async functions. However, this would only be enough if we weren't using a context in our tests, but using it is an application of the Don't Repeat Yourself principle and helps us to simplify our code.

(Async)Context for async tests

We might be tempted to use the test_context macro as it was, but when we provide a Context to a tokio::test, it must implement AsyncTestContext. This trait is very similar to TestContext, but setup() and teardown() must be asynchronous. We can implement that trait for Context.

  impl AsyncTestContext for Context {
      async fn setup() -> Context {
          Context {
              sut: Supervillain {
                  first_name: test_common::PRIMARY_FIRST_NAME.to_string(),
                  last_name: test_common::PRIMARY_LAST_NAME.to_string(),
              },
          }
      }

      async fn teardown(self) {}
  }

Since Rust 1.75 traits support async functions/methods in the stable version. But, if for any reason, you are using an older version of the toolchain, you can add the async-trait crate.

  cargo add async-trait

and use the macro #[async_trait::async_trait] on the trait.

However, that still doesn't work. A superficial inspection of the error ("error[E0119]: conflicting implementations of trait `TestContext` for type `supervillain::tests::Context`") may suggest that problem is that the two traits use the same names for the associated function and the method, and they clashing causing a conflict. But, quoting the Rust Book:

Nothing in Rust prevents a trait from having a method with the same name as another traitโ€™s method, nor does Rust prevent you from implementing both traits on one type. Itโ€™s also possible to implement a method directly on the type with the same name as methods from traits.

Wrong suspect then! ๐Ÿ˜… Let's read the note with care:

conflicting implementation in crate `test_context`:

  • impl<T> TestContext for T where T: AsyncTestContext, T: std::marker::Send;

and also the help for error E0119. Then we realize that the test-context crate implements the synchronous trait automatically if we provide the asynchronous implementation. So, what we have to do is delete the implementation of TestContext.

We can run cargo t and get all the tests to pass. Hooray!

Final code

We have added some new code to supervillain.rs file. You can find the code in the corresponding commit of the repo with all the code in this series or read it here with all the changes introduced in this article.

  #![allow(unused)]
  use std::time::Duration;

  use thiserror::Error;

  pub struct Supervillain {
      pub first_name: String,
      pub last_name: String,
  }

  pub trait Megaweapon {
      fn shoot(&self);
  }

  impl Supervillain {
      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<_>>();
          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!")
      }
  }

  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(),
              })
          }
      }
  }

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

      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 {
          sut: Supervillain,
      }

      impl AsyncTestContext for Context {
          async fn setup() -> Context {
              Context {
                  sut: Supervillain {
                      first_name: test_common::PRIMARY_FIRST_NAME.to_string(),
                      last_name: test_common::PRIMARY_LAST_NAME.to_string(),
                  },
              }
          }

          async fn teardown(self) {}
      }
  }

Summary

Once again, dealing with asynchronicity proves to be harder than you initially thought it would. However, once you know what has to be done, testing basic async code isn't that difficult. There is more to cover about asynchronous code that interacts with other asynchronous parts of the codebase, but we will get into those in future articles. Await publication soon! ๐Ÿ˜‚

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