Next-level Swift unit tests using the builder pattern

Make unit tests easier to code, review and maintain

Gabrielle Earnshaw
Swift2Go
8 min readAug 13, 2018

--

Introduction

Unit tests are essential for software projects, but they add overhead. In this article, I’m going to talk about how to leverage the builder pattern to make it more efficient for your team to write, review and maintain unit tests. I’ll start by talking about what the pattern is and why it is particularly useful in unit tests. Then I’ll explain the gains you can make by using the pattern. Finally I’ll show you how to create builder classes in your Xcode project.

This article uses examples for iOS code using Swift, but they apply equally well to just about any language and platform.

The full example code used in this article can be found at https://github.com/gearnshaw/BuilderPatternExample.

What is the builder pattern?

The builder pattern is used for creating objects, separating their set up from their creation. Introducing this separation allows you to: set defaults for the fields in an object; override only values that need setting explicitly; provide presets for complex object states; and, easily create complex object graphs.

The pattern is particularly useful in unit tests because they require you to create objects over and over again, setting them into the right state for each test case. If you create objects manually each time, you find yourself repeating code, copy and pasting object set up from one test to another, and writing various hybrid createMyObject() functions in your unit tests. These create a refactoring nightmare, and make it hard to understand what a test is testing and why. The builder pattern avoids this, giving you a way to create objects quickly, cleanly and maintainably.

Gains from using the builder pattern in your tests

The main benefits of using the builder pattern in your tests are:

  • Communication of intent: it is obvious from your code exactly what state is required in tests
  • Ease of refactoring: refactoring tests is trivial when object interfaces change
  • Minimal code: test code is succint, with less boilerplate code

These together lead to a project that is easier to code, review and maintain.

Consider an object called Device. It is a a simple struct, but the same principles apply to any kind of object, including those using core data. I’ll use this object in the examples in this article.

Example Device object

Communication of intent

Communication of intent means making it clear exactly what the code is doing and why. Getting this right saves you a ton of maintenance effort, and it makes peer reviews easier and more effective.

Consider the following snippets of code, the first using the builder pattern, the second constructing an object directly.

Setting required fields only using a builder

With the builder pattern you only specify values needed for the test case. In this case a Device with a specific id is needed. It’s clear from the code what is important.

Compare to constructing the object directly, where you have to initialise every field. It’s not clear which, if any, of those values are important for the test.

Setting every field on an object when creating directly

In the next example we need an instance of a device that is activated. This is a complex state that depends on the values of more than one field.

In the code that uses the builder, the builder is responsible for knowing how to make an activated device. It is absolutely clear from the code that the test requires this state, and you get the added bonus of not having to work out how to set up the state every time you need it.

Setting complex state with a builder

However in the case of the code without builders, it isn’t clear that you even need an activated device for the test, never mind what values were used to achieve that state. This makes review and maintenance harder.

Setting up complex state when creating directly

Ease of refactoring

Using builders to create tests makes it extremely easy to update them when code is refactored or a model’s interface is changed.

Imagine changing the Device object so instead of the userId field, it has a User object.

If you have created Device objects directly in your tests, you will have to change every single reference to Device to make the code compile again. This will be time consuming, and will create a lot of changes that need peer reviewing.

On the other hand if you’ve used a builder, you only need to change the builder, and any places where you have called with(userId:_).

In another example, imagine that the definition of an activated Device changes. With a builder, you simply need to change your makeActivated function and all of the tests will run in the correct state. When creating objects directly, if you are lucky you’ll get a ream of test failures to go through and fix. If you are unlucky, your tests will continue to pass, but will no longer be testing the state and behaviour that they are supposed to.

Minimal Code

The builder pattern considerably reduces both the amount of code you need to write, and the effort you need to spend thinking about how to set up test objects, saving lots of time coding, and making it easier to review and maintain code.

Often a test will only need a default object, which can be set up in one line using a builder.

One-line set up of a default object using a builder

Sometimes a test needs a complex object graph, i.e. an object that references other objects, that in turn reference other objects etc. The builder pattern lets you create object dependencies automatically.

Consider the example of a User object, that requires an Account object when it is initialised. Account in turn needs a History object.

Using a builder, the User is created in one line, with the builder taking responsibility for creating any other objects needed in the object graph.

One-line set up of a complex object graph using a builder

However when the object is constructed by hand, all of the objects in the graph have to be created manually. This generates more code that needs reviewing and maintaining.

Lots of code to write when creating a complex object graph directly

The builder pattern is flexible, so if you do care about those other objects in the graph, you can build those yourself. In this example we need a User with specific values in the associated Account. We use a builder to create the Account, then set the required account on the User's builder.

Set up a complex object graph flexibly with builders

Creating builders in your Xcode project

Where to put builders

Builders that are used in your unit tests should go in the test target, so they are not included with your application code.

In the test folder (Builder pattern exampleTests in my example), create a group called supporting code, and inside that create a group called builders.

To create a builder file, add a .swift file to your builder group. Ensure that the Target Membership is set to the test target, and not the main application target.

Add @testable import ... to the top of the file, as you would for a unit test class, to give the builder access to the main code target.

Location of builder files in an Xcode project

Anatomy of a builder

Here is the complete DeviceBuilder class.

The device builder class

@testable import: The builder class should include the same @testable import used in unit test classes to give it access to the main code target.

Default values: Add a default value for each of the fields needed to construct the object. These values shouldn’t need changing in order to create a valid object for tests. Then you only need to override values for fields you are specifically interested in for a given test.

with(field: value): Add a with function for each field in the object to allow defaults to be overridden. Notice that the with functions return the builder class. This allows objects to be constructed in a single, fluid statement, e.g.

make…(): Use make functions to initialise complex state in the object. This lets you communicate intent effectively in tests (i.e. it is clear from the code that an object must be in a particular state), and it allows tests to be written quickly without needing knowledge of what makes up that state. As for the with functions, make functions return the builder class.

build(): Last but not least is the build function, which returns a constructed object. This function uses the default values and any overrides set through calls to with and make to construct the object. In the above example, build is guaranteed to return an instance of the object. However it could return an optional, e.g. for a core data object that isn’t guaranteed to be constructed successfully.

A note on building complex object graphs

If a complex object graph is being created, don’t create default values for dependent objects until build is called. In the example below you can see that although Account is required to create a User, its variable is optional. The caller can set it explicitly by calling its with function, otherwise build will create a default value. This trades off some of the convenience of having the defaults set up front, for the efficiency of not creating objects that often won’t be used.

Creating default object dependencies in the build function

Speed up builder creation

Creating builders is quick and easy, and their reuse across tests saves lots of time. I mostly create them by hand, and to make things quicker, I often only add the with and make functions as I need them.

The with functions are boilerplate code, so you could get creative with Alfred snippets, Teacode, or even Sourcery to speed up development. I use the following Teacode expander:

This allows me to type with id String DeviceBuilder and expand to:

Summary

In this article I’ve explained what the builder pattern is and why it is particularly useful in unit tests. I’ve described how using it will save time and effort in coding, reviewing and maintaining your projects. Finally I’ve shown how to set up builders in your Xcode project. I hope you have found this article useful!

For app development and more, please contact me at Control F1, look me up on Linked In or say ‘hi’ on twitter at @GabEarnsh.

--

--

Gabrielle Earnshaw
Swift2Go

Mobile App Strategy, Leadership and Engineering Expert.