Spend less time maintaining your test suite by using the Builder Pattern

Published by donnywals on

Often when we write code, we have to initialize objects. Sometimes the object’s initializer doesn’t take any arguments and a simple let object = MyObject() call suffices to create your object, other times things aren’t so simple and you need to supply multiple arguments to an object’s initializer. If you have read my previous post, Cleaning up your dependencies with protocols , you might have refactored your code to use protocol composition to wrap dependencies up into a single object that only exposes what’s needed to the caller. In this blogpost I would like to show you a technique I use when this kind of composition isn’t an option.

What problem are we solving exactly?

Before I dive in and show you how you can use builders in your test suite, let’s take some time to fully appreciate the problem we’re trying to solve. Imagine you’re building a very simple logger object that has the following initializer:

init(sink: LogSink) {
  self.sink = sink
}

All you need to do to create a logger, is provide it with a log sink and you’re good to go. In your test you might have written several tests for your logger, for instance:

func testLoggerWritesToSuppliedSink() {
  let sink = MockLogSink()
  let logger = Logger(sink: sink)
  logger.log("hello world")
  XCTAssertTrue(sink.contains("hello world")
}

In most applications, you’ll create a single instance of a logger in your AppDelegate, some kind of root coordinator, view model or other top-level object that is responsible for bootstrapping your application. However, in your test suite, you should be creating a new instance of your logger every time you want to test this. You do this to avoid tests accidentally influencing each other. Every test should start with a clean slate.

I want you to keep this in mind and think of the impact that changing the logger’s initializer would have. For instance, what’s the impact of changing the logger’s initializer to the following:

init(sink: LogSink, config: LogConfig) {
  self.sink = sink
  self.config = config
}

Obviously, you’re going to have to update the initializer you’re using in your application code. But you might also have to update dozens of other calls to the Logger initializer in your test suite. You might even have to update several tests that are unrelated to the logger itself, where the logger is used as a dependency for another object.

Personally, when I make changes to my code, I want the impact of that to be minimal. Finding out that there might be dozens of lines of code that I have to update when I change an initializer is not something I enjoy, especially if I can avoid it. This is the exact reason why I’d like to show you the Builder Pattern as it’s a fantastic way to minimize the impact of this kind of problem.

Introducing: the Builder Pattern

If you’ve studied design patterns, the Builder Pattern should be familiar to you. The very short explanation of this pattern is that you use an intermediate object (the builder) to create instances of an object for you so you don’t have to concern yourself with the details of object creation. Besides the use case I will show you in a moment, a builder can have several other benefits, for instance, you could use it to create different versions of an object depending on whether your app is built in debug or release mode.

In the context of testing, I really like to use the builder pattern for two reasons:
1. I don’t have to change a lot of code every time I refactor.
2. Test code is cleaner because it’s not polluted with information about creating an object if it’s not relevant to my test.

Before I explain the pattern and it’s usage a little bit more, let’s have a look at an example builder and how it would be used in context.

// The builder
class LoggerBuilder {
  var sink: LogSink = MockLogSink()
  var config: LogConfig = TestLogConfig()

  func build() -> Logger {
    return Logger(sink: sink, config: config)
  }
}

// in a test class
func testLoggerWritesToSuppliedSink() {
  let loggerBuilder = LoggerBuilder()
  let logger = loggerBuilder.build()
  logger.log("hello world")
  XCTAssertTrue(loggerBuilder.sink.contains("hello world")
}

The code above is a good example of how the builder pattern can hide some of the complexities that you’re unlikely to be interested in while writing your tests. For example, it’s likely that all of your tests will use the exact same configuration object, and it’s very possible that the MockLogSink contains all the functionality you need to use and test your logger in most of your test cases.

To make effective use of this pattern in your tests, there are a couple of rules to keep in mind. First, the builder should always be a reference type with mutable properties. This allows users of the builder to override certain properties on the object that is created. In the case of the logger, you’ll likely want to write a couple of tests that verify that a logger handles certain configurations as expected. You can achieve this by setting the config property on the builder:

let loggerBuilder = LoggerBuilder()
loggerBuilder.config = SpecialConfig()
let logger = loggerBuilder.build()

This brings me to the second rule. A builder must always construct and return a new instance when build() is called. This will make sure that any customizations that are performed by the user of your builder are actually applied.

The third rule of the builder pattern as I use it is that it should do as little work as possible in the build() method. And it’s strictly forbidden to create instances of objects that would be passed to the built object. For example:

// Don't do this!
class ProfileViewModelBuilder {
  var userProfile: UserProfile = MockUserProfile()

  func build() -> ProfileViewModel {
    let logger = LoggerBuilder().build()
    return ProfileViewModel(userProfile: userProfile, logger: logger)
  }
}

// Do this instead
class ProfileViewModelBuilder {
  var userProfile: UserProfile = MockUserProfile()
  var logger = LoggerBuilder().build()

  func build() -> ProfileViewModel {
    return ProfileViewModel(userProfile: userProfile, logger: logger)
  }
}

The fourth and final rule to keep in mind when working with this pattern is that you should always have default values for every property, even if it doesn’t make much sense. This value can never be another builder though, you don’t want to create instances of objects in a builder’s build() method because that would violate rule number three. Of course, you can use builders to create instances as I did in the previous example with the following line of code: var logger = LoggerBuilder().build(). The following code snippet shows an example of using builders the wrong way:

// Don't do this!
class ProfileViewModelBuilder {
  var userProfile: UserProfile = MockUserProfile()
    var loggerBuilder = LoggerBuilder() 

  func build() -> ProfileViewModel {
    let logger = LoggerBuilder().build()
    return ProfileViewModel(userProfile: userProfile, logger: loggerBuilder.build())
  }
}

By using builders instead of concrete objects, you lose the ability to properly configure and control a builder. There might be cases where you want to pass the same instance of a logger to several builders for instance, or you might want to keep a reference to a logger in your test to ensure that a certain object uses the logger correctly. This would be very hard if a builder creates new instances of the logger when you don’t expect it to.

If you keep these four rules in mind while writing builders for your own test suite, you should be able to improve your test suite’s readability and flexibility in no time!

In summary

I hope that this post has given you some insight into the builder pattern and how you can use it to make improvements to your test suite. From my experience, the builder pattern is very powerful for both small and large projects but it does take some getting used to at first. I would like to recommend that you carefully start introducing builders for simple objects in your test suite before refactoring large complicated sections of your test code since that, like any big refactor, can prove to be quite the challenge.

Once you start adopting builders, take a moment to appreciate your test code, you should find that your test code contains less boilerplate, less configuration and setup than it did before, allowing you to focus on what matters, the test itself. The less code you have to look at, the easier it is to understand the purpose of a test and the Builder Pattern is just another tool to achieve this kind of clarity.

I hope this blog post inspires you to make improvements to your test suite. If you have questions, doubts or if you want to let me know that this post helped you, don’t hesitate to reach out on Twitter

Receive weekly updates about my posts

Categories: Testing