PHP Data Builders for unit testing

Daniel Ruiz Camacho
9 min readApr 13, 2023

--

Introduction

In this article, we will learn how to simplify our tests using data builders and how to implement them in an easy way in PHP.

The objective of this article is to increase clarity in our code and avoid to deal with big pieces of code to create an object to be used in our unit tests.

During the process, we will see different ways to create objects only for testing purpose. We will start with the most simple one that it’s creating the entity that we need in the tests that we are implementing. After that, we will take a next step using Object mothers and checking that everything is continue working as expected. With the last refactor, we will be able to get our main objective that it’s using a data builder to create our entity. About this last step, we will do a second refactor to avoid generating a lot of boiler plate code and use PHP magic method and trait to simplify our data builders.

Let’s take a look to a simple piece of code and how we can test it.

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

Example

We will work in a really easy example to see the benefits of this implementation. We will use two classes. One is Course and the another one is Lesson. Basically, in our example, we will see that a course has multiple lessons and a state. This state has some rules and this state field will help us a lot in our purpose to see the 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

On this way, we are creating the entity for the specific test that we are doing. As we said before, we need a course in progress to be finished. If it’s not in this state, we can’t finish the course and our test will fail.

Basically, we need to have a course with some lessons that it’s started to be finished. With this reference let’s build our finish test.

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 are creating lessons and a course and calling to the start method to have the entity in the status that we need to test properly our finish method.

Also, we need to test the exceptions and see if we are getting 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();
}

With this first iteration, we can see that our tests are hard to maintain and we need to generate an entity with the correct status in each test that we will execute. This is a lot of code that we need to maintain.

Second iteration. Create an object mother

Here you have an excellent article from Martin Fowler about the object mother pattern https://martinfowler.com/bliki/ObjectMother.html. In this article, you can see a clear explanation about object mothers but as a quick overview they are factory methods to have all the possible entities in only 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 basically using factory methods to create the course that we need in a reusable way. Applying this object mother pattern to the previous tests, we will have this new version of them.

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();
}

With this code we are able to simplify our tests doing them more readable and simpler but we have one inconvenience with this strategy. Our object mother class maybe has a lot of factory methods due to all the possible options that we need for our tests and we will finish with a lot of lines of code in this factory. Also, it’s really common that we will have two factory methods that generates the same entity due that maybe we don’t use a correct name (making easy for the next developer to find the expected course status) or we were not able to find the correct entity if we have a lot of factory methods.

The conclusion for the object mother pattern is that it gives us simplicity but is not flexible when we need a simple change in our data.

Third iteration. Data builders

With object mothers we commented that there is a lack of flexibility and for that reason we have the data builders to help us with this issue. Basically, this pattern allow us to create different objects step by step and with the same code we can produce different representations of our course model.

In our code, we will use our data builder only for testing purposes and to implement this pattern we will use reflection to modify private entities of our class.

To create our builder, we will use a trait to manage in only one place our reflection methods and avoid duplicated functionality in all 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 really easy to create courses with the info that we need for our tests and we only focus on the specific data that we need to modify. To test the finish course method the only important thing is the state of the course, it’s not relevant for us the lessons or other data of our course entity so we only need to modify the state and we do it with withState method.

Four iteration. Data builders let’s go further

We can see in our previous data builder, we have a lot of boiler plate code that we can simplify for efficiency and we will use again traits. If we take a look to the ‘with’ methods all of them are similar only changing the field and all our builders will have the ‘build’ and the ‘but’ methods.

Basically, we will rename our previous trait and we will call it BuilderTrait. The butand the buildmethods are really easy to move to the trait because we will have always the same functionality but it’s a little bit more difficult to move our ‘with’ methods.

To move our ‘with’ methods and avoid all this repetitive code we will use the magic method __call from PHP. This method is called when we are not able to find a method in our class.

<?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;
}
}

We can appreciate in the __call method we only accept methods that starts with the string ‘with’ and after that we should use the variable name of the class as we were doing in the previous builder.

So our __call method capture all the withXXX functions and set the value to the correct field inside the entity.

Now, we can check the difference between our previous data builder and the new one using our builder trait.

<?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 only these lines we are able to create our builder simplifying the work of our developers and creating a common way to work with them.

In the next snippet, we will see another builder and it’s 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 the method comment at the beginning of our classes, we help to our code editors to know the possible methods available.

Conclusion

Data builders give us flexibility and the possibility to create different entities for our tests in a really simple way. Apart from that, with the last refactor we are also able to reduce the repetitive code of our data builders giving us a lot of functionality with a low number of lines of code.

Here you have all the code used in this article with the different versions of our tests.

https://github.com/daniruizcamacho/unit-test-data-builder

--

--