Misusing v2 `Try` and `?` to make unit tests nicer

Hi all, possibly crazy idea here, but:

Could we (mis)use the new v2 Try trait to improve the ergonomics of unit tests that involve fallible code? Currently it's rather annoying, and there seem to be two main approaches that both have drawbacks:

Approach 1 - lots of unwrap and/or expect:

#[test]
fn my_test() {
    let arg = might_fail().unwrap();
    let value = also_might_fail(arg).unwrap();
    let final_result = again_might_fail(value).unwrap();
    assert_equal!(final_result, 10);
}

Approach 2 - lots of ? and returns Result:

#[test]
fn my_test() -> Result<(), Box<dyn Error>> {
    let arg = might_fail()?;
    let value = also_might_fail(arg)?;
    let final_result = again_might_fail(value)?;
    assert_equal!(final_result, 10);
    Ok(())
}

Approach 1 is nice because unit test failures are easily traced back to the line of code that failed (possibly needs backtracing enabled). Not nice, because all those unwrap calls bloat the code and make it ugly.

Approach 2 is the opposite -- much cleaner (normal looking) code, but it needs to return a Result now and thus loses the backtrace. All you know is the error type, not where it came from. Doing tricks with map_err could help localize the error better, but that's even more bloated than unwrap.

So here's the crazy idea: Only under #[cfg(test)], provide an impl Try for (), with a corresponding impl<E: Debug> FromResidual<Result<Infallible, E>> for () whose from_residual panics just like Result::unwrap would. This playground example, using a FakeUnit newtype as a proxy for () to avoid triggering orphan rules, suggests the standard library could do it.

Doing so would allow the best of both worlds in a unit test -- unit return but able to use ?:

#[test]
fn my_test() {
    let arg = might_fail()?;
    let value = also_might_fail(arg)?;
    let final_result = again_might_fail(value)?;
    assert_equal!(final_result, 10);
}

Potentially useful way to improve unit test ergonomics? Or just plain crazy/silly/evil?

4 Likes

Small suggestion: Adding #[track_caller] to from_residual makes the output print the file+line number where the ? is. That way you don't even have to run it with RUST_BACKTRACE=1 (with a descriptive panic message that shouldn't be problem).

2 Likes

Wouldn't using anyhow::Error as error type work? It should capture backtraces at creation time. Also I think you can have something like:

enum PanicError {}

impl<T: Error> From<T> for PanicError {
    fn from(e: T) {
        panic!("test failed with: {e}");
    }
}

and then use Result<(), PanicError> as return type.

6 Likes

This part isn't going to happen, especially since IIRC that cfg isn't set in dependencies, which is where the impl would need to exist.

That said, something like this could work, allowing you to ? both Options and Results in the test:

#[test]
fn demo1() -> QuestionMarkMeansUnwrap { try {
    let x: i32 = 1234;
    let y: u8 = x.try_into()?;
    assert_eq!(y, 2);
    
    let a = [1, 2];
    let b = a.get(2)?;
    assert_eq!(*b, 2);
}}

https://play.rust-lang.org/?version=nightly&mode=debug&edition=2024&gist=fbb9d9f9c913932c95e7478a69030fd9

1 Like