Как начать разрабатывать через тестирование (TDD): или как перестать искать баги в 3 часа ночи

Вы, может, слышали про TDD (Test-Driven Development) — «Разработку через тестирование». А может, где-то натыкались на папку tests/ с кучей файлов, которые, кажется, никто никогда не запускает. Знакомо?

А теперь представьте классическую ситуацию: проект разрастается, вы добавляете безобидную, на первый взгляд, фичу, деплоите её… А в понедельник утром прод тихо плачет, потому что новая логика сломала какую-то древнюю, но рабочую часть системы.

Начинается детектив: ползёте по коду, ставите var_dump(), запускаете дебагер, пьёте третий литр кофе и наконец находите: «А, блин, тут забыл проверку на null».

Именно в такие моменты тесты перестают быть «бумажной работой» и становятся вашим личным телохранителем. Хочется иметь тест на каждый чих, чтобы при любом изменении просто нажать кнопку и спать спокойно.

Вступление: узнай себя

Те, кто хочет проводить своё свободное время за отладкой в пятницу вечером — тесты не пишут. Или пишут их «потом», когда проект уже сдан, а основные бизнес-правила давно выветрились из головы вместе с контекстом.

Те, кто хочет проводить свободное время за чем угодно, кроме поиска регрессий — пишут тесты с самого первого дня. И делают это через TDD.

Как на самом деле выглядит процесс?

Довольно «скучно», если честно. Сначала, как обычно, пытаемся подумать, как будет работать программа и что в ней главное. Потом пишем первый тест. Запускаем его… и видим, что всё «красное»!11

💡 И это отлично. Серьёзно. Если тест сразу зелёный, значит, вы либо протестировали воздух, либо у вас уже есть магия в настройках CI. Красный цвет в TDD — это не ошибка. Это дорожный знак: «Здесь пока ничего нет. Стройте».

Дальше пишем минимальную реализацию, чтобы заставить тест пройти. Снова запускаем… и так по кругу, пока тест не станет стабильно «зелёным». Это как чинить машину, заранее зная, какой именно шум она должна перестать издавать.

Практика: начнём с малого

Возьмём что-нибудь жизненное. Представьте сайт, который позволяет публиковать контент на разных языках. Для выбора языка нужна сущность 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
🎉 Ура! Официально начал TDD. Тест упал не потому, что где-то накосячил, а потому, что класса физически не существует. Это приглашение к действию.

Реализуем 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 — это не про бюрократию. Это про то, чтобы ваш код говорил вам: «Я готов к изменениям, я всё проверю сам».


P.S. Время — ценный ресурс, который расходуется очень быстро и незаметно.

Тесты — это не трата времени. Это инвестиция в ваш будущий спокойный сон и выходные без ноутбука. Попробуйте. Ваш «я из прошлого» обязательно скажет спасибо. 😊

Обсуждение

💬 Есть вопросы? Пишите в Telegram-канал или на ping@ambrion.dev