Two easy ways to test async functions in Rust
What you need to know
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:
- A runtime (such as tokio)
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.