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 Arc
ed and RwLock
ed 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 Arc
ed, 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:
Further reading: