DEV Community

loading...
Cover image for About mothers, builders, and how to reduce duplication in your tests

About mothers, builders, and how to reduce duplication in your tests

xoubaman profile image Carlos Gándara ・6 min read

Applying the builder pattern when creating the fixture data in our tests will provide several relevant benefits: better readability, more explicit test scenarios, and resilience to refactors.

It sounds too good to do not give it a try, doesn't it?

Disclaimer: code examples are in PHP but the concepts discussed refer to OOP, so don't get scared by the dollar symbols and the arrows.

Harmless changes with unexpected consequences

A non-desirable but common scenario: an apparently harmless, small change in production code causes zillions of tests to fail. We did stick to SOLID, DRY and plenty of other buzzwords while coding. Then, how can this be possible? Why we have to tediously update several apparently unrelated tests?

There are multiple possible reasons for this to happen. One of them is how we create fixture data in our tests. Consider the following scenario:

We have an application that processes orders, modeled in the Order class:

final class Order 
{
    private Uuid $id;
    private array $items;
    private string $address;
    private DateTime $deliveryDeadline;

    public function __construct(
        Uuid $id,
        array $items,
        string $address,
        DateTime $deliveryDeadline
    ) {
        $this->id = $id;
        $this->items = $items;
        $this->address = $address;
        $this->deliveryDeadline = $deliveryDeadline;
    }

    //Fancy business logic here
}
Enter fullscreen mode Exit fullscreen mode

In production code, we did the smart move of instantiating Order class only in the CreateNewOrder service when creating a new one, or in the OrderRepository when we get an existing order from the database.

As good or bad this modeling may look, our startup is starting to get some revenue out of it. The best part is that the code is extensively tested. For instance, we have this test taken from the more than forty we have involving an Order:

/** @test */
public function order_cannot_be_processed_when_delivery_deadline_is_reach(): void
{
    $order = new Order(
        Uuid::create(),
        [
            new Item('Dynamite Headdy', 1, 6500, 'GOLD'),
            new Item('Gunstar Heroes', 1, 12500, 'GOLD'),
        ],
        'Rincewind, Mage Tower, Ankh-Morpork, 00752, Discworld',
        new DateTime('yesterday')
    );

    self::assertFalse($order->canBeProcessed());
}
Enter fullscreen mode Exit fullscreen mode

Feels good to have business logic properly tested. However, our company is having more and more problems from customers living in some parts of Ankh-Morpork -no surprises- and we want to stop sending stuff to a certain zip codes. For that, we decide to model the address as an Address value object so we can easily evaluate when an order is being delivered to a forbidden zip code:

final class Address
{
    private string $name;
    private string $street;
    private string $city;
    private string $zipCode;
    private string $country;

    public function __construct(...) {}

    public function hasBannedZipCode(): bool {}
}
Enter fullscreen mode Exit fullscreen mode

We write a test for the new behavior:

/** @test */
public function order_cannot_be_processed_when_address_has_a_banned_zipcode(): void
{
    $order = new Order(
        Uuid::create(),
        [
            new Item('Speck of dust', 1000, 2, 'GOLD'),
            new Item('Higgins bosom', 100, 1, 'GOLD'),
        ],
        new Address('Rincewind', 'Mage Tower', 'Ankh-Morpork', self::BANNED_ZIPCODE, 'Discworld'),
        new DateTime('yesterday')
    );

    self::assertFalse($order->canBeProcessed());
}
Enter fullscreen mode Exit fullscreen mode

Changes in the code are straightforward. We implement the canBeProcessed() method using the new Address value object and the new test passes. Now we can expect to update CreateNewOrder and OrderRepository classes to deal with the change in Order constructor. But when we run the test suite we see forty of them in red. Not surprisingly, the ones involving orders.

In our production code, we took care of minimizing the places where we instantiate a new Order (band reference not intended) to reduce the reach of changes like the one we just did, but we forgot about our tests that are instantiating orders all around.

There are other undesirable effects on the approach we took. One is that Order instantiation is causing a lot of noise because of this complexity when we usually care only about one aspect of it -the zip code in the above example. The other is the inability to visually detect if we are messing up the order of the parameters in the address.

Luckily, we are here to see how builders mitigate such situations.

Introducing the builder pattern

Thanks, Jim

The builder pattern is one of the GoF Design Patterns. It consists in separating the -usually complex- construction of an object from its representation.

Twisting it a bit, the suggestion here is to use builders for any object or data structure you create more than once in your tests.

A possible public interface for a builder for our Order might be:

final class OrderBuilder
{
    public function build(): Order {}
    public function withId(Uuid $id) : self {}
    public function withItems(array $items): self {}
    public function withAddress(Address $address): self {}
    public function withDeliveryDeadline(DateTime $deliveryDeadline): self {}
}
Enter fullscreen mode Exit fullscreen mode

The build() method return an Order instance with any setup we define through the withX() methods. Internally we define default values for all the parameters involving instantiating an Order, so we don't need to call all the withX() methods every time -that would miss big part of the point of using these builders.

Our previous test looks now like:

/** @test */
public function order_cannot_be_processed_when_address_if_from_a_banned_zipcode(): void
{
    $address = new Address('Rincewind', 'Mage Tower', 'Ankh-Morpork', self::BANNED_ZIPCODE, 'Discworld');
    $order = (new OrderBuilder)->withAddress($address)->build();
    self::assertFalse($order->canBeProcessed());
}
Enter fullscreen mode Exit fullscreen mode

Way more readable, concise, and explicit about the purpose of the test. Even more, we can have an AddressBuilder as well and reduce all the irrelevant code needed to create a complete address.

If now we need to change anything else in the Order modeling, we have drastically reduced the points we need to change in our tests to do so. For instance, a change from the array of Item to an ItemCollection, so we can filter certain types of items to apply discounts, will require only to change the OrderBuilder, because it is the only point in our test suite we create Order instances. Profit.

Builders are as well very handy to generate plain structures, like request payloads or configuration files, and they do un awesome work removing the mysticism of magic numbers in the tests: it is more understandable withDiscount(10) than a lonely, meaningless 10 in the middle of some constructor call.

Introducing object mother pattern

There is only one

Object mother is a pattern where a class is used to create example objects, exposing a number of explicit factory methods.

If we start noticing we are building the same Order configuration in several tests we are still duplicating code. For instance, if our orders over 50 total price are susceptible to special discounts and many other advantages, we will do the following setup multiple times:

/** @test */
public function order_has_free_shipping_when_total_amount_is_50_eur_or_more(): void
{
    $item = (new ItemBuilder)->withPrice(51)->build();
    $order = (new OrderBuilder)->withItems([$item])->build();
    self::assertTrue($order->hasFreeShipping());
}
Enter fullscreen mode Exit fullscreen mode

Using an object mother class for order would reduce the previous example to:

/** @test */
public function order_has_free_shipping_when_total_amount_is_50_eur_or_more(): void
{
    $order = OrderMother::ofAmountGreaterThan(50);
    self::assertTrue($order->hasFreeShipping());
}
Enter fullscreen mode Exit fullscreen mode

Object mothers do the same work as builders when it comes to reduce duplication and noise in tests, although we must be careful to don't hide the duplication inside the object mother and bloat it with multiple instantiations of the same class, incurring in the same problem as before -although at a lesser scale, for sure.

Another issue we can run into is starting to use the object mother for everything, even for slight variations of the same data setup, overcharging them with factory methods with names too long to represent to the specificness of each case. A builder would work better in that case.

That said, why not combine both? We can add factory methods to our builders and if they start to become too cumbersome, move all the mother-ish methods to a proper object mother that internally uses a builder. Or have an object mother for builders with setups that we can tweak for concrete, edgy cases. Sky and your needs and context are the limit.

A builder implementation example

This is an example of how you can implement a builder in PHP:

final class MetalSlugCharacterBuilder
{
    private array $base;

    public function __construct() {
        $this->base = [
            'name'   => 'Marco Rossi',
            'weapon' => new RocketLauncher(),
            'bombs'  => 10,
        ];
    }

    public function build(): MetalSlugCharacter {
        return new MetalSlugCharacter(...$this->base);
    }

    public function withName(string $name): self {
        return $this->base['name'] = $name;
    }

    public function withWeapon(Weapon $weapon): self {
        return $this->base['weapon'] = $weapon;
    }

    public function withBombs(int $bombs): self {
        return $this->base['bombs'] = $bombs;
    }
}
Enter fullscreen mode Exit fullscreen mode

Furthermore, I even wrote a small library to reduce the amount of boilerplate and the time required to code a builder, plus some extra goodies. You can check it here:

GitHub logo xoubaman / builder

A lightweight PHP library to build... builders

Summary

Same as we care about avoiding unnecessary repetition in our production code, our tests deserve the same attention, if not more. Builder and object mother are two patterns that will help us to have a more consistent test suite, where tests fail because they should and not because we forgot to update the n-ish instantiation of some class.

Discussion (0)

pic
Editor guide