How to make your test set up cleaner and why it’s more important than you think

Garrett James Cassar
Nerd For Tech
Published in
7 min readFeb 3, 2021

I’ve always thought that if you think that tests are solely for the purpose of building robust-ish software components and finding bugs, then you’re kind of missing the point. They are much more powerful than that.

Tests are a long-standing form of communication and the only really reliable form of documentation in your company. A tool that can create clarity around edge cases, explore what-if scenarios and help the efficiency of what is, by far your companies most expensive and sought after resource, (devs) years after you’ve buggered off for a better salary.

But frustratingly, test set up can be confusing, long-winded and unclear, muddying the water around what can quite easily be a huge asset to the collective understanding of the way your application behaves.

The code

Simplified for demonstration purposes

public void validatePet(Pet pet, PetRequest petRequest) {
validateAge(pet, petRequest.getMaxAgeInMonthsExpectation());
validateColor(pet, petRequest.getColorExpectation());
validateCuteness(pet, petRequest.getCutenessExpectation());
validateTail(pet.getTail(), petRequest.getTailRequest());
}

void validateAge(Pet pet, Integer maxAgeExpectation){
if(pet.getAgeInMonths() > maxAgeExpectation){
throw new TooOldException();
}
}

void validateColor(Pet pet, String color){
if(!pet.getColor().equals(color)){
throw new WrongColorException();
}
}

void validateCuteness(Pet pet, Integer minCutenessExpectation){
if(pet.getCuteness() < minCutenessExpectation){
throw new NotCuteEnoughException();
}
}
public void validateTail(Tail tail, TailRequest tailRequest){
if(!tail.getTailFluff().equals(tailRequest.getTailFluff())){
throw new TailFluffException(tail.getTailFluff(), tailRequest.getTailFluff());
}
if(tail.getTailLength() < 0) {
throw new InvalidTailLengthException(tail.getTailLength());
}
if(tail.getTailLength() < tailRequest.getTailLength()){
throw new TailLengthException(tail.getTailLength(), tailRequest.getTailLength());
}
}

The Test

Test set up commonly looks like this.

@Test
void validationFailsWhenPetNotCuteEnough() {
// difficult to spot straight away which field has an effect on the test
var pet =
Pet.builder()
.petType(PetType.DOG)
.ageInMonths(12)
.color("brown")
.cuteness(2)
.healthRating(10)
.tail(Tail
.builder()
.tailLength(10)
.tailFluffiness(TailFluffiness.SUPER_FLUFFY)
.build())
.build();

var petRequest = PetRequest
.builder()
.petType(PetType.DOG)
.maxAgeInMonthsExpectation(120)
.colorExpectation("brown")
.cutenessExpectation(8)
.healthExpectation(8)
.tailRequest(
TailRequest
.builder()
.tailFluffiness(TailFluffiness.SUPER_FLUFFY)
.tailLength(10)
.build())
.build();

assertThrows(NotCuteEnoughException.class,
() -> petValidatorService.validatePet(pet, petRequest));

}

While this is definitely a correct test and displays the behaviour of the validator. It’s not pretty. It really isn’t immediately obvious which field is relevant to the test failure. And yes, while you don’t need to be a genius to figure it out, it does cause me to squint my eyes and cock my head forward for four or five seconds before I figure it out. While you could argue the name of the exception and the contents of the display name do give you all the information you need, in my experience it’s generally not good to rely on a developers willingness to name things well.

Multiply a test set up like this over thousands of tests and a more complicated domain than a puppy and it can easily become a confusing and tiresome game of where’s wally.

Therefore, the test set up like this in my opinion does not carry out the very important responsibility of the test which is to create clarity.

Moreover, what if this object was built 100 times across the code base and all of a sudden we added a size validator? Yep, that’s right, you would need to change 100 classes.

So let’s try something different.

The Object Mother Pattern

From the perspective of the test, the object mother class creates a much cleaner interpretation of our unrealistic expectations of this poor puppy. However, like many mothers, as the object mother matures it can become resistant to change and high maintenance, requiring many test default objects in order to accommodate all of your requirements. Let’s check it out below.

