Async test setup and teardown in Rust
Rust is a modern language and it shows by having its test framework out of the box. However, the test framework is pretty basic and does not provide us with setup/teardown functions or before_each/after_each hooks. Fortunately, Rust is powerful and extensible enough that we can implement this without adding another library.
This synchronous setup and teardown snippet below is taken from the wonderful blog post by Eric Opines.
#[test] fn test_something_interesting() { run_test(|| { let true_or_false = do_the_test(); assert!(true_or_false); }) } fn run_test<T>(test: T) -> () where T: FnOnce() -> () + std::panic::UnwindSafe { setup(); let result = std::panic::catch_unwind(|| { test() }); teardown(); assert!(result.is_ok()) }
The snippet works with having the run_test
function take in a closure test
which performs the test instructions. If any panic occurs, panic::catch_unwind
will capture it, which will allow the teardown
function to proceed and the panic will be reported as an error in the Rust test.
Adapting for async/await
Async engines like tokio, async-std, and actix-web come with annotations like tokio::test
, async-std
and actix_rt::test
to help with async testing, so let’s try that.
#[actix_rt::test] fn test_something_interesting() { run_test(|| { let true_or_false = do_the_test().await; assert!(true_or_false); }) }
Immediately, we hit our first roadblock when compiling:
error[E0728]: `await` is only allowed inside `async` functions and blocks --> tests/test.rs:59:9 | 58 | run_test(|| { | -- this is not `async` 59 | do_the_test().await; | ^^^^^^^^^^^^^^^^^^^^ only allowed inside`async` functions and blocks
Rust is complaining that we are using the await keyword in a non-async function, which is our test
closure. To remedy that, let’s try making the closure async:
error[E0658]: async closures are unstable --> tests/temp.rs:58:14 | 58 | run_test(async || -> () { | ^^^^^
Unfortunately, the compiler warns us that async closures are unstable. Let’s try making it an async block instead!
#[actix_rt::test] async fn test_something_interesting() { run_test(async { let true_or_false = do_the_test().await; assert!(true_or_false); }) } fn run_test<T>(test: T) -> () where T: std::future::Future + std::panic::UnwindSafe { setup(); let result = std::panic::catch_unwind(|| { test() }); teardown(); assert!(result.is_ok()) }
This works for some tests, but when we try to integrate something more complicated, we may get errors like the following:
`UnsafeCell<AtomicUsize>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
We hit another roadblock. This time, our inner mechanisms in do_the_test
cannot cross the catch_unwind
boundary.
Unwrapping runtime macros
To fix this issue, we must understand what our annotations like tokio::test
, async-std
, and actix_rt::test
are doing.
All async/await systems require a runtime to process the asynchronous tasks. These runtimes are abstracted away behind the test annotations. Let’s unwrap what actix_rt::test
is doing behind the scenes:
#[test] fn test_something_interesting() { actix_rt::System::new().block_on( run_test(async { let true_or_false = do_the_test().await; assert!(true_or_false); }) }) }
So it seems like actix_rt
is instantiating a runtime and blocking on the entire async test we wrote. Both async_std
and tokio
work on similar mechanisms. This is helpful because we can do the same thing within the catch_unwind
boundary and by doing so, we no longer violate the previous constraints.
#[test] fn test_something_interesting() { run_test(async { let true_or_false = do_the_test().await; assert!(true_or_false); }) } fn run_test<T>(test: T) -> () where T: std::future::Future + std::panic::UnwindSafe { setup(); let result = std::panic::catch_unwind(|| { actix_rt::System::new().block_on(test); }); teardown(); assert!(result.is_ok()) }
Running cargo test
now, we get … no errors!
Extending the tests
The above snippet is the simplest example of an async test with setup and teardown. If you wanted to run async setup and teardown functions, you can simply wrap them in more async runtime block_on
functions.
fn run_test<T>(test: T) -> () where T: std::future::Future + std::panic::UnwindSafe { actix_rt::System::new().block_on(async { setup().await }); let result = std::panic::catch_unwind(|| { actix_rt::System::new().block_on(test); }); actix_rt::System::new().block_on(async { teardown().await }); assert!(result.is_ok()) }
You can also pass objects by changing the async block to a closure that returns an async block:
fn run_test<T>(test: T) -> () where T: FnOnce(PgPool) -> std::future::Future + std::panic::UnwindSafe { setup(); let result = std::panic::catch_unwind(|| { actix_rt::System::new().block_on(async { let db_pool = db_init().await; test(db_pool).await }) }); teardown(); assert!(result.is_ok()); }
Wrapping Up
So adding setup and teardown to an async test isn’t easy or clean, but understanding how async runtimes work does make the challenge a whole lot easier. I hope this guide was useful in understanding and running your async tests!