Async test setup and teardown in Rust

Published - 4 min read

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.

rust
#[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.

rust
#[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:

sh
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:

sh
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!

rust
#[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:

sh
`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:

rust
#[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.

rust
#[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.

rust
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:

rust

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!