public class PetObjectMother {
public static Pet getNotSoCuteDog() {
return Pet.builder()
.petType(PetType.DOG)
.ageInMonths(12)
.color("brown")
.cuteness(2)
.healthRating(10)
.tail(getLongAndFluffyTail())
.build();
}

public static Pet getOldDoggo() {
return Pet.builder()
.petType(PetType.DOG)
.ageInMonths(120)
.color("brown")
.cuteness(8)
.healthRating(10)
.tail(getLongAndFluffyTail())
.build();
}
}
public class TailObjectMother {
public static Tail getLongAndFluffyTail(){
return Tail.builder()
.tailFluffiness(TailFluffiness.SUPER_FLUFFY)
.tailLength(10)
.build();
}
}
@Test
void validationFailsWhenPetNotCuteEnough() {
var pet = getNotSoCuteDog();

var petRequest = getFairlyDemandingPetRequest();

assertThrows(NotCuteEnoughException.class,
() -> petValidatorService.validatePet(pet, petRequest));
}

@Test
void validationFailsWhenPetIsTooOld() {
var pet = getOldDog();

var petRequest = getFairlyDemandingPetRequest();

assertThrows(TooOldException.class,
() -> petValidatorService.validatePet(pet, petRequest));
}

I’m sure that you can imagine how this solution doesn’t scale very well. You end up needing a new method for each test case and bloats out very quickly.

A quick and obvious solution to this is to replace a default object to a sort of default object that takes parameters, but that can quickly turn into a normal test class set up without the clarity of a builder pattern (if you do choose to use a builder pattern).

Moreover, while this solution is prettier on the client-side, it doesn’t actually create much clarity. We still need to rely on the name of the method to detect which part of the dto effects the result of the test. Which can be easily misnamed, or become out of date.

This pattern can work out well if the company has a strong culture of using testing personas, which I endorse, but it’s difficult to mitigate the scaling issue and honestly, while I find personas a nice idea, I’ve never really seen it effectively implemented and maintained over a long period of time in my career.

So let’s try something new. The young object mother pattern.

The Young Object Mother Pattern

The concept of the young object mother is very simple. It works by creating one default object that is set up to always pass through the validation cleanly, relying on the toBuilder pattern (a builder pattern that retains it’s default values) in order to change the field required to make the test fail.

public class PetProvider {
public static Pet getDefaultDog(){
return Pet.builder()
.petType(PetType.DOG)
.ageInMonths(1)
.color("brown")
.cuteness(10)
.healthRating(10)
.tail(getDefaultTail())
.build();
}
}
public class TailProvider {
public static Tail getDefaultTail(){
return Tail
.builder()
.tailFluffiness(TailFluffiness.SUPER_FLUFFY)
.tailLength(10)
.build();
}
}
@Test
void validationFailsWhenPetNotCuteEnough() {
// Immediately obvious that cuteness is effecting the result of the test
var pet = getDefaultDog().toBuilder().cuteness(1).build();

var petRequest = getDemandingPetRequest();

NotCuteEnoughException notCuteEnoughException = assertThrows(NotCuteEnoughException.class,
() -> petValidatorService.validatePet(pet, petRequest));
}

@Test
void validationFailsWhenPetIsTooOld() {
var pet = getDefaultDog().toBuilder().ageInMonths(120).build();

var petRequest = getDemandingPetRequest();

assertThrows(TooOldException.class,
() -> petValidatorService.validatePet(pet, petRequest));
}

This approach has three advantages.

The first is that you only have one default object to create anywhere.
For all of your tests. All of your unit tests, integration tests, all of your contract tests, acceptance tests and any other type of test. If we add a size validation now, all we need to do is change one line of code as far as our test set up is concerned. How nice is that?

The second, and more important is that it is immediately obvious which field causes the validation to fail. Which mean it’s easy to understand and comprehend.

The third advantage is that it is easy to add semantic meaning whenever and however you like by creating more default provider methods. Although, when taking this approach we are once again not explicitly specifying which field causes the test to fail.

public class TailProvider {
public static Tail getDefaultTail(){
return Tail
.builder()
.tailFluffiness(TailFluffiness.SUPER_FLUFFY)
.tailLength(10)
.build();
}
public static Tail getInvertedTail(){
return Tail
.builder()
.tailFluffiness(TailFluffiness.SUPER_FLUFFY)
.tailLength(-1)
.build();
}
}
@Test
void validationFailsTailNotFluffyEnough() {
var pet = getDefaultDog()
.toBuilder()
.tail(getInvertedTail())
.build();

var petRequest = getDemandingPetRequest();

assertThrows(InvalidTailLengthException.class,
() -> petValidatorService.validatePet(pet, petRequest));

}

The major disadvantage of this approach is that you need to implement the toBuilder pattern on your domain objects (using Lombok).

While it’s considered bad practice to couple your production code to patterns in order to accommodate your tests, I’ve always found it to be largely unimpactful in practice.

However to mitigate this risk, you can use the TestDataTemplate Pattern.

Test Data Template Pattern

The concept of the test data template pattern is very similar to the young object mother pattern, the only difference is that it creates a test object POJO with all of the fields created in the original POJO and a builder that instead of returning itself in the build() method, returns an original POJO for testing.

public class PetTestDataTemplate {
public static PetTestDataTemplateBuilder builder() {
return new PetTestDataTemplateBuilder();
}

public static class PetTestDataTemplateBuilder {
private String name = "woofy";
private Integer ageInMonths = 0;
private Integer cuteness = 10;
private Integer healthRating = 10;
private String color = "brown";
private PetType petType = PetType.DOG;
private Tail tail = TailTestDataTemplate.builder().build();

PetTestDataTemplateBuilder() {
}

public PetTestDataTemplateBuilder name(String name) {
this.name = name;
return this;
}

public PetTestDataTemplateBuilder ageInMonths(Integer ageInMonths) {
this.ageInMonths = ageInMonths;
return this;
}

public PetTestDataTemplateBuilder cuteness(Integer cuteness) {
this.cuteness = cuteness;
return this;
}

public PetTestDataTemplateBuilder invalidHealthRating(){
this.healthRating = -1;
return this;
}

public PetTestDataTemplateBuilder healthRating(Integer healthRating) {
this.healthRating = healthRating;
return this;
}

public PetTestDataTemplateBuilder color(String color) {
this.color = color;
return this;
}

public PetTestDataTemplateBuilder tail(Tail tail) {
this.tail = tail;
return this;
}

public PetTestDataTemplateBuilder petType(PetType petType) {
this.petType = petType;
return this;
}

public Pet build() {
return Pet.builder()
.name(name)
.ageInMonths(ageInMonths)
.cuteness(cuteness)
.healthRating(healthRating)
.color(color)
.petType(petType)
.tail(tail)
.build();
}
}
}
@Test
void validationFailsWhenPetHealthRatingIsBelow0() {
var pet = PetTestDataTemplate.builder().invalidHealthRating().build();

var petRequest = PetRequestDataTemplate.builder().build();

assertThrows(InvalidHealthRatingException.class,
() -> petValidatorService.validatePet(pet, petRequest));
}

As we can see on the test side, this pattern is almost exactly the same, and is extremely similar in concept. You can only see the fields that effect the result of the test. The advantage is that it does not couple your POJOs to any creational patterns, however it is a bit bigger and more of pain to maintain, but I’m happy with that if you are.

Conclusion

In conclusion, while the method in which you set up your testing data might seem trivial its not. Anything that one can do to create clarity and reduce faf in a project is never a waste of time.

If, like me, you’re lazy and don’t mind coupling your POJOs to the builder and toBuilder creational patterns I’d recommend the YOM (with lombok). If you understandably don’t want to, or can’t couple your POJOs any creational patterns are you are OK with maintaining test objects I would recommend the TDT Pattern.

--

--