- Published on
How to Get Error Locations with `?` in Rust Tests
- Authors
- Name
- Jonas Platte
Svix is the enterprise ready webhooks sending service. With Svix, you can build a secure, reliable, and scalable webhook platform in minutes. Looking to send webhooks? Give it a try!
Given a large codebase like ours at Svix, one often ends up also having to write a lot of end-to-end test code that does various forms of I/O. Due to the fallible nature of I/O and Rust's (great!) choice to make error handling explicit, the testing code can end up being quite verbose.
The baseline
When learning Rust, it usually doesn't take long to encounter .unwrap()
(perhaps the most approachable form of error handling), which returns the T
inside Option<T>
or Result<T, E>
, or panics if it's called on Option::None
or Result::Err
, generally unwinding the stack and printing the error location plus message (in the Result
case).
This is what we used in our tests up until recently. The big upside is that it provides nice error messages when a test fails:
test test_unwrap ... FAILED
failures:
---- test_unwrap stdout ----
thread 'test_unwrap' panicked at src/main.rs:7:28:
called `Result::unwrap()` on an `Err` value: "oh no!"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
The big downside to this is that it's quite verbose, and a little annoying to type. Where an exception-based language would let you write something like
let response = await api_client.get("/v1/test/thing");
let value = await response.expect_status(200).json();
in our tests we would end up something closer to
let value = api_client
.get("/v1/test/thing")
.await
.unwrap()
.expect_status(200)
.json()
.await
.unwrap();
An incomplete fix
Outside of tests, it's common to "bubble up" errors using the ?
(try) operator. This is also possible in tests, by making the test return Result<(), E>
. If an Err
is returned, the test is considered to have failed, and the E
value is printed.
The issue with this is that the error value does not usually carry location information:
test test_anyhow ... FAILED
failures:
---- test_anyhow stdout ----
Error: oh no!
Some libraries like anyhow
that provide a convenient "any error" type also optionally support capturing a full backtrace, but this has two problems:
- It's very verbose. On my Linux system,
anyhow::bail!
in a test generates a 26 frames deep backtrace. This could be worked around with custom backtrace printing code, if it wasn't for the second problem: - The exact error location is only part of the backtrace if full debug info is built into the test binary - at Svix we reduced this to save on compile time and disk space
#[track_caller]
to the rescue
So how is a function like Result::unwrap()
able to print the error location even without debug info? The answer is #[track_caller]
, a built-in attribute that changes a function's ABI to a Rust-specific one that passes through the call site as a hidden extra parameter which is used by the panic!
macro and anything that indirectly invokes it (like assert!
or unreachable!
).
We can use this attribute to create an error type that can track where it was instantiated:
pub struct TestError {
// ...
}
impl<T> From<T> for TestError {
#[track_caller]
fn from(value: T) -> Self {
// If we panic here, the source location will be the start of the expression that invoked
// this impl, even if it was indirect, i.e. via the desugaring of the `?` operator.
//
// We can also use `std::panic::Location::caller()` to obtain the caller information for
// later use.
todo!()
}
}
As noted in the comment above, there are two ways to go from here: panic as part of the conversion, or obtain the caller information for later use (i.e. printing). It may seem unorthodox, but we actually went for the first solution: Whenever this From
implementation is called, we simply panic and let the standard panic hook take care of printing the error, its location, and optionally a backtrace (if enabled via RUST_BACKTRACE
).
This is the actual definition we use at the time of writing:
#[derive(Debug)] // needed to be able to return TestResult from #[test] fns
pub enum TestError {}
// If the bound is just `T: Debug`, this impl conflicts with the blanket
// `impl From<T> for T` in the standard library.
//
// Can't use `T: std::error::Error` either because anyhow doesn't implement
// `std::error::Error` (also to avoid a conflicting From impl).
//
// Using `Display + Debug`, even though we only use `Debug`, works around this.
impl<T: Display + Debug> From<T> for TestError {
#[track_caller]
fn from(value: T) -> Self {
panic!("error: {value:?}")
}
}
This is then paired with a type alias for making it maximally convenient to use:
pub type TestResult = Result<(), TestError>;
With this, we can use ?
on virtually any Result
-returning function in our tests, and if an Err
is ever encountered, we get a nice error message with the Debug
-printed error value as well as the location in the test where the error was produced. Notably, we don't use TestResult
in any test helper functions, because generally it's most useful to know which part of the test function itself failed, rather than which part of a function it called. There, we use anyhow::Result
and if some extra error context is needed, its Context
trait instead.
Conclusion
This pattern is not entirely new, I first learned about it on social media a year or two ago. Still, I haven't seen it highlighted as much as it deserves to be, and I think most Rust user aren't aware of it at all.
Have you seen this pattern before? Are there other tricks related to test failure messages we should know about? Leave your comments on the reddit post for this article.
For more content like this, make sure to follow us on Twitter, Github, RSS, or our newsletter for the latest updates for the Svix webhook service, or join the discussion on our community Slack.