Testing Rust: 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 helper functions.

What’s a helper function?

What I’m talking about is functions created to encapsulate some test-code you need to use several times that you don’t want to duplicate.

Here’s an example based on something I saw in futures:

// A file-scope shared helper
fn assert_empty<T: IntoIterator, F: Fn() -> T>(f: F) {
    assert!(f().into_iter().count() == 0);
}

#[test]
fn test_empty() {
    // A function-scope factory function
    fn empty() -> Vec<i32> { vec![] }

    assert_empty(|| empty());
    assert_empty(|| {
        let mut e = empty();
        e.append(&mut empty());
        e
    });
}

This example is pointless as it’s just testing the behaviour of empty Vecs. But it demonstrates two ways helper functions are used to support the tests.

First, a specialised assert function assert_empty is defined and used repeatedly to verify different test scenarios. It is in file scope so cannot be shared outside of this test file.

Second, a helper function empty has been nested within test_empty, which makes it possible for the tests to simply call empty() instead of future::empty::<i32, u32>() every time a new value needs to be created. In effect, it is a tiny factory function. This has two benefits: it shortens the code required to create a new fixture instance; and it assigns the two template parameters <T, E> as i32 and u32 respectively.

Of course the nested helper doesn’t have to be declared within test_empty. But it has the effect of making it private to the scope of this test. So if you have function that is not relevant to other tests you could consider nesting it to:

Knowing that we can do this, lets go further by using a nested-helper to make the Vec joining code less noisy too:

#[test]
fn test_empty_with_join() {
    // A function-scope factory function
    fn empty() -> Vec<u64> { vec![] }

    // Another function-scope helper
    fn join(mut a: Vec<u64>, mut b: Vec<u64>) -> Vec<u64> {
        a.append(&mut b);
        a
    }

    assert_empty(|| empty());
    assert_empty(|| join(empty(), empty()));
}

Sharing helper functions between more than one test file

When there are many test files and lots of helper code to be shared, we are going to need a place to put all this code so it’s accessible. This is easily done using Rust’s module system.

There are a few different ways to do this, with one constraint: each test file under the tests/ folder gets compiled into a separate executable binary and run separately. I.e. the test files are not bundled into a single unit by default, so each test file will need to redeclare its relationship to the shared helper code.

In futures they have created a module called support within the tests/ folder:

$ tree tests/
tests/
├── support
│   ├── local_executor.rs
│   └── mod.rs
├── all.rs
├── bilock.rs
├── ... (more files)
└── unsync.rs

Then in EACH test file they need to redeclare the module. E.g:

// Declare the module
mod support;

// Make specific helpers available without the namespace
use support::{assert_this, assert_something_else};

#[test]
fn test_whatever() {
    assert_this(/* ... */)
    assert_something_else(/* ... */)
    // ...
}

Separate crates

As things become more complex you could consider creating a separate crate for your helper code.

The cargo project uses a nested crate for their helper code; cargotest. It sits in a sub-folder below their test suite. It’s also interesting to note they went as far as reorganising their integration test suite as one crate with many modules, instead of the separate-executable-per-test-file default. See the associated issue for discussion of the pros and cons.

The Serde project is made up of several crates within a single Git repository. It uses cargo’s workspace feature to keep them all together. Within its workspace it has a separate crate for the project’s entire integration test_suite, and the serde_test crate, which provides test helpers for end-users to test code written using Serde.

Conclusion

Being able to write and organise supporting code for tests is a fundamental requirement in any language. Rust provides powerful options to scope and organise helpers at the function, file and shared-module level.