Table of Contents#
Open Table of Contents
Diving into it#
Consider this test:
#[test]
fn login_works() -> Result<(), Box<dyn std::error::Error>> {
let world = TestableWorld::init();
let response: Session = world.login("person@example.com", "hunter2")?;
assert!(response.has_valid_token());
Ok(())
}
And this function on a test helper, TestableWorld
:
impl TestableWorld {
pub fn login(
&self,
email: impl AsRef<str>,
password: impl AsRef<str>,
) -> Result<AuthenticatedSession, BoxError> {
let login = serde_json::json!({
"type": "login",
"data": {
"email": email.as_ref(),
"password": password.as_ref()
}
});
self.client.login(login)?.json()
}
}
If you run this and the request fails, it’ll point you to the client’s login
function or the json
on the response in TestableWorld.login
. But, it won’t tell you about the part you care about: your test which is using it, login_works
.
You can change that by annotating TestableWorld.login
with #[track_caller]
:
#[track_caller]
pub fn login(
&self,
email: impl AsRef<str>,
password: impl AsRef<str>,
) -> Result<AuthenticatedSession, BoxError> {
And that’ll make sure when your tests fail, it points to the test and not the helper.
Problems with async#
Unfortunately, if you’ve got an async fn
, this doesn’t work. It’ll compile, but you’ll trigger a warning message that says it isn’t doing anything:
#[track_caller] on async functions is a no-op
see issue #110011 https://github.com/rust-lang/rust/issues/110011 for more information
#[warn(ungated_async_fn_track_caller)] on by default (rustc ungated_async_fn_track_caller)
Why? Because rust is “desugaring” async
/ await
at compile time. What that means is that the compiler is generating different structures out of them before the code is run.
Specifically, they get transformed into state machines. These are super useful code structures that let you avoid a lot of complicated design problems, if you use them. They form the backbone of rust’s whole async setup.
We don’t need to do a deep dive on them right now, but it’s important to know that state machines are more elaborate than single functions. That means that when we tell the compiler to #[track_caller]
on an async function, there’s too much ambiguity in the state machine to know where it should best sit, and who its caller even is. The compiler just doesn’t know where to put it.
The async workaround#
So is there anything we can do? Yes! Definitely. It involves knowing some of the inner workings of async to come up with, but, luckily, not to crib from some random dude’s blog.
The trick is this: we quit using async, and instead “drop down a level” and use the future trait:
impl TestableWorld {
#[track_caller]
pub fn login(
&self,
email: impl AsRef<str>,
password: impl AsRef<str>,
) -> impl Future<Output = Result<AuthenticatedSession, BoxError>> {
let login = serde_json::json!({
"type": "login",
"data": {
"email": email.as_ref(),
"password": password.as_ref()
}
});
// Now we can wait on stuff in here.
async move {
self.client.login(login).await?.json()
}
}
}
It’s almost the same code, but we’ve changed two things:
- We’ve taken the
Result<AuthenticatedSession, BoxError>
and wrapped it in aFuture
trait, turning it intoimpl Future<Output = Result<AuthenticatedSession, BoxError>>
. - We’ve wrapped our return type in an
async move {}
block.
This is the same outcome as our async function, but the structure is different. The function itself doesn’t get desugared into a state machine on this one. Instead, the async move
block does.
That leaves us with a firm place for the compiler to add its #[track_caller]
behavior, so it doesn’t complain anymore.
The drawback#
This approach is mostly fine but not entirely. It’s a closure, which has its own type signature apart from the code around it. That means it leaves some type information, which is defined firmly in the function, on the floor.
In practical terms, this means that sometimes you need to write notation because the compiler can’t infer it. In practice, for me, this hasn’t been much of an issue in my test types. Your mileage may vary.