---
title: 'How to Get Error Locations with `?` in Rust Tests'
authors: ['jonas']
date: 2025-10-16T13:00:00
tags: ['technical', 'rust']
summary: "How to avoid the get good concise error location information on test failure using Rust's try operator in tests."
---

![Cover image](./cover.png)

<div className="lead">
  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?{' '}
  <a href="https://www.svix.com">Give it a try!</a>
</div>

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:

```text
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

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

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

```text
test test_anyhow ... FAILED

failures:

---- test_anyhow stdout ----
Error: oh no!
```

Some libraries like [`anyhow`](https://docs.rs/anyhow/latest/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]`][tc], 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:

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

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

```rust
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`][anyhow_context] trait instead.

[tc]: https://doc.rust-lang.org/reference/attributes/codegen.html#the-track_caller-attribute
[anyhow_context]: https://docs.rs/anyhow/latest/anyhow/trait.Context.html

## 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][reddit].

[reddit]: https://www.reddit.com/r/rust/comments/1o86uh0/how_to_get_error_locations_with_in_tests_svix_blog/

---

For more content like this, make sure to follow us on [Twitter](https://twitter.com/SvixHQ), [Mastodon](https://mastodon.social/@svixhq), [Github](https://github.com/svix), [RSS](https://www.svix.com/blog/rss/), or [our newsletter](https://www.svix.com/newsletter/) for the latest updates for the [Svix webhook service](https://www.svix.com), or join the discussion on [our community Slack](https://www.svix.com/slack/).
