Increase your test quality thanks to builders or factories

In a previous blog post, I explained why it’s better to compare object instances instead of exposing their state to test them. This avoids breaking encapsulation and it does not have any impact on their design.

Let’s take an example! My side project allows me to create maps to remember places I have been. A map has a name and, as a cartographer, I am allowed to rename it. Real basic use case but more than enough! The following test ensures I can rename this map:

$map = new Map(
    new MapId('e9a01a8a-9d40-476e-a946-06b159cd484a'),
    new Username('Pepito'),
    new MapName('Bordeaux city'),
    new Description('Good places in Anglet'),
    Tag::city(),
    MarkerList::empty(),
);

$map->rename('Anglet city');

Assert::equals(
    $map,
    new Map(
        new MapId('e9a01a8a-9d40-476e-a946-06b159cd484a'),
        new Username('Pepito'),
        new MapName('Anglet city'),
        new Description('Good places in Anglet'),
        Tag::city(),
        MarkerList::empty(),
    )
);

We can see that comparing object instances is great for encapsulation because we don’t expose the object’s state but this makes the test less readable. Here, the only thing we want to focus on is the value of MapName. The values of the other value object are only noise because they are not useful for this test. But, this is not the only drawback of this test. What happens if you want to add an extra property to the Map object? In this case, we will need to refactor all the tests that create a map object. It might be easily doable in small projects but it can become messy for big ones.

Now, let’s show how we can improve this test. The title of my blogpost can give you a huge hint on the solution. We will add a named constructor called whatever to the Map object to centralize the object construction. Named constructors are static factories that build the object itself.

class Map 
{
    /** @internal */
    public static function whatever(
        string $mapId = 'e9a01a8a-9d40-476e-a946-06b159cd484a',
        string $addedBy = 'Pepito',
        string $name = 'Anglet city',
        string $description = 'Good places in Anglet',
        string $tag = 'city',
        array $markers = [],
    ): self {
        return new self(
            new MapId($mapId),
            new Username($addedBy),
            new MapName($name),
            new Description($description),
            new Tag($tag),
            new MarkerList($markers),
        );
    }
}

Tip: I like to add a @internal annotation to remind all teammates that the object constructor should only be used in tests.

Do you speak French ? Tired of the same old CRUD applications, struggling with your framework, or feeling the pressure of production releases? It's time to take your career to the next level.

Discover the power of Hexagonal Architecture and DDD to build robust and sustainable Symfony applications. Join me and kickstart your journey toward mastering advanced development techniques.

The value object instantiation is delegated to the whatever constructor. I try to use primitive data types like arguments as much as possible, it makes me write less code and it’s easier to read. All constructor arguments have a default value, then I can override a given value depending on the needs thanks to the named argument feature.

$map =  Map::whatever(name: 'Bordeaux city');

$map->rename('Anglet city');

Assert::equals(
    $map,
    Map::whatever(name: 'Anglet city')
);

Now, the test is clear and focuses on the right thing. Everyone can easily understand it, and it will help your teammates to grasp the code you wrote. Refactoring will be simplified as you only have to rewrite the whatever constructor if the signature of the primary constructor of Map changes.

I know that some people won’t like the idea of adding a method to objects only for testing purposes. If you don’t like that, you can replace this static factory with a builder.

class MapBuilder
{
    private string $mapId = 'e9a01a8a-9d40-476e-a946-06b159cd484a';
    private string $addedBy = 'Pepito';
    private string $name = 'Anglet city';
    private string $description = 'Good places in Anglet';
    private string $tag = 'city';
    private array $markers = [];

    public function identifiedBy(string $mapId): self
    {
        $this->mapId = $mapId;
        
        return $this;
    }

    public function named(string $name): self
    {
        $this->name = $name;

        return $this;
    }

    // ... other setters ....

    public function build(): Map {
        return new Map(
            new MapId($this->mapId),
            new Username($this->addedBy),
            new MapName($this->name),
            new Description($this->description),
            new Tag($this->tag),
            new MarkerList($this->markers),
        );
    }
}

Then your test will look like this:

$map =  (new MapBuilder())->named('Bordeaux city')->build();

$map->rename('Anglet city');

Assert::equals(
    $map,
    (new MapBuilder())->named('Anglet city')->build()
);

Tip: Read or anemic models don’t have logic to ensure they are built in a good way. If you use this method for them you can add some logic to your builder/factories to ensure they are created with consistent data. It will make your tests stronger.

Final thought

Builders or factories ease test refactoring and make tests more readable. Don’t forget that bad test suites are a nightmare to maintain and can drastically slow down your delivery. Taking care of your test quality will help you to ship fast. Moreover, good tests are free documentation.

Thanks to my proofreader @LaureBrosseau.