Skip to content

Buildotter Core: Foundation to implement the Test Data Builder Pattern

License

Notifications You must be signed in to change notification settings

buildotter/php-core

Repository files navigation

Buildotter Core

Tests License GitHub release (latest SemVer) Conventional Commits

Foundation to implement the Test Data Builder Pattern.

Installation

composer require --dev buildotter/php-core

What's the Test Data Builder Pattern?

Creating and managing test data is a critical aspect of ensuring the quality and reliability of an application.

The Test Data Builder Pattern is a technique for simplifying the process of constructing test data, making tests more maintainable and readable.

It provides a structured way to create complex and consistent test data for your test cases.

This pattern isolate the process of creating test data from the actual test logic.

By doing so, it promotes separation of concerns and helps you write cleaner and more focused tests.

It helps for the following problems:

  • How to decouple object creation from implementation in your tests?
  • How to improve code readability in your tests?
  • How to speed up writing your test setup?

The 4 rules to keep in mind are:

  • Have a property for each constructor parameter
  • Initialize its properties to commonly used or safe values
  • Have a build method that creates a new object using the values in its properties
  • Have chainable public methods for overriding the values in its properties

To know more about the Test Data Builder Pattern, you can read the following articles:

Why do we need libraries to implement the Test Data Builder Pattern?

We don't.

It is more about helping with repetitive tasks, speed up the process of creating test data builders and being focused on the test logic itself.

With time passing, we gathered parts that are common to projects. So why not share them?

Furthermore, it helps to have a common foundation for the adoption of the pattern in a team.

Usage

We tried not to be too opinionated about the foundation here. For instance, we like to use functions like anElephant() instead of ElephantBuilder::random() (we find it more readable and allow us to focus on what is important) but nothing forces you to do the same.

The main foundation: Buildotter\Core\Buildatable interface

You may found the name weird, it is a mix of "build" and "data" with the suffix "-able". "-able" is used to form adjectives from verbs, with the meaning "capable of, fit for, tending to". With this name, we want to express that the object is capable of building data as a test data builder.

The Buildotter\Core\Buildatable::random() method is the named constructor to create a new instance of the class with random but commonly used or safe values. Imagine that you develop a dating app, you know the age of your customers. Most of the time you don't mind about the exact age, you just need one respecting the invariants of your domain (see Propery-Based Testing for more about this). It is less likely that a customer is 300 or 10 years old than between 18 and 60 years old for instance.

The Buildotter\Core\Buildatable::build() method to create a new object using the values in its properties.

A ::with() method with Buildotter\Core\BuildableWithXXX traits

We provide some traits that already implement a ::with() method.

Buildotter\Core\BuildableWithArgUnpacking allows you to use named arguments to set the properties of the builder.

anElephant()->with(
    name: 'Elmer',
    birth: new \DateTimeImmutable(),
);

Buildotter\Core\BuildableWithArray allows you to set properties of the builder by passing an array as its argument.

anElephant()->with([
    'name' => 'Elmer',
    'birth' => new \DateTimeImmutable(),
]);

You may prefer one or the other depending on your preferences. You may choose to use both. You may choose to not use them at all.

Build multiple objects

You may need multiple objects of the same type at once. Buildotter\Core\RandomMultiple static methods are here to help you.

Example

Imagine you have a class Elephant with the following constructor:

namespace App\Entity;

final class Elephant
{
    public function __construct(
        private Uuid $id,
        private string $name,
        private \DateTimeImmutable $birth,
        /** @var Topic[] */
        private array $topics = [],
    ) {}
}

You may create a test data builder for this class like this:

namespace App\Fixtures\Builder;

use App\Entity\Elephant;
use App\Entity\Topic;
use Buildotter\Core\Buildatable;
use Buildotter\Core\BuildableWithArgUnpacking;

/**
 * @implements Buildatable<Elephant>
 */
final readonly class ElephantBuilder implements Buildatable
{
    use BuildableWithArgUnpacking;

    public function __construct(
        private Uuid $id,
        private string $name,
        private \DateTimeImmutable $birth,
        /** @var Topic[] */
        private array $topics,
    ) {}

    public static function random(): self
    {
        $random = \random();

        // Note that we try to use commonly used or safe values.
        return new self(
            new Uuid(),
            $random->name(),
            \DateTimeImmutable::createFromMutable($random->dateTimeBetween('-50 years', 'now')),
            // You may see here that it may be necessary to build multiple objects sometimes.
            \someTopics($random->numberBetween(0, 10)),
        );
    }

    public function build(): Elephant
    {
        return new Elephant(
            $this->id,
            $this->name,
            $this->birth,
            $this->topics
        );
    }

    // Providing domain specific methods instead of only relying on the `with()` method
    // allows you to focus on what is important in your tests.
    // It helps to make your tests more readable and maintainable.
    //
    // Note here that we did not find the need to implement a domain specific method
    // to change the `$topics` property.
    // We may rely on the `with()` method as long as we don't find useful to have
    // a method like `interestedInTopics(array $topics)` or whatever.

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

    public function bornThe(\DateTimeImmutable|string $birth): self
    {
        return $this->with(birth: $birth instanceof \DateTimeImmutable ? $birth : \DateTimeImmutable::createFromFormat('Y-m-d', $birth));
    }

    public function yearsOld(int $age): self
    {
        if ($age <= 0) {
            throw new \InvalidArgumentException('Age must be positive');
        }

        return $this->with(birth: new \DateTimeImmutable(\sprintf('-%d years', $age)));
    }

    public function minor(): self
    {
        return $this->yearsOld(\random()->numberBetween(1, 17));
    }
}

Feel free to create functions like anElephant() and someTopics() to make your tests more readable.

For instance, we may have a file containing the following functions:

use App\Fixtures\Builder\ElephantBuilder;
use App\Fixtures\Builder\TopicBuilder;

function random(): \Faker\Generator
{
    return \Faker\Factory::create();
}

function anElephant(): ElephantBuilder
{
    return ElephantBuilder::random();
}

/**
 * @return App\Entity\Elephant[]
 */
function someElephants(int|null $numberOfItems = null): array
{
    return RandomMultiple::from(ElephantBuilder::class, $numberOfItems);
}

function aTopic(): TopicBuilder
{
    return TopicBuilder::random();
}

/**
 * @return App\Entity\Topic[]
 */
function someTopics(int|null $numberOfItems = null): array
{
    return RandomMultiple::from(TopicBuilder::class, $numberOfItems);
}

And then, during testing:

use PHPUnit\Framework\TestCase;

final class RentBookTest extends TestCase
{
    public function test_a_minor_cannot_rent_a_book_for_adult(): void
    {
        $minor = \anElephant()->minor()->build();

        expectException(UnderAgeException::class);

        (new RentBook())($minor, \aBook()->forAdult()->build());
    }
}

More

To generate test data builders, you may use the Buildotter Maker Standalone. Instead, you may prefer to use PHPStorm Live templates. Or combine them.

Question: Do you think that it could be helpful to share some live templates that helps for testing?

Contributing

See the contributing guide.

TODOs

  • provide a symfony bridge in a dedicated package

About

Buildotter Core: Foundation to implement the Test Data Builder Pattern

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Packages

No packages published

Languages