One of the times I find Builders particularly useful is when creating complex objects in tests. Often the test case will be influenced by something small within the complex object. To make my tests as readable as possible, I want the intent of the test to be clear. Let's take the following as an example (assuming
card() is an imported static factory method from
Card card = card() .withNickname("My VISA Card") .with(CardType.VISA) .with(CardHolder.fromString("Fred Bloggs")) .with(CardNumber.fromString("4543210000000000")) .with(CV2.fromString("123")) .with(expiryInPast) .build();
It may well be that the test I'm writing requires a
Card, but that the test itself isn't interested in the value of the
Card. In this example the thing I'm really interested in is having a
Card with an expiry in the past. Unfortunately this is hidden in a deluge of less important calls. If I use the snippet shown above in my Given block, I have made the intent of my test harder to understand. One solution to this problem is to refactor this to a method, so I could do something like this in the actual test:
Card card = createCard(expiryInPast);
This is good, it shows the test wants a
Card with an expiry in the past. But this will soon get out of control as I introduce more and more methods to build a
Card in various states, for example with a specific
CardNumber. And to make matters worse as soon as I have multiple test classes I will end up duplicating this functionality across the tests. Yuk. A friend introduced me to the idea of having a
withReasonableDefaults() method within the builder.
Card card = card().withReasonableDefaults().build();
I think this is looking much better already. I can create a
Card "with reasonable defaults" in any of my tests and it's clear that the values it will use to build the
Card will be something, well, reasonable. One of the nice things about this, is we can override those defaults should there be something specific we care about, for example that pesky expiry date.
Card card = card() .withReasonableDefaults() .with(expiryInPast) .build();
This feels very explicit. Reading this test I can see we're interested in the scenario where the
Card has expired. In addition, I can use this in a way that doesn't incur the problems described when I was extracting the creation of the
Card to methods in the test.
The one thing I wasn't so sure about was the term "reasonable defaults", after all, what is reasonable? I've been playing with some other names,
withTestDefaults(). I think the three of these indicate something slightly different. I'm not 100% sure about these, but here are my thoughts so far.
withDefaults() is an improvement over
withReasonableDefaults, the reasonable aspect is implied. I would expect this method to have everything I need to subsequently use the build method and get an object in a valid state.
withArbitraryDefaults() strongly implies the test isn't concerned about the defaults. That is to say everything is arbitrary. Sometimes this seems to fit well, but it only really feels suitable when everything about those defaults is unimportant to the test.
withTestDefaults() is clearly intended for testing. And implies the defaults will be supportive of the test environment. Unlike
withArbitraryDefaults() it implies the defaults are important, but aren't so important they need to be visible in the test. I think this makes particular sense for integration tests where we are specifying something very specific about the test environment; for example if I were creating a Request object that included details of an end point, the end point would be specific to the test environment.
My final thought is whether the withDefaults methods should be static factory methods. The problem with the solution described so far is we can explicitly set something and then inadvertently override it, for example:
Card card = card().with(expiryInPast).withDefaults().build()
Making these methods static factory methods would overcome this problem. I haven't tried this yet, but here are some ideas on how this might work.
Card card = cardWithDefaults() .with(expiryInPast) .build(); Card card = cardWithArbitraryDefaults() .with(expiryInPast) .build(); Card card = cardWithTestDefaults() .with(expiryInPast) .build();