Testing Rust: Compile-time Helper Functions

This is part of a series of posts on Testing in Rust. As part of my journey learning this language, I wanted to understand what kinds of techniques people are using to solve the standard set of testing problems - setting up fixtures, testing corner cases, injecting test doubles, etc - but in Rust.

In this installment I’m going to write about compile-time test helper functions.

A real example

Here’s a test that caught my eye from tests/all.rs in the futures project.

// From https://github.com/rust-lang-nursery/futures-rs/blob/master/tests/all.rs#L21
fn result_smoke() {
    fn is_future_v<A, B, C>(_: C)
        where A: Send + 'static,
              B: Send + 'static,
              C: Future<Item=A, Error=B>

    is_future_v::<i32, u32, _>(f_ok(1).map(|a| a + 1));
    is_future_v::<i32, u32, _>(f_ok(1).map_err(|a| a + 1));
    is_future_v::<i32, u32, _>(f_ok(1).and_then(Ok));
    is_future_v::<i32, u32, _>(f_ok(1).or_else(Err));
    is_future_v::<(i32, i32), u32, _>(f_ok(1).join(Err(3)));
    is_future_v::<i32, u32, _>(f_ok(1).map(f_ok).flatten());
    // ... other testing continues

I don’t know what the proper term is for the role is_future_v plays, so for now I am calling this type of helper a compile-time test helper function - because it has no implementation and thus its effect is made at compile-time rather than when you run the tests.

But what is going on here? A whole lot of things actually, a whole lot.


Let’s analyse the first invocation of is_future_v:

is_future_v::<i32, u32, _>(f_ok(1).map(|a| a + 1));

Specifying the i32 and u32 primitive types inserts them as generic type parameters A and B in the call to is_future_v.

The code would not compile if i32 and u32 did not comply with the trait bounds specified in the is_future_v function signature:

where A: Send + 'static,
      B: Send + 'static,

This says that both A and B (and thus i32 and u32) must implement the Send marker trait, which is implemented for types that can be safely transferred across thread boundaries.

It also specifies that the actual values passed must have the 'static lifetime. That is, they must live throughout the entire execution of the program itself.

<i32, u32, _>

Type parameter C is left undefined using _, which leaves it to the compiler to infer. The compiler can do this because the signature of is_future_v specifies C‘s bounds as:

C: Future<Item=A, Error=B>

This demands that whatever C actually ends up being, it must implement the Future trait and its asssociated types Item and Error must be generic types A and B. We have already shown they will be i32 and u32 respectively. So specifying C as Future<Item=i32, Error=u32> at the call site would be redundant.

f_ok(1).map(|a| a + 1)

This expression is evaluated as the argument to is_future_v. First we need to look at f_ok. It is another test helper function defined as:

// From tests/support/mod.rs
fn f_ok(a: i32) -> FutureResult<i32, u32> {

It takes an i32 as an argument, which it wraps in the Ok variant of the core Result enum. The futures crate defines a trait called IntoFuture, which it implements for Result so that the call to into_future() will return a FutureResult - a future type whose value is immediately ready - whose type parameters will be inferred as i32 and u32 based on the return type signature.

A FutureResult<i32, u32> is a concrete future that implements the Future trait with Item=i32 and Error=u32. So it is already a type compatible with the trait bound for generic parameter C in our call to is_future_v.

Next the code calls .map(|a| a + 1) on our FutureResult<i32, u32>. This unpacks the value of 1 contained in our FutureResult and passes it to the closure |a| a + 1, which adds 1 to it. Because a is an i32 the result of the addition will also be an i32 and so the return value of the closure will also be i32.

The implementation of map is lazy, so instead of evaluting the closure it wraps the FutureResult<i32, u32> up with the closure into a Map<FutureResult<i32, u32>, FnOnce(i32) -> i32>.

Finally this nested Map object will be passed as the argument of type C to is_future_v(_: C), which does … absolutely nothing with it!

But what does it all mean?

The overall intention of this test is to guarantee that the combinators map, map_err, and_then, or_else, join, and flatten all return a Future<Item=A, Error=B> where A and B are Send + 'static. I.e. this:

where A: Send + 'static,
      B: Send + 'static,
      C: Future<Item=A, Error=B>

The tests could have been written explicitly as:

let _x: Map<FutureResult<i32, u32>, _> = f_ok(1).map(|a| a + 1);
let _x: MapErr<FutureResult<i32, u32>, _> = f_ok(1).map_err(|a| a + 1);
let _x: AndThen<FutureResult<_, _>, Result<i32, u32>, fn(i32) -> Result<i32, u32>> = f_ok(1).and_then(Ok);
let _x: OrElse<FutureResult<_, _>, Result<i32, u32>, fn(_) -> Result<_, _>> = f_ok(1).or_else(Err);
let _x: Join<FutureResult<i32, u32>, FutureResult<i32, u32>> = f_ok(1).join(Err(3));
let _x: Flatten<Map<FutureResult<i32, u32>, fn(i32) -> FutureResult<i32, u32>>> =
    f_ok(1).map(f_ok as fn(i32) -> FutureResult<i32, u32>).flatten();

But this is unnecessarily complicated and intrudes on the implementation details. What we really care about is that each resulting type - deeply nested as it may be due to laziness and chaining - implements the Future trait correctly.


Being able to write:

is_future_v::<i32, u32, _>(f_ok(1).map(|a| a + 1));

allowed the test author to state their assumptions simply and have the compiler check them and that is the value of compile-time test helper functions.