PHP Data Builders for unit testing

Daniel Ruiz Camacho
9 min readApr 13, 2023

--

Introduction

In this article, we will explore how to simplify our tests using data builders and how to implement them effectively in PHP.

The goal of this article is to enhance the clarity of our code and avoid dealing with large chunks of code to create objects for use in our unit tests.

Throughout the process, we will examine different methods for creating objects solely for testing purposes. We will start with the simplest approach — creating the entity we need directly within the tests we’re implementing. Next, we’ll take a step forward by using Object Mothers and ensuring that everything continues to work as expected. Finally, we’ll achieve our main goal: using a data builder to create our entities. In this last step, we’ll refactor the code further to avoid generating excessive boilerplate code by leveraging PHP’s magic methods and traits to simplify our data builders.

Let’s begin by looking at a simple piece of code and how we can test it.

As a reference, here is a GitHub repository containing all the code used in this article: https://github.com/daniruizcamacho/unit-test-data-builder

Example

We will work through a simple example to demonstrate the benefits of this implementation. We will use two classes: one called Course and another called Lesson. In our example, a course will have multiple lessons and a state. This state follows certain rules, and it will play a crucial role in helping us explore different implementations.

<?php

declare(strict_types=1);

namespace App;

use Assert\Assertion;

class Course
{
private const ACCEPTED_STATE = 'accepted';
private const IN_PROGRESS_STATE = 'inProgress';
private const FINISHED_STATE = 'finished';
private const CANCELLED_STATE = 'cancelled';

private string $state;

public function __construct(
private int $id,
private array $lessons,
) {
Assertion::allIsInstanceOf($lessons, Lesson::class);

$this->state = self::ACCEPTED_STATE;
}

public function id(): int
{
return $this->id;
}

public function lessons(): array
{
return $this->lessons;
}

public function state(): string
{
return $this->state;
}

public function totalTimeInSeconds(): int
{
$total = 0;
foreach ($this->lessons as $lesson) {
$total += $lesson->timeInSeconds();
}

return $total;
}

public function start(): void
{
if ($this->state !== self::ACCEPTED_STATE) {
throw new \Exception("The order can't be started. State = {$this->state}");
}

$this->state = self::IN_PROGRESS_STATE;
}

public function cancel(): void
{
if ($this->state !== self::ACCEPTED_STATE) {
throw new \Exception("The order can't be cancelled. State = {$this->state}");
}

$this->state = self::CANCELLED_STATE;
}

public function finish(): void
{
if ($this->state !== self::IN_PROGRESS_STATE) {
throw new \Exception("The order can't be finished. State = {$this->state}");
}

$this->state = self::FINISHED_STATE;
}
}
<?php

declare(strict_types=1);

namespace App;

use Assert\Assertion;

class Lesson
{
private const MIN_TIME = 0;

public function __construct(
private int $id,
private string $name,
private int $timeInSeconds
) {
Assertion::greaterThan($timeInSeconds, self::MIN_TIME);
Assertion::notEmpty($name);
}

public function id(): int
{
return $this->id;
}

public function name(): string
{
return $this->name;
}

public function timeInSeconds(): int
{
return $this->timeInSeconds;
}
}

We will focus the article in this method:

public function finish(): void
{
if ($this->state !== self::IN_PROGRESS_STATE) {
throw new \Exception("The order can't be finished. State = {$this->state}");
}

$this->state = self::FINISHED_STATE;
}

To test this method, we need a course that it’s in progress and we will see different ways to get a course with this state.

First iteration. Create the object in the test

In this approach, we create the entity specifically for the test we’re conducting. As mentioned earlier, we need a course that is in progress in order to mark it as finished. If the course is not in this state, we cannot finish it, and our test will fail.

Essentially, we need to have a course with some lessons that has already started in order to complete it. With this in mind, let’s build our test for the finish functionality.

public function testFinishCourse(): void
{
$expectedId = 1;
$expectedState = 'finished';

$lesson1 = new Lesson(1, 'Lesson 1', 500);
$lesson2 = new Lesson(2, 'Lesson 2', 1000);

$course = new Course(1, [$lesson1, $lesson2]);
$course->start();
$course->finish();

$this->assertSame($expectedId, $course->id());
$this->assertSame($expectedState, $course->state());
}

With this code, we create lessons and a course, then call the start method to ensure the entity is in the correct state for properly testing our finish method. Additionally, we need to test the exceptions to verify that we receive the expected ones.

public static function finishExceptionDataProvider(): array
{
$lesson1 = new Lesson(1, 'Lesson 1', 500);
$lesson2 = new Lesson(2, 'Lesson 2', 1000);

$cancelledCourse = new Course(1, [$lesson1, $lesson2]);
$cancelledCourse->cancel();

$acceptedCourse = new Course(2, [$lesson1, $lesson2]);

$finishedCourse = new Course(3, [$lesson1, $lesson2]);
$finishedCourse->start();
$finishedCourse->finish();

return [
'cancelled course' => [$cancelledCourse],
'accepted course' => [$acceptedCourse],
'finished course' => [$finishedCourse],
];
}

/**
* @dataProvider finishExceptionDataProvider
*/
public function testFinishExceptionCourse(Course $course): void
{
$this->expectException(\Exception::class);
$course->finish();
}

In this initial iteration, we can see that our tests are difficult to maintain. We need to generate an entity with the correct status for each test we execute, resulting in a lot of code that requires upkeep.

Second iteration. Create an object mother

Here is an excellent article by Martin Fowler on the Object Mother pattern: Object Mother. In this article, you’ll find a clear explanation of Object Mothers. To give a brief overview, Object Mothers are factory methods that centralize the creation of all possible entities in one place.

<?php

declare(strict_types=1);

namespace App\Tests\v2;

use App\Course;

class CourseObjectMother
{
public static function anAcceptedCourse(): Course
{
return new Course(
1,
[LessonObjectMother::aLesson('name', 500)]
);
}

public static function anInProgressCourse(): Course
{
$course = new Course(
1,
[LessonObjectMother::aLesson('name', 500)]
);
$course->start();

return $course;
}

public static function aCancelledCourse(): Course
{

$course = new Course(
1,
[LessonObjectMother::aLesson('name', 500)]
);
$course->cancel();

return $course;
}

public static function aFinishedCourse(): Course
{

$course = new Course(
1,
[LessonObjectMother::aLesson('name', 500)]
);
$course->start();
$course->finish();

return $course;
}
}

As you can see in the previous code, we are essentially using factory methods to create the course we need in a reusable way. By applying the Object Mother pattern to our earlier tests, we arrive at this new version.

public function testFinishCourse(): void
{
$expectedState = 'finished';

$course = CourseObjectMother::anInProgressCourse();
$course->finish();

$this->assertSame($expectedState, $course->state());
}

public static function finishExceptionDataProvider(): array
{
return [
'cancelled course' => [CourseObjectMother::aCancelledCourse()],
'accepted course' => [CourseObjectMother::anAcceptedCourse()],
'finished course' => [CourseObjectMother::aFinishedCourse()],
];
}

/**
* @dataProvider finishExceptionDataProvider
*/
public function testFinishExceptionCourse(Course $course): void
{
$this->expectException(\Exception::class);
$course->finish();
}

This code allows us to simplify our tests, making them more readable and straightforward. However, there’s a drawback to this strategy. Our Object Mother class may end up with numerous factory methods due to the various options needed for our tests, resulting in a large amount of code in this factory. Additionally, it’s common to have duplicate factory methods that generate the same entity because we either didn’t choose the right name (making it difficult for future developers to find the correct course status) or couldn’t locate the correct entity among many factory methods.

The conclusion with the Object Mother pattern is that, while it provides simplicity, it lacks flexibility when we need to make minor changes to our data.

Third iteration. Data builders

While Object Mothers provide simplicity, they often lack flexibility. To address this issue, we use Data Builders. This pattern allows us to create different objects step by step and, with the same code, produce various representations of our course model.

In our code, we will use Data Builders exclusively for testing purposes. To implement this pattern, we will use reflection to modify private properties of our class. To streamline the process, we will employ a trait to manage our reflection methods in one place, avoiding code duplication across different builders.

<?php

declare(strict_types=1);

namespace App\Tests\v3;

trait ReflectionTrait
{
public function createInstance(string $className): object
{
$reflection = new \ReflectionClass($className);

return $reflection->newInstanceWithoutConstructor();
}

public function setPrivateValue(object $object, string $property, mixed $value): void
{
$refObject = new \ReflectionObject($object);
$refProperty = $refObject->getProperty($property);
$refProperty->setAccessible(true);
$refProperty->setValue($object, $value);
$refProperty->setAccessible(false);
}
}

Here you can see an example of our data builder using the previous trait.

<?php

declare(strict_types=1);

namespace App\Tests\v3;

use App\Course;

class CourseDataBuilder
{
use ReflectionTrait;

private const DEFAULT_ID = 1;
private const DEFAULT_STATE = 'accepted';

private Course $course;

private function __construct()
{
$this->course = $this->createInstance(Course::class);
$this->setPrivateValue($this->course, 'id', self::DEFAULT_ID);
$this->setPrivateValue($this->course, 'state', self::DEFAULT_STATE);
$this->setPrivateValue($this->course, 'lessons', [LessonDataBuilder::aLesson()->build()]);
}

public static function aCourse(): self
{
return new self();
}

public function build(): Course
{
return $this->course;
}

public function but(): self
{
return clone $this;
}

public function withId(int $id): self
{
$this->setPrivateValue($this->course, 'id', $id);

return $this;
}

public function withState(string $state): self
{
$this->setPrivateValue($this->course, 'state', $state);

return $this;
}

public function withLessons(array $lessons): self
{
$this->setPrivateValue($this->course, 'lessons', $lessons);

return $this;
}
}

In the next code, we will use our data builders:

public function testFinishCourse(): void
{
$expectedId = 1;
$expectedState = 'finished';

$course = CourseDataBuilder::aCourse()
->withId($expectedId)
->withState('inProgress')
->build();
$course->finish();

$this->assertSame($expectedId, $course->id());
$this->assertSame($expectedState, $course->state());
}

public static function finishExceptionDataProvider(): array
{

$cancelledCourse = CourseDataBuilder::aCourse()
->withState('cancelled')
->build();

$acceptedCourse = CourseDataBuilder::aCourse()
->withState('accepted')
->build();

$finishedCourse = CourseDataBuilder::aCourse()
->withState('finished')
->build();

return [
'cancelled course' => [$cancelledCourse],
'accepted course' => [$acceptedCourse],
'finished course' => [$finishedCourse],
];
}

As we can see, it’s easy to create courses with the necessary information for our tests, allowing us to focus solely on the specific data that needs modification. To test the finishCourse method, the key factor is the state of the course. The lessons or other data within the course entity are not relevant for this test. Therefore, we only need to modify the state, which we accomplish using the withState method.

Four iteration. Data builders let’s go further

In our previous data builder, we encountered a lot of boilerplate code that we can simplify for efficiency using traits once again. The with methods in our builders are very similar, differing only by the field they modify. Additionally, all our builders include the build and but methods.

To streamline this, we will rename our previous trait to BuilderTrait. The but and build methods can be easily moved to this trait, as they provide consistent functionality. However, moving the with methods requires a bit more effort due to their repetitive nature.

To handle the with methods and reduce repetitive code, we will use PHP's magic method __call. This method is invoked when a method is not found in the class, allowing us to dynamically handle method calls and simplify our code.

<?php

declare(strict_types=1);

namespace App\Tests\v4;

trait BuilderTrait
{
private object $entity;

public function createInstance(string $className): void
{
$reflection = new \ReflectionClass($className);

$this->entity = $reflection->newInstanceWithoutConstructor();
}

private function setPrivateValue(object $object, string $property, mixed $value): void
{
$refObject = new \ReflectionObject($object);
$refProperty = $refObject->getProperty($property);
$refProperty->setAccessible(true);
$refProperty->setValue($object, $value);
$refProperty->setAccessible(false);
}


public function build(): object
{
return $this->entity;
}

public function but(): self
{
return clone $this;
}

public function __call(string $name, $value)
{
if (!str_starts_with($name, 'with')) {
throw new \BadMethodCallException("Method '{$name}' is not supported");
}

$property = lcfirst(str_replace('with', '', $name));
$value = current($value);

$this->setPrivateValue($this->entity, $property, $value);

return $this;
}
}

In the __call method, we only accept methods that start with the string 'with'. After that, we use the variable name of the class, similar to how we did in the previous builder.

The __call method captures all the withXXX functions and sets the value to the corresponding field inside the entity.

Now, we can compare our previous data builder with the new one that uses the BuilderTrait to see the improvements.

<?php

declare(strict_types=1);

namespace App\Tests\v4;

use App\Course;
use App\Tests\v3\LessonDataBuilder;

/**
* @method CourseDataBuilder withId(int $id)
* @method CourseDataBuilder withState(string $state)
* @method CourseDataBuilder withLessons(array $lessons)
*/
class CourseDataBuilder
{
use BuilderTrait;

private const DEFAULT_ID = 1;
private const DEFAULT_STATE = 'accepted';

private function __construct()
{
$this->createInstance(Course::class);
$this->withId(self::DEFAULT_ID);
$this->withState(self::DEFAULT_STATE);
$this->withLessons([LessonDataBuilder::aLesson()->build()]);
}

public static function aCourse(): self
{
return new self();
}
}

With just these lines of code, we have streamlined the creation of our builders, simplifying development and establishing a consistent approach.

In the next snippet, we will examine another builder, which is quite similar to the previous one.

<?php

declare(strict_types=1);

namespace App\Tests\v4;

use App\Lesson;

/**
* @method LessonDataBuilder withId(int $id)
* @method LessonDataBuilder withName(string $name)
* @method LessonDataBuilder withTimeInSeconds(int $timeInSeconds)
*/
class LessonDataBuilder
{
use BuilderTrait;

private const DEFAULT_ID = 1;
private const DEFAULT_NAME = 'Default Lesson Name';
private const DEFAULT_TIME_IN_SECONDS = 300;

public function __construct()
{
$this->createInstance(Lesson::class);
$this->withId(self::DEFAULT_ID);
$this->withName(self::DEFAULT_NAME);
$this->withTimeInSeconds(self::DEFAULT_TIME_IN_SECONDS);
}

public static function aLesson(): self
{
return new self();
}
}

Adding method comments at the beginning of our classes helps code editors recognize the available methods.

Conclusion

Data builders provide flexibility and allow us to create different entities for our tests in a straightforward manner. Additionally, the recent refactor has reduced the repetitive code in our data builders, offering extensive functionality with fewer lines of code.

You can find all the code used in this article, including the various versions of our tests, at the following GitHub repository: https://github.com/daniruizcamacho/unit-test-data-builder

--

--