Testing Rust: Using the builder pattern for complex fixtures

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 using the builder pattern to ease contruction of complicated fixtures.

What is the Builder Pattern?

The builder pattern was famously catalogued as one of five creational patterns in the Gang of Four Design Patterns Book, where its intent was defined as:

Separate the construction of a complex object from its representation so that the same construction process can create different representations.

But what does that mean?

The idea essentially boils down to this: given an object that is complex to construct, e.g. a Thing, you create second object ThingBuilder with setter methods to assemble the attributes of a Thing and aggregate instances of associated types. You then perform construction piecemeal across several calls to these methods.

Finally you call a build, construct, finish or similarly named method that instantiates and returns an instance of your Thing object initialised with the arguments you provided to the builder. E.g.

let thingey: Thing = ThingBuilder::new("a mandatory arg here")
    .maybe_things("Some optional argument here")
    .related_thing(RelatedThing::new(123, 234))
    .related_thing(RelatedThing::new(432, 1))
    .build();

What is its association with Rust?

The builder pattern is a general solution applicable to many languages, but appears to be popular in Rust. According to the Unofficial Rust Patterns repository, this is because Rust lacks a way to do constructor overloading - you would need to define a uniquely named constructor for each variant. For complicated objects this could lead to a large number of similarly named methods with many arguments.

impl Thing {
    fn new(mandatory_arg: &str) -> Self {
        /* .... */
    }
    fn new_with_related_things(
      mandatory_arg: &str,
      related_things: &[&RelatedThing]
    ) -> Self {
        /* .... */
    }
    fn new_with_related_things_and_optional_thing(
        mandatory_arg: &str,
        related_things: &[&RelatedThing],
        optional_thing: &str,
    ) -> Self {
        /* .... */
    }

    // and so on, madness continues ...
}

The pattern is also recommended in the Rust Style Guide.

How is it used in tests?

In tests we need to fake the various scenarios that can occur in our application in production.

Often there is a small graph of objects in our domain model that always have to be created together to make sense - because they collaborate. This may be the case even if the focus of testing is on a small aspect of just one of those models. Rebuilding all the peripheral objects can quickly become a pain, making it hard to see which data is actual changing from test to test.

The builder pattern is ideal in this scenario as it can be configured to always construct a valid model, even if you only change some small part of the particular object under test. Thus hiding distracting complexity and allowing us to highlight the differences that matter.

Can you give me a real example?

Here is a real example from the cargo project’s install.rs test:

// From tests/testsuite/install.rs
#[test]
fn multiple_crates_auto_binaries() {
    let p = project("foo")
        .file("Cargo.toml", r#"
            [package]
            name = "foo"
            version = "0.1.0"
            authors = []

            [dependencies]
            bar = { path = "a" }
        "#)
        .file("src/main.rs", "extern crate bar; fn main() {}")
        .file("a/Cargo.toml", r#"
            [package]
            name = "bar"
            version = "0.1.0"
            authors = []
        "#)
        .file("a/src/lib.rs", "")
        .build();

    assert_that(cargo_process("install").arg("--path").arg(p.root()),
                execs().with_status(0));
    assert_that(cargo_home(), has_installed_exe("foo"));
}

The problem the builder is solving beautifully is the repetitive need to create all manner of build configuration files to run cargo against.

The actual builder code is in a test support module so it can be reused throughout the test suite.

The project function is a factory function that constructs a ProjectBuilder from a mandatory name and a default folder path:

// Generates a project layout
pub fn project(name: &str) -> ProjectBuilder {
    ProjectBuilder::new(name, paths::root().join(name))
}

The ProjectBuilder struct has attributes that aggregate the artifacts needed to construct the fixture:

pub struct ProjectBuilder {
    name: String,
    root: Project,
    files: Vec<FileBuilder>,
    symlinks: Vec<SymlinkBuilder>,
}

It then implements, a constructor new, assembly methods file and symlink for adding project artifacts, and a final build method to perform construction (method bodies and extraneous methods omitted for brevity):

impl ProjectBuilder {
    // Constructor
    pub fn new(name: &str, root: PathBuf) -> ProjectBuilder { /* ... */ }

    // Chainable assembly methods
    pub fn file<B: AsRef<Path>>(mut self, path: B, body: &str) -> Self { /* ... */ }
    pub fn symlink<T: AsRef<Path>>(mut self, dst: T, src: T) -> Self { /* ... */ }

    // Final method that constructs and returns the Project
    pub fn build(self) -> Project { /* ... */ }
}

Note the assembly methods return Self so they can be chained repeatedly until build is called.

The only attribute of the Project struct is the path to the test fixture project on the filesystem.

pub struct Project{
    root: PathBuf,
}

We have already seen that this test project root path is passed by the project factory function to the builder constructor. So in this particular example of the builder pattern the produced type is instantiated upfront in the builder constructor and held in the ProjectBuilder‘s attributes. As opposed to being instantiated in build as is often the case for in-memory only structures.

In this case, the complexity the builder is encapsulating is the creation of multiple files and symlinks. The build function handles this then returns the precreated Project instance from the builder’s attributes.

pub fn build(self) -> Project {
    // First, clean the directory if it already exists
    self.rm_root();

    // Create the empty directory
    self.root.root.mkdir_p();

    for file in self.files.iter() {
        file.mk();
    }

    for symlink in self.symlinks.iter() {
        symlink.mk();
    }

    let ProjectBuilder{ name: _, root, files: _, symlinks: _, .. } = self;
    root
}

Summary

The builder pattern is a well known design pattern for creating complex things. It is popular in Rust in general, but is a particularly nice fit for constructing test fixtures, which are often complex and need to be recreated constantly.