Building a Builder – TDD Patterns

A Builder (design pattern) lets you create an object piecemeal rather than requiring the data all at once. Let’s look at what’s involved in testing one into existence.

Builder

A builder has a constructor, methods that gather information, and a method you call at the end to produce the desired object. Behind the scenes, the builder has data structures to hold information until it’s time to build. If needed, you can create a family of builders, sharing a protocol/interface, but producing different things.

Builders don’t require that you have a “fluent” interface, but it’s very commonly done by having the info-gathering methods return “self” (“this”).

Builder Constructor

On its own, there’s not much to test in the constructor. The only method that provides any visibility is the final make() or build() method (either name is common).

For a running example, let’s take a dungeon game. We’ll accumulate configuration data and format it in JSON so we can store it somewhere. (In many languages, there are better ways to convert to JSON, but it’s easy to understand for the example.)

func test_constructor_returns_empty_data() {
  var actual = Builder().make()
  assertEquals("{\n}", actual)
}

Decision: Is an empty construction valid? (That is, no info-gathering methods called.) If not, make() needs to somehow indicate the error (and the test needs to detect it.)

If there are multiple legal variations of the output, you may need to normalize the expected and actual values to be able to compare them.

Single-Value Methods

Sometimes you’re accumulating a single, simple value such as int or string. In our example, we’d like to capture the integer seed value used to generate the dungeon.

func test_seed_is_retained() {
  var actual = Builder()
    .seed(99)
    .make()
  
  var expected = """
{
  seed: 99
}
"""
  assertEquals(expected, actual)
}

Decision: Is there a default value for this item? If so, we want a different constructor test:

func test_constructor_returns_default_data() {
  var actual = Builder().make()
  var expected = """
{
  seed: 42
}
"""
  assertEquals(expected, actual)
}

Decision: What if you send a simple value multiple times?
A. It’s an error, detected by the method or possibly make(). Test that.
B. Last value in wins. You could test it; I’ll confess I often don’t.
C. First value in wins. You probably want to test this.

Accumulating Methods

Other times, you want to accumulate values (in a list, set, multi-set, etc.). In this case, calling a method multiple times adds another instance of data.

This is a good opportunity for a Zero-One-Many test. You’ve tested “zero” with the constructor; test “one”:

func test_one_sound_is_retained() {
  var actual = Builder()
    .sound("door", "creak.wav")
    .make()

  var expected = """
{
  sounds: {
    "door" : "creak.wav"
  }
}
"""
   assertEqual(expected, actual)
}

and test “many”:

func test_multiple_sounds_are_retained() {
  var actual = Builder()
    .sound("door", "creak.wav")
    .sound("key", "chime.wav")
    .make()

  var expected = """
{
  sounds: {
    "door" : "creak.wav",
    "key" : "chime.wav"
  }
}
"""
   assertEqual(expected, actual)
}

Notice that we need comma separators, but can’t have trailing commas:(

Decision: What if the same item is added multiple times?
A. No problem, we’re creating a list that allows duplicates. You may test if it will drive behavior, or you want to document it.
B. Latest wins: Test if it will drive behavior or you want to document it.
C. First in wins: Unusual answer, definitely test.

Builder Errors

There may be errors you can’t detect until make() is called. Assess each possibility, and decide if it applies.

Decision: Is a simple value required?
A. No: there’s either a default or we can omit it. No problem.
B. Yes: Modify other tests to add the required value, and test make() for error cases with a missing required value.

Decision: Is an accumulating value required?
A. No: We allow lists or sets with no items.
B. Yes: Modify other tests to add at least one of the required item, and test make() for an error case with no values of that type.

The final case concerns mutually exclusive options.

Decision: Are there mutually exclusive options?
A. No: No problem.
B1. Yes, and if it hurts when you do that, don’t do that: This relies on correct usage but doesn’t enforce it – risky!
B2. Yes, and they’re handled like the single-value case where the first or last value wins. Worth testing.
B3. Yes, and you’ll get an exception if you use more than one: Worth testing. (The info-gathering method or the make() could be designed to throw.)

In the latter case, you’ll have to decide the depth of testing needed: Will you check that each option has a conflict with one other? Test all pairs? Test all combinations? [I’ve never done the latter.]

I’ll confess: Laying out all these options makes me realize I’ve under-tested some of my builders.

Big Test

There’s one more case that’s worth doing: a builder with each method called at least once.

We expect (and hope:) this passes, but it can catch small-scale integration issues. For example, it would be very easy to forget to add the comma to the “seed” line when both seeds and sounds are present:

func test_all_methods_together() {
  var actual = Builder()
    .seed(99)
    .sound("swordplay", "snick.wav")
    .make()
  var expected = """
{
  seed: 99,
  sounds: {
    "swordplay" : "snick.wav"
  }
}
"""
  assertEquals(expected, actual)
}

Evolving Builder

Like most patterns, there are several ways to get here:

  • Design it in: Say “This situation obviously calls for a builder; I’ll create one now and call it later.” (Not an evolutionary approach.)
  • Refactor it in: Realize “What a mess: It mixes up deciding what goes in with how to put it there. So I’ll extract a builder.”
  • Grow it: Realize “This is starting to mix up what goes in with how to put it there; let me start growing a builder to keep it simple.”

The trick in evolutionary design is to refactor when needed, but it’s better to build good instincts for that third option, avoiding speculative generality and excessive rework.

Conclusion

A Builder simplifies code: rather than a caller knowing what to keep and how to format it, the two tasks are separated. Further, it enables a strategy approach where we substitute a different format if that becomes needed.

We’ve seen the types of tests that can test-drive a Builder, and we’ve identified some key decisions that you’ll need to make.

References