Short story about design using tests

...basically is about how i do design using tests

Let's imagine that we need register user in our app...

yeah sophisticated task

How do you design register user feature?

Yay let's plan the database schema...

and use getters and setters everywhere

No, let's plan the domain first...

bin/phpspec desc "PHPBenelux\Domain\User"

What data are needed for User to use our system?

<?php

namespace spec\PHPBenelux\Domain;

use PhpSpec\ObjectBehavior;

class UserSpec extends ObjectBehavior
{
    function it_has_email_and_password()
    {
        $this->setEmail('leszek.prabucki@gmail.com');
        $this->email()->shouldBe('leszek.prabucki@gmail.com');

        $hash = password_hash('secretPass', PASSWORD_BCRYPT);
        $this->setPasswordHash();
        $this->passwordHash()->shouldBe($hash);
    }
}

Why not use constructor instead?

<?php

namespace spec\PHPBenelux\Domain;

use PhpSpec\ObjectBehavior;

class UserSpec extends ObjectBehavior
{
    function it_has_email_and_password()
    {
        $hash = password_hash('secretPass', PASSWORD_BCRYPT);

        $this->beConstructedWith('leszek.prabucki@gmail.com', $hash);

        $this->email()->shouldBe('leszek.prabucki@gmail.com');
        $this->passwordHash()->shouldBe($hash);
    }
}

We can add Value Objects to protect invariants

<?php

namespace spec\PHPBenelux\Domain;

use PHPBenelux\Domain\Email;
use PHPBenelux\Domain\PasswordHash;
use PHPBenelux\Domain\UserId;
use PhpSpec\ObjectBehavior;

class UserSpec extends ObjectBehavior
{
    function it_has_email_and_password()
    {
        $hash = password_hash('secretPass', PASSWORD_BCRYPT);

        $this->beConstructedWith(
            UserId::generate(),
            new Email('leszek.prabucki@gmail.com'),
            PasswordHash::fromHash($hash)
        );

        $this->id()->shouldHaveType(UserId::class);
        $this->email()->shouldBeLike(new Email('leszek.prabucki@gmail.com'));
        $this->passwordHash()->shouldBeLike(PasswordHash::fromHash($hash));
    }
}

bin/phpspec run

<?php

declare(strict_types=1);

namespace PHPBenelux\Domain;

class User
{
    public function __construct($argument1, $argument2, $argument3)
    {
        // TODO: write logic here
    }

    public function id()
    {
        // TODO: write logic here
    }
}
<?php

declare(strict_types=1);

namespace PHPBenelux\Domain;

class User
{
    private $id;
    private $email;
    private $passwordHash;

    public function __construct(UserId $id, Email $email, PasswordHash $passwordHash)
    {
        $this->id = $id;
        $this->email = $email;
        $this->passwordHash = $passwordHash;
    }

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

    public function email(): Email
    {
        return $this->email;
    }

    public function passwordHash(): PasswordHash
    {
        return $this->passwordHash;
    }
}

Ok, what now?

Register User service

<?php

namespace spec\PHPBenelux\Application\UseCase;

use Exception;
use InvalidArgumentException;
use PHPBenelux\Application\UseCase\RegisterUser;
use PHPBenelux\Application\TransactionManager;
use PHPBenelux\Domain\Users;
use PHPBenelux\Domain\UserFactory;
use PHPBenelux\Domain\User;
use PhpSpec\ObjectBehavior;

class RegisterUserSpec extends ObjectBehavior
{
    function let(
        TransactionManager $transactionManager,
        Users $users,
        UserFactory $userFactory
    ) {
        $this->beConstructedWith($transactionManager, $users, $userFactory);
    }

    function it_creates_and_store_user_in_repository(
        TransactionManager $transactionManager,
        Users $users,
        UserFactory $userFactory,
        User $user
    ) {
        $transactionManager->begin()->shouldBeCalled();
        $userFactory->create(
            'leszek.prabucki@gmail.com',
            'password'
        )->willReturn($user);
        $users->has($user)->willReturn(false);
        $users->add($user)->shouldBeCalled();
        $transactionManager->commit()->shouldBeCalled();

        $this->execute(new RegisterUser\Command(
            'leszek.prabucki@gmail.com',
            'password'
        ));
    }
}
<?php

declare(strict_types=1);

namespace PHPBenelux\Application\UseCase;

use PHPBenelux\Application\TransactionManager;
use PHPBenelux\Domain\UserFactory;
use PHPBenelux\Domain\Users;
use Throwable;

class RegisterUser
{
    private $transactionManager;
    private $users;
    private $userFactory;

    public function __construct(TransactionManager $transactionManager, Users $users, UserFactory $userFactory)
    {
        $this->transactionManager = $transactionManager;
        $this->users = $users;
        $this->userFactory = $userFactory;
    }

    public function execute(RegisterUser\Command $command): void
    {
        $this->transactionManager->begin();

        try {
            $user = $this->userFactory->create($command->email(), $command->password());
            if ($this->users->has($user)) {
                $this->transactionManager->rollback();
                return;
            }
            $this->users->add($user);
            $this->transactionManager->commit();
        } catch (Throwable $exception) {
            $this->transactionManager->rollback();

            throw $exception;
        }
    }
}

Doctrine Adapter

<?php

declare(strict_types=1);

namespace PHPBenelux\Adapter\Doctrine\Domain;

use Doctrine\ORM\EntityManagerInterface;
use PHPBenelux\Domain\Email;
use PHPBenelux\Domain\Exception\UserNotFound;
use PHPBenelux\Domain\Users as UsersInterface;

final class Users implements UsersInterface
{
    private $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function add(User $user): void
    {
        $this->entityManager->persist($user);
    }

    public function has(User $user): bool
    {
        return (bool) $this
            ->entityManager
            ->getRepository(User::class)
            ->findOneBy(['email.email' => (string) $user->email()])
        ;
    }

    public function get(Email $email): User
    {
        /** @var User $user */
        $user = $this->entityManager->getRepository(User::class)->findOneBy(['email.email' => (string) $email]);

        if (!$user) {
            throw new UserNotFound();
        }

        return $user;
    }
}

<?php

declare(strict_types=1);

namespace PHPBenelux\Adapter\Doctrine\Application;

use Doctrine\ORM\EntityManagerInterface;
use PHPBenelux\Application\TransactionManager as TransactionManagerInterface;

final class TransactionManager implements TransactionManagerInterface
{
    private $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function begin(): void
    {
        $this->entityManager->beginTransaction();
    }

    public function commit(): void
    {
        $this->entityManager->flush();
        $this->entityManager->commit();
    }

    public function rollback(): void
    {
        $this->entityManager->rollback();
    }
}

Thank you!

Short story about design using tests PHPBenelux uncon

By Leszek Prabucki

Short story about design using tests PHPBenelux uncon

  • 1,826