A practical example of how to apply the Test-Driven development technique in PHP.

Are you familiar with the Test-Driven Development? Most of you probably are. There is an excellent definition of the TDD approach written by famous Martin Fowler on his site:
Test-Driven Development (TDD) is a technique for building software that guides software development by writing tests. It was developed by Kent Beck in the late 1990’s as part of Extreme Programming. In essence you follow three simple steps repeatedly:
1. Write a test for the next bit of functionality you want to add. 2. Write the functional code until the test passes. 3. Refactor both new and old code to make it well structured.
Is this technique practically useful you may ask? It is! In some cases anyway. I personally always follow this strategy when I am about to write a piece of code that incorporates a lot of mathematical operations on the input.
A perfect example would be a calculator of any kind. For instance, a VAT calculator. We can quickly start with a test:
<?php
function test_it_calculates_vat_amount(): void
{
$strategy = new PolishVatStrategy();
$product = new Product();
$product->setPrice(120);
$vatAmount = $strategy->calculate($product);
self::assertEquals(27.6, $vatAmount);
}To apply TDD correctly we should:
- execute the test and make sure it does not pass,
- write the code that makes this test go green as quickly as possible
- finally refactor it to make the code beautiful and easy to comprehend
Now to the real deal! Let’s write something useful for a change. Our product owner put a task in the backlog. He says the registration form of our corporate page sucks because every time somebody wants to register, who is already registered, a nasty error page is displayed. He wants to prevent this and instead portray an error that “the e-mail address is already taken”.
Since the object used for the registration is a simple DTO used by a form, we’re going to add a new annotation constraint and an accompanied validator. Let’s call this constraint EmailAddressNotRegistered.
Let’s start with a series of unit tests then what we would like to happen. Since the validator is going to require database access, we’re going to mock the user repository and inject a fake object — an in-memory implementation of the UserRepository. The following features were requested:
- it must not allow already registered e-mail addresses
- it also must not allow aliases of already registered addresses
- it must allow unregistered e-mails
- it must allow registered e-mails of soft-deleted users
The following unit tests were written to ensure the class complies with these requirements. I’ve used Symfony’s ConstraintValidatorTestCase that allows easy validator testing:
<?php
declare(strict_types=1);
namespace Test\App\Validator;
use App\Entity\User;
use App\Validator\EmailAddressNotRegistered;
use App\Validator\EmailAddressNotRegisteredValidator;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
use Test\Fake\InMemoryUserRepository;
final class EmailAddressNotRegisteredValidatorTest extends ConstraintValidatorTestCase
{
/**
* This method is required and needs to return the test subject.
*/
protected function createValidator(): EmailAddressNotRegisteredValidator
{
$userFactory = function (string $email, bool $deleted): User {
$user = new User();
$user->setEmail($email);
$user->setDeleted($deleted);
return $user;
};
// stub user repository
// I prefer real objects over mocks whenever I can
$users = new InMemoryUserRepository([
$userFactory('jane@email.local', false),
$userFactory('bob@corporate.local', false),
$userFactory('deleted@private.local', true),
]);
// finally return the test subject with mocked database access
return new EmailAddressNotRegisteredValidator($users);
}
public function test_it_does_not_allow_already_registered_user(): void
{
$constraint = new EmailAddressNotRegistered();
$this->validator->validate('bob@corporate.local', $constraint);
$this->buildViolation($constraint->message)->assertRaised();
}
public function test_it_does_not_allow_already_registered_user_using_alias(): void
{
$constraint = new EmailAddressNotRegistered();
$this->validator->validate('jane+office@email.local', $constraint);
$this->buildViolation($constraint->message)->assertRaised();
}
public function test_it_allows_unregistered_users(): void
{
$constraint = new EmailAddressNotRegistered();
$this->validator->validate('monty@corporate.local', $constraint);
$this->assertNoViolation();
}
public function test_it_allows_soft_deleted_users_to_register_again(): void
{
$constraint = new EmailAddressNotRegistered();
$this->validator->validate('deleted@corporate.local', $constraint);
$this->assertNoViolation();
}
}
We execute this test and it fails as it is supposed to. Sure enough, we still have no constraint and no validator:
PHPUnit 8.5.23 by Sebastian Bergmann and contributors.
EEEE 4 / 4 (100%)
Time: 34 ms, Memory: 4.00 MB
We need the constraint:
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* @Annotation
*/
final class EmailAddressNotRegistered extends Constraint
{
/**
* @var string
*/
public $message = 'This e-mail address is already registered';
/**
* @return class-string<ConstraintValidator>
*/
public function validatedBy(): string
{
return EmailAddressNotRegisteredValidator::class;
}
public function getTargets(): string
{
return self::PROPERTY_CONSTRAINT;
}
}
And the validator to make sure tests pass as effortlessly as we can:
medium-tdd-validator-initial.php
<?php
declare(strict_types=1);
namespace App\Validator;
use App\Entity\User;
use App\Repository\UserRepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
final class EmailAddressNotRegisteredValidator extends ConstraintValidator
{
private UserRepositoryInterface $userRepository;
public function __construct(UserRepositoryInterface $userRepository)
{
$this->userRepository = $userRepository;
}
/**
* {@inheritDoc}
*/
public function validate($value, Constraint $constraint): void
{
if (false === ($constraint instanceof EmailAddressNotRegistered)) {
throw new UnexpectedValueException(
$constraint,
EmailAddressNotRegistered::class
);
}
if (!is_string($value) || empty($value)) {
return;
}
$users = $this->userRepository->findAllByEmail(
preg_replace('/\+.*?@/', '@', $value)
);
$users = array_filter(
$users,
fn (User $user): bool => false === $user->isDeleted()
);
if (count($users) > 0) {
$this->context->addViolation($constraint->message);
}
}
}
When we now run the test suite:
.... 4 / 4 (100%)
Time: 34 ms, Memory: 4.00 MB
OK (4 tests, 6 asto easily test a validator:sertions)
We’re green! Now let’s refactor the code too:
medium-tdd-validator-final.php
<?php
declare(strict_types=1);
namespace App\Validator;
use App\Entity\User;
use App\Repository\UserRepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
final class EmailAddressNotRegisteredValidator extends ConstraintValidator
{
private UserRepositoryInterface $userRepository;
public function __construct(UserRepositoryInterface $userRepository)
{
$this->userRepository = $userRepository;
}
/**
* {@inheritDoc}
*/
public function validate($value, Constraint $constraint): void
{
if (false === ($constraint instanceof EmailAddressNotRegistered)) {
throw new UnexpectedValueException(
$constraint,
EmailAddressNotRegistered::class
);
}
if (!is_string($value) || empty($value)) {
return;
}
$emailAddressWithoutAlias = preg_replace('/\+.*?@/', '@', $value);
$allUsersByEmail = $this->userRepository->findAllByEmail(
$emailAddressWithoutAlias
);
$usersDeletedExcluded = array_filter(
$allUsersByEmail,
fn (User $user): bool => false === $user->isDeleted(),
);
if (count($usersDeletedExcluded) > 0) {
$this->context->addViolation($constraint->message);
}
}
}
Final test suite run, and…
.... 4 / 4 (100%)
Time: 37 ms, Memory: 4.00 MB
OK (4 tests, 6 assertions)
We can now safely use this constraint on our registration DTO and mark the task as done.
<?php
declare(strict_types=1);
namespace App\UseCase\Registration;
use App\Validator\EmailAddressNotRegistered;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @psalm-immutable
*/
final class RegisterDto
{
/**
* @Assert\NotBlank()
* @Assert\Email()
* @Assert\Length(max=255)
* @EmailAddressNotRegistered()
*/
public string $email;
}
Hopefully this will make our product owner happy for a change.

The code was published on a GitHub repository by the way. If you happen to have Docker, you can run entire test suite in an instant. Happy coding!
Ps. the coding techniques I used are described in the following publication, make sure you read it:
Clean code tricks in PHP everyone should follow Improve your code drastically with these few simple tricks. Today I would like to share my coding techniques with you.blog.devgenius.io
Quick question: was this story of any value to you? Please support my work by leaving a clap as a token of appreciation. Thank you.