Writing ergonomic async assertions in Rust

April 22 2024



A pattern I encounter when testing asynchronous code is waiting for a certain condition to become true. This is done with tokio::time::sleep set to a reasonable duration or tokio::time::timeout.

With the former, conditions are checked after the duration has elapsed but tests might run for longer than they need to if the value flips early in the sleep. The duration could be reduced but test suites need to run on different machines, from local dev to CI pipelines, and the run time could vary enough to cause breakage. Thus, it becomes a tradeoff between run time and brittleness.

With the latter, conditions only run until they become true or time out. Unfortunately, they must be wrapped in a future, synchronous conditions won’t compile. This is problematic if the condition checks asynchronous state through a synchronous call. A typical example would be verifying a value appears in an Arced and RwLocked collection. The execution is async but the read is synchronous.[1]

In order to improve my experience with async testing, I published deadline. The crate’s single macro combines timeout and std::assert under the hood by wrapping the condition closure in a future and improves panic messaging. Here it is used to check an atomic integer eventually gets incremented.

#[tokio::test]
async fn it_waits_until_true() {
    let x = Arc::new(AtomicI32::new(1));
    let y = 2;

    let x_clone = x.clone();
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(5)).await;
        x_clone.fetch_add(1, Ordering::SeqCst);
    });

    deadline!(Duration::from_millis(10), move || {
        x.load(Ordering::Relaxed) == y
    });
}

The value needs to be moved as future is created, though in an async context a lot of structures are Arced, making clones cheap.[2] The condition must be synchronous (otherwise timeout is your friend) and it needs to evaluate to a bool. If the condition isn’t met within the duration, the macro panics. Crucially, using futures ensures the assertion is non-blocking to the async runtime.



Footnotes:

  1. The read is synchronous unless async locks are used although these are rarely a superior choice to parking_lot’s, in my opinion.
  2. If the type is Copy, the operation will be completely transparent, making the ergonomics even better.

Further reading:

  1. Check out the implementation on Github: https://github.com/niklaslong/deadline.