Claus

6 minute read

One reason to like the Rust ecosystem is testing. No test runners need to be installed, no reading up on 10 variations of unit testing frameworks, no compatibiltity issues…

… or are there? Rust has recently started supporting full async/await abilities after years of development and it looks like there is a missing piece there: tests. How so?

Let’s back up a bit

In order to work with async code, there are two things you need:

  1. A runtime (such as tokio)
  2. async functions

The latter is fully under your control and requires a few syntactical changes (if you are already comfortable with the async paradigm). So what about the runtime? Picking the runtime - for example in Scala - is done automagically and just works - but this is not what the Rust team wanted. As with many aspects of Rust (e.g. memory allocation), the focus is on being able to fine-tune the technology that is powering your program; something that is especially valuable for embedded programming.

tokio is the most popular runtime it seems and it powers several well-known frameworks. Some frameworks even come with the runtime built-in (like actix-web). All of this is reasonably simple and with their examples you can be on your way quickly.

What about tests

Testing is an essential part of software engineering (as opposed to programming/scripting/…) in that the developer wants to make sure that whatever he/she writes will be readable in the future for maintenance, extension, or admiring the general beauty of code. Typically the setting up of easy-to-use testing is where many projects fall short, resulting in a lack of structured testing…

That brings us to the topic at hand: how do you test your async functions? Rust’s built-in tests don’t come with a runtime (yet?), so if you start calling your async functions in your regular tests, things will get tricky. How would the compiler know which thread a particular async part is supposed to run on? When does the result come in? Should it wait on the result?

Let’s work with an example:


fn str_len(s: &str) -> usize {
  s.len()
}

async fn str_len_async(s: &str) -> usize {
  // do something awaitable ideally... 
  s.len()
}

#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
  use super::*;

  #[test]
  fn test_str_len() {
    assert_eq!(str_len("x5ff"), 4);
  }
}

If str_len() is a regular function, no problems will occur. However str_len_async() is async and we can’t test like the sync counterpart. Instead we’ll have to think about how best to unwrap that Future type that the function returns:

$ cargo test
   Compiling async-testing v0.1.0 (/private/tmp/async-testing)
error[E0369]: binary operation `==` cannot be applied to type `impl std::future::Future`
  --> src/lib.rs:23:9
   |
23 |         assert_eq!(str_len_async("x5ff"), 4);
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |         |
   |         impl std::future::Future
   |         {integer}
   |
   = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0277]: `impl std::future::Future` doesn't implement `std::fmt::Debug`
  --> src/lib.rs:23:9
   |
23 |         assert_eq!(str_len_async("x5ff"), 4);
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `impl std::future::Future` cannot be formatted using `{:?}` because it doesn't implement `std::fmt::Debug`
   |
   = help: the trait `std::fmt::Debug` is not implemented for `impl std::future::Future`
   = note: required because of the requirements on the impl of `std::fmt::Debug` for `&impl std::future::Future`
   = note: required by `std::fmt::Debug::fmt`
   = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)

error: aborting due to 2 previous errors

Some errors have detailed explanations: E0277, E0369.
For more information about an error, try `rustc --explain E0277`.
error: could not compile `async-testing`.

To learn more, run the command again with --verbose.

If you didn’t skip the introduction, then you already know why and what to do about it: add a runtime.

Two ways to do async testing

There are at least two ways to run an async test and it really depends on your preference and how picky you are with (unnecessary) dependencies 😁. Also – bear in mind that Rust’s testing is multi-threaded by default, so async runtimes have limited use here compared to a regular program.

1. Use your framework’s testing support

Actix comes with a boatload of examples and one of them features async testing as well. As part of their design they are using an additional attribute on top of the function to assign it to a runtime that runs the test just like anything else async.

// ... 
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
  use super::*;
  
  #[test]
  fn test_str_len() {
    assert_eq!(str_len("x5ff"), 4);
  }

  #[actix_rt::test]
  async fn test_str_len_async() {
    assert_eq!(str_len_async("x5ff").await, 4);
  }
}

This allows for using the async function just as you would normally but requires actix-rt as a dependency:

[dependencies]
actix-rt = "*"

For those non-web projects there is another way: using a dedicated testing runtime.

2. Use a testing runtime

If you want to use a different framework or work on a different use case from web servers, there is another way. tokio-test provides a testing runtime. This runtime provides a function to “reverse” what async functions do and block the execution until the async function has returned. The function is called tokio_test::block_on() (docs) and it blocks the current thread until the Future is finished executing.

In order to make things a little less typing-heavy, I suggest creating a macro like this (aw is short for await):

macro_rules! aw {
  ($e:expr) => {
      tokio_test::block_on($e)
  };
}

If we add this test to the example above:

// ... 
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
  use super::*;
  
  #[test]
  fn test_str_len() {
    assert_eq!(str_len("x5ff"), 4);
  }

  // ... the other async test

  macro_rules! aw {
    ($e:expr) => {
        tokio_test::block_on($e)
    };
  }

  #[test]
  fn test_str_len_async_2() {
    assert_eq!(aw!(str_len_async("x5ff")), 4);
  }
}

Obviously the tokio-test crate has to be added to your list of dependencies, but it can go into the dev-dependencies section where it won’t add another layer of dependencies:

[dev-dependencies]
tokio-test = "*"

So now you can close the testing loop quickly and write well-tested software.

Done?

Here’s what you should see:

$ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.17s
     Running target/debug/deps/async_testing-7d7ff38dac475e0e

running 3 tests
test tests::test_str_len ... ok
test tests::test_str_len_async_2 ... ok
test tests::test_str_len_async ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests async-testing

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

If not, go to the GitHub repo to see the entire code.

One last thing: if you thought this was valuable, pay it foward by sharing it with others who profit from this.

Thank you.