/ Golang

Golang testing — functional arguments for wonderful builders

Programming is not easy; even the best programmers are incapable of writing programs that work exactly as intended every time. Therefore an important part of the software development process is testing. Writing tests for our code is a good way to ensure quality and improve reliability.

Go programs, when properly implemented, are fairly simple to test programatically. The testing built-in library and the features of the language itself offer plenty of ways to write good tests. As this is a subject I particularly like, I'm gonna write a bunch of articles about it, that, hopefully do not get old or boring.

I'm not going to start by introducing how testing works, it's already widely described in the testing godoc, some articles and blogs. I'm going to jump ahead on a more advanced techinque to write tests, the builders for tests.

One of the most important characteristic of a unit test (and any type of test really) is readability. This means it should be easy to read but most importantly it should clearly show the intent of the test. The setup (and cleanup) of the tests should be as small as possible to avoid the noise. And as we are going to see below, go makes it pretty easy to do so.

Builders in tests

Sometimes, your need to create data structure for your test that might take a lot of line and introduce noise. In golang we don't have method overload or even constructors as some other language have. This means most of the time, we end up building our data using directly the struct expression, as below.

node := &Node{
        Name: "carthage",
        Hostname: "carthage.sbr.pm",
        Platform: Platform{
                Architecture: "x86_64",
                OS:           "linux",
        },
}

Let's imagine we have a Validate function that make sure the specified Node is supported on our structure. We would write some tests that ensure that.

func TestValidateLinuxIsSupported(t *testing.T) {
        valid := Validate(&Node{
                Name: "carthage",
                Hostname: "carthage.sbr.pm",
                Platform: &Platform{
                        Architecture: "x86_64",
                        OS:           "linux",
                },
        })
        if !valid {
                t.Fatal("linux should be supported, it was not")
        }
}
func TestValidateDarwinIsNotSupported(t *testing.T) {
    valid := Validate(&Node{
        Name: "babylon",
        Hostname: "babylon.sbr.pm",
        Platform: &Platform{
        Architecture: "x86_64",
        OS:           "darwin",
        },
    })
    if valid {
        t.Fatal("darwin should not be supported, it was")
    }
}

This is quickly hard to read, there is too much noise on that test. We setup a whole Node struct, but the only thing we really intend to test is the Platform.OS part. The rest is just required fields for the function to correctly compile and run.

This is where test builders (and builders in general) comes into play. In Growing Object-Oriented Software Guided by Tests, the Chapter 22 "Constructing Complex Test Data" is exactly about that and guide us through the why and the how of these builders. The examples in the book are in java and uses wisely the object-oriented nature of the language. Here is an example from the book.

// I just want an order from a customer that has no post code
Order order = anOrder()
    .from(aCustomer().with(anAddress().withNotPostCode()))
    .build()

These builders helps keep tests expressive, as it's pretty obvious when reading it, what we want to test. They remove the visual noise you have when building an object (or a struct{} in Go) and allows you to put sane default. They also make tests resilient to change. If the structure changes, only the builder has to be updated, not the tests depending on it. They also make default case really simple to write, and special cases not much more complicated.

Builder in Go

The naive way to create builders in go could be to create a builder struct that have methods to construct the final struct and a build method. Let's see how it looks.

func ANode() *NodeBuilder {
        return &NodeBuilder{
                node: &Node{
                        Name: "node",
                        // Other defaults
                },
        }
}
type NodeBuilder struct {
    node *Node
}

func (b *NodeBuilder) Build() *Node {
    return b.node
}

func (b *NodeBuilder) Hostname(hostname string) *NodeBuilder {
    b.node.Hostname = hostname
    return b
}

func (b *NodeBuilder) Name(name string) *NodeBuilder {
    b.node.Name = name
    return b
}

func (b *NodeBuilder) Platform(platform *Platform) *NodeBuilder {
    b.node.Platform = platform
    return b
}

This looks decent, and using it is pretty straightforward. At least it make building the struct more expressive, less noisy and resilient to change. We can update the previous test as follow.

func TestValidateLinuxIsSupported(t *testing.T) {
        valid := Validate(ANode().Platform(&Platform{
                Architecture: "x86_64",
                OS:           "linux",
        }).Build())
        if !valid {
                t.Fatal("linux should be supported, it was not")
        }
}

func TestValidateDarwinIsNotSupported(t *testing.T) {
    valid := Validate(ANode().Platform(&Platform{
        Architecture: "x86_64",
        OS:           "darwin",
    }).Build())
    if valid {
        t.Fatal("darwin should not be supported, it was")
    }
}

There is room for improvement :

  • There is still some noise, mainly build() and the platform struct, as it still shows too much.
  • It's not that extensible yet. If you want to update the Node a certain way that the builder is not written for, you have to update the builder.
  • The NodeBuilder struct feels a little empty, it's just there to hold on the Node being constructed until it is build.

One improvement we could make is to have a Platform builder, even if it's a small struct here. Let's do that in the same way we did with Node.

func APlatform() *PlatformBuilder {
        return &PlatformBuilder{
                platform: &Platform{
                        Architecture: "x64_86",
                        OS: "linux",
                },
        }
}

type PlatformBuilder struct{
    platform *Platform
}

func (b *PlatformBuilder) Build() *Platform {
    return b.platform
}

func (b *PlatformBuilder) OS(os string) *PlatformBuilder {
    b.platform.OS = os
    return b
}

And our tests becomes 🐻.

func TestValidateLinuxIsSupported(t *testing.T) {
        valid := Validate(ANode().Platform(
                APlatform().OS("linux").Build()
        ).Build())
        if !valid {
                t.Fatal("linux should be supported, it was not")
        }
}

func TestValidateDarwinIsNotSupported(t *testing.T) {
    valid := Validate(ANode().Platform(
        APlatform().OS("darwin").Build()
    ).Build())
    if valid {
        t.Fatal("darwin should not be supported, it was")
    }
}

It does not really improve the visual noise as there is now quite a few duplication : several build, APlatform inside Platform, … It is a small improvement on readability but not that much compared to the previous one. This is were the Go language features comes into play.

Functional arguments to the rescue

Go has two interesting feature that are going to be useful here.

First, a function in Go is a type on its own and thus is considered a first class citizen. It means it's possible to pass a function as argument, or define a variable that holds it.

func ApplyTo(s string, fn func(string) string) string {
        return fn(s)
}

func world(s string) string {
    return fmt.Sprintf("%s, world!", s)
}

// Usage
a := ApplyTo("hello", world)
// a == "hello, world!"

The second feature that comes into play here, is the possiblity to have variadic functions. A variadic function is a function that takes a variable number of arguments (from 0 to any number of argument).

func Print(strs ...string) string {
        for _, s := range strs {
                fmt.Println(s)
        }
}

As we are going to see below, combining these two feature makes our builders pretty easy to write and to use with simple case, while staying very customizable, even outside of the builder. This is really well described in a talk from Dave Cheney : Functional options for friendly APIs (transcription).

Let's apply that to our new builders.

func ANode(nodeBuilders ...func(*Node)) *Node {
        node := &Node{
                Name: "node",
                // Other defaults
        }

        for _, build := range nodeBuilders {
                build(node)
        }

        return node
}

func APlatform(platformBuilders ...func(*Platform)) *Platform {
        platform := &Platform{
                Architecture: "x64_86",
                OS: "linux",
        }

        for _, build := range platformBuilders {
                build(platform)
        }

        return platform
}

And that is it for the actual builder code. It is small and simple, there is no more NodeBuilder struct, and this is highly extensible. Let's see how to use it.

// a default node
node1 := ANode()
// a node with a specific Hostname
node2 := ANode(func(n *Node) {
        n.Hostname = "custom-hostname"
})
// a node with a specific name and platform
node3 := ANode(func(n *Node) {
        n.Name = "custom-name"
}, func (n *Node) {
        n.Platform = APlatform(func (p *Platform) {
                p.OS = "darwin"
        })
})

The last step is to define some function builder for common or widely used customization, to make this expressive. And let complex, one-time function builder in the end of the user. Now our tests looks like.

func TestValidateLinuxIsSupported(t *testing.T) {
        valid := Validate(ANode(WithAPlatform(Linux)))
        if !valid {
                t.Fatal("linux should be supported, it was not")
        }
}

func TestValidateDarwinIsNotSupported(t *testing.T) {
        valid := Validate(ANode(WithAPlatform(Darwin)))
        if valid {
                t.Fatal("darwin should not be supported, it was")
        }
}

// Function builders
func WithAPlatform(builders ...func(*Platform)) func (n *Node) {
        return func(n *Node) {
                n.Platform = Platform(builders...)
        }
}

func Linux(p *Platform) {
        p.OS = "linux"
}

func Darwin(p *Platform) {
        p.OS = "darwin"
}

The intent is now clear. It's readable and still resilient to change. The code Node(WithPlatform(Linux)) is easy to understand for a human. It makes what are the tested characteristics of struct pretty clear. It's easy to combine multiple builders as the WithPlatform function shows 👼. It's also easy to create a function builder, even in a different package (as long as the ways to modify the struct are exported) and complex or on-off builder can be embedded in the function call (Node(func(n *Node) { // … })).

In summary, using these types of builder have several advantages :

  • tests are easy to read, and reduce the visual noise
  • tests are resilient to change
  • builders are easy to compose and very extensible
  • builders could even be shared with production code as there is nothing tied to testing.
Vincent Demeester

Vincent Demeester

French developer 🐻, Gopher 🐹, sysadmin 🐺, factotum 🦁 and free-software fan 👼 — ah and unicode fan too 🐸. Working @Docker 🐳 as a software engineer

Read More