Вступление: узнай себя
Те, кто хочет проводить своё свободное время за отладкой в пятницу вечером — тесты не пишут. Или пишут их «потом», когда проект уже сдан, а основные бизнес-правила давно выветрились из головы вместе с контекстом.
Те, кто хочет проводить свободное время за чем угодно, кроме поиска регрессий — пишут тесты с самого первого дня. И делают это через TDD.
Как на самом деле выглядит процесс?
Довольно «скучно», если честно. Сначала, как обычно, пытаемся подумать, как будет работать программа и что в ней главное. Потом пишем первый тест. Запускаем его… и видим, что всё «красное»!11
Дальше пишем минимальную реализацию, чтобы заставить тест пройти. Снова запускаем… и так по кругу, пока тест не станет стабильно «зелёным». Это как чинить машину, заранее зная, какой именно шум она должна перестать издавать.
Практика: начнём с малого
Возьмём что-нибудь жизненное. Представьте сайт, который позволяет публиковать контент на разных языках. Для выбора языка нужна сущность Language. Но прежде чем бросаться создавать её, подумаем: а что главное у языка? Локаль. Это то, как мы определяем его уникальность на уровне кода. Значит, начнём с Locale.
Буду использовать PHPUnit — проверенный инструмент для тестирования PHP-приложений. Приступим.
Создам первый тест на проверку правильности создания локали:
<?php
declare(strict_types=1);
namespace App\Tests\Language\Domain\ValueObject;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class LocaleTest extends TestCase
{
#[Test]
public function it_creates_valid_locale(): void
{
$locale = new Locale('ru_RU');
self::assertSame('ru_RU', (string)$locale);
}
}
Что тут получилось?
- Назвал тест
it_creates_valid_locale(). Сразу понятно, что именно он проверяет. Никакой магии в названиях, пожалуйста. - Предполагаю, что VO будет называться
Localeи принимать строку видаru_RU. - Добавил условие: ожидаем, что созданная локаль при приведении к строке будет в точности равна эталону.
Запускаю тест и, конечно, вижу ошибку:
Error: Class "App\Tests\Language\Domain\ValueObject\Locale" not found
Реализуем VO Locale
Сам VO будет жить тут src/Language/Domain/ValueObject/Locale.php:
<?php
declare(strict_types=1);
namespace App\Language\Domain\ValueObject;
use App\Language\Domain\Exception\InvalidLocaleException;
final readonly class Locale
{
private const string REGEX = '/^[a-z]{2}([_-][A-Z]{2})?$/';
public function __construct(private string $value)
{
if (!preg_match(self::REGEX, $value)) {
throw InvalidLocaleException::forFormat($value);
}
}
public function __toString(): string
{
return $this->value;
}
public function getLanguageCode(): string
{
return strtolower(explode('-', str_replace('_', '-', $this->value))[0]);
}
public function getRegionCode(): ?string
{
$parts = explode('-', str_replace('_', '-', $this->value));
return $parts[1] ?? null;
}
}
Основная проверка идёт тут:
private const string REGEX = '/^[a-z]{2}([_-][A-Z]{2})?$/';
Да, регулярки — это отдельный вид искусства, но сегодня она просто охраняет вход от мусора.
В случае, когда VO Locale будет некорректным, будем вежливо, но твёрдо выбрасывать исключение.
Оно будет жить тут src/Language/Domain/Exception/InvalidLocaleException.php:
><?php
declare(strict_types=1);
namespace App\Language\Domain\Exception;
final class InvalidLocaleException extends DomainException
{
public static function forFormat(string $value): self
{
return new self(sprintf('Неверный формат локали: "%s". Ожидается BCP-47 (например: ru, ru_RU, en-GB).', $value));
}
}
А DomainException поживёт рядом
src/Language/Domain/Exception/DomainException.php:
<?php
declare(strict_types=1);
namespace App\Language\Domain\Exception;
use RuntimeException;
abstract class DomainException extends RuntimeException
{
}
Обновляю тест, импортируя созданный VO
use App\Language\Domain\ValueObject\Locale;:
<?php
declare(strict_types=1);
namespace App\Tests\Language\Domain\ValueObject;
use App\Language\Domain\ValueObject\Locale;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class LocaleTest extends TestCase
{
#[Test]
public function it_creates_valid_locale(): void
{
$locale = new Locale('ru_RU');
self::assertSame('ru_RU', (string)$locale);
}
}
Запускаю тест… опа-па, повезло! Тест «зелёный», значит, прошёл! Первый кирпичик в фундамент заложен. Никакого рефакторинга пока, только удовлетворение от того, что код делает ровно то, что от него потребовали.
Теперь можно включить режим «здорового параноика» и добавить ещё варианты. Что если передадут локаль через дефис? А если без региона? А если пустую строку или полную ерунду вроде russian?
#[Test]
public function it_creates_valid_locale_with_hyphen(): void
{
$locale = new Locale('en-GB');
self::assertSame('en-GB', (string)$locale);
}
#[Test]
public function it_creates_valid_locale_without_region(): void
{
$locale = new Locale('uk');
self::assertSame('uk', (string)$locale);
}
#[Test]
public function it_throws_exception_for_empty_locale(): void
{
$this->expectException(InvalidLocaleException::class);
new Locale('');
}
#[Test]
public function it_throws_exception_for_invalid_format(): void
{
$this->expectException(InvalidLocaleException::class);
new Locale('russian');
}
#[Test]
public function it_throws_exception_for_uppercase_start(): void
{
$this->expectException(InvalidLocaleException::class);
new Locale('RU');
}
Запускаю и радуюсь зелёному цвету. В консоли он особенно прекрасен, как свежий утренний ветер в горах.
Поднимаемся на уровень выше: Сущность
Локаль — это хорошо, но бизнесу нужен сам язык. Создам второй тест:
<?php
declare(strict_types=1);
namespace App\Tests\Language\Domain\Entity;
use App\Language\Domain\Entity\Language;
use App\Language\Domain\Enum\LanguageStatus;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class LanguageTest extends TestCase
{
#[Test]
public function it_creates_language_with_default_status(): void
{
$language = Language::create(
title: 'Русский',
locale: new Locale('ru_RU'),
sortOrder: 10
);
self::assertNull($language->getId());
self::assertSame('Русский', $language->getTitle());
self::assertSame('ru_RU', (string)$language->getLocale());
self::assertSame(10, $language->getSortOrder());
self::assertSame(LanguageStatus::ACTIVE, $language->getStatus());
}
}
Что тут получилось?
- Тест
it_creates_language_with_default_status()— проверяем, что сущность создаётся корректно и получает статус по умолчанию. - Предположил, что сущность умеет создавать себя через статический фабричный метод, принимая
title,localeиsortOrder. - Для локали переиспользую уже протестированный VO. TDD любит переиспользование.
- Добавил ассерты: ID пока
null(ещё не в БД), заголовок совпадает, локаль совпадает, порядок сортировки на месте, статус —ACTIVE. - Заранее прикинул, что понадобится Enum для статусов, и сразу намекнул на это в тесте.
Запускаю тест и вижу классическое «красное» падение:
Error: Class "App\Language\Domain\Entity\Language" not found
Упало на моменте Language, потому что сущности ещё нет. Штош, пора её создать! Тесты работают как GPS: «Поверните налево, создайте класс».
Создаю сущность Language
Жить сущность будет тут src/Language/Domain/Entity/Language.php:
<?php
declare(strict_types=1);
namespace App\Language\Domain\Entity;
use App\Language\Domain\Enum\LanguageStatus;
use App\Language\Domain\ValueObject\Locale;
use InvalidArgumentException;
final class Language
{
private ?int $id = null;
private function __construct(
private string $title,
private readonly Locale $locale,
private int $sortOrder,
private LanguageStatus $status
)
{
if (trim($title) === '') {
throw new InvalidArgumentException('Название языка не может быть пустым.');
}
if ($sortOrder < 0) {
throw new InvalidArgumentException('Порядок сортировки не может быть отрицательным.');
}
}
public static function create(string $title, Locale $locale, int $sortOrder, LanguageStatus $status = LanguageStatus::ACTIVE): self
{
return new self($title, $locale, $sortOrder, $status);
}
public static function restore(int $id, string $title, Locale $locale, int $sortOrder, LanguageStatus $status): self
{
$instance = new self($title, $locale, $sortOrder, $status);
$instance->id = $id;
return $instance;
}
public function getId(): ?int
{
return $this->id;
}
public function assignId(int $id): void
{
if ($this->id !== null) {
throw new InvalidArgumentException('ID уже присвоен. Нельзя изменить ID существующего языка.');
}
$this->id = $id;
}
public function getTitle(): string
{
return $this->title;
}
public function changeTitle(string $title): void
{
if (trim($title) === '') {
throw new InvalidArgumentException('Название языка не может быть пустым.');
}
$this->title = $title;
}
public function getLocale(): Locale
{
return $this->locale;
}
public function getSortOrder(): int
{
return $this->sortOrder;
}
public function changeSortOrder(int $sortOrder): void
{
if ($sortOrder < 0) {
throw new InvalidArgumentException('Порядок сортировки не может быть отрицательным.');
}
$this->sortOrder = $sortOrder;
}
public function getStatus(): LanguageStatus
{
return $this->status;
}
public function changeStatus(LanguageStatus $status): void
{
$this->status = $status;
}
}
Запускаю тест снова… и вижу другую ошибку:
Error: Class "App\Language\Domain\Enum\LanguageStatus" not found
Ага, пришло время создать Enum! Тесты честно говорят, чего им не хватает.
Жить он будет тут src/Language/Domain/Enum/LanguageStatus.php:
<?php
declare(strict_types=1);
namespace App\Language\Domain\Enum;
enum LanguageStatus: int
{
case ACTIVE = 1;
case INACTIVE = 0;
public function isActive(): bool
{
return $this === self::ACTIVE;
}
}
Запускаю тест снова… мммм, красота:
OK (1 test, 5 assertions)
Тест прошёл. Никакого адреналина, только чистый дзен.
Фантазирую, что ещё может пойти не так, и добавляю новые тесты:
#[Test]
public function it_creates_language_with_custom_status(): void
{
$language = Language::create(
title: 'English',
locale: new Locale('en_US'),
sortOrder: 20,
status: LanguageStatus::INACTIVE
);
self::assertSame(LanguageStatus::INACTIVE, $language->getStatus());
}
#[Test]
public function it_throws_exception_for_empty_title(): void
{
$this->expectException(InvalidArgumentException::class);
Language::create(
title: '',
locale: new Locale('ru_RU'),
sortOrder: 1
);
}
#[Test]
public function it_throws_exception_for_negative_sort_order(): void
{
$this->expectException(InvalidArgumentException::class);
Language::create(
title: 'Українська',
locale: new Locale('uk_UA'),
sortOrder: -5
);
}
#[Test]
public function it_allows_changing_status(): void
{
$language = Language::create('English', new Locale('en_US'), 1);
$language->changeStatus(LanguageStatus::INACTIVE);
self::assertSame(LanguageStatus::INACTIVE, $language->getStatus());
}
#[Test]
public function it_allows_changing_title(): void
{
$language = Language::create('Old', new Locale('de_DE'), 1);
$language->changeTitle('Deutsch');
self::assertSame('Deutsch', $language->getTitle());
}
#[Test]
public function it_allows_changing_sort_order(): void
{
$language = Language::create('Test', new Locale('fr_FR'), 5);
$language->changeSortOrder(15);
self::assertSame(15, $language->getSortOrder());
}
#[Test]
public function it_restores_id_from_persistence(): void
{
// Симуляция загрузки из БД
$language = Language::restore(
id: 42,
title: 'Русский',
locale: new Locale('ru_RU'),
sortOrder: 10,
status: LanguageStatus::ACTIVE
);
self::assertSame(42, $language->getId());
}
Запускаю и снова радуюсь зелёному цвету:
OK (8 tests, 12 assertions)
Итог: почему это того стоит
На этом этапе у меня уже есть разработанные через тестирование:
- VO
Locale - Enum
LanguageStatus - сущность
Language - пара исключений для корректной работы
А также целых 16 тестов, которые будут служить страховкой для дальнейшего развития проекта!
/app $ php bin/phpunit tests/Language/Domain/
PHPUnit 13.1.7 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.20
Configuration: /app/phpunit.xml
................ 16 / 16 (100%)
Time: 00:00.014, Memory: 16.00 MB
OK (16 tests, 24 assertions)
Да, это маленький шаг. Да, сначала кажется, что писать тесты «до кода» — это долго и непривычно. Но вспомните тот самый понедельник, когда фича сломала прод, а вы в панике искали, где именно. TDD — это не про бюрократию. Это про то, чтобы ваш код говорил вам: «Я готов к изменениям, я всё проверю сам».
Тесты — это не трата времени. Это инвестиция в ваш будущий спокойный сон и выходные без ноутбука. Попробуйте. Ваш «я из прошлого» обязательно скажет спасибо. 😊