DarkRiDDeR12 мин

PHP. Unit и integration: где заканчивается mock и начинается настоящий запрос

PHPТестирование

Все unit-тесты сервиса зелёные, но первая реальная запись падает с ошибкой SQL или читает не то значение из конфигурации. Цена такой зелени — ложная уверенность при рефакторинге: mock подтвердил договор с самим тестом, а не с драйвером БД, схемой и внешним адресом.

Один вопрос этой заметки: как провести границу между unit- и integration-тестом PHP, чтобы не назвать mock настоящей проверкой? В 2018 году достаточно простой модели. Сначала проверяем правило в классе без сети и БД, затем отдельно проводим один реальный переход через адаптер. Не надо заставлять каждый тест поднимать всё приложение.

Слово «unit» описывает контролируемую границу

Unit-тест оставляет под контролем сам класс и подменяет его соседей. Подстановка нужна не потому, что БД «плохая», а потому, что мы хотим быстро проверить одно правило: например, запрещает ли сервис дублирующий адрес до записи. В таком тесте объект-заглушка возвращает заранее выбранный ответ, а test case проверяет реакцию сервиса. Он не зависит от таблицы, сети и текущего значения переменной окружения.

Integration-тест оставляет настоящий переход там, где важен договор двух частей. Для PHP-репозитория это драйвер PDO, запрос и тестовая схема. Для HTTP-клиента это формирование запроса, cURL и управляемый тестовый endpoint. Оба вида тестов могут вызывать один сервис, но отвечают на разные вопросы. Ошибка начинается, когда они получают одинаковое название и от одного ждут доказательства другого.

Схема границы тестов PHP: unit-тест заменяет порт репозитория и проверяет решение сервиса; integration-тест оставляет реальный PDO-адаптер или HTTP-клиент и проверяет договор на границе процесса.
Пунктир обозначает место подстановки. Всё, что осталось за ним, unit-тест не способен проверить независимо от числа ожиданий.

Один сценарий можно разложить на два точных вопроса

Представим регистрацию пользователя. Правило «не создавать запись для уже занятого email» удобно проверить unit-тестом: он получает подставной репозиторий, который сообщает, что адрес существует. Но сам SQL с WHERE email = ?, тип колонки и сопоставление строки с массивом PHP остаются за границей. Их должен покрыть отдельный integration-тест конкретного PDO-репозитория.

ВопросUnit-тестIntegration-тестПризнак лишней работы
Правило дубликатаПодставной репозиторий отвечает trueНе обязателен в каждом варианте правилаПоднимать БД, чтобы проверить одно условие if
SQL и имена столбцовНе проверяетВыполняет настоящий запрос в тестовой схемеСравнивать строку SQL с копией этой же строки в тесте
Тип результата PDOМожно задать массив вручнуюПоказывает фактический FETCH_ASSOC и преобразованиеСчитать mock доказательством работы драйвера
HTTP-запросПроверяет, что клиент был вызван с нужными даннымиПроверяет URL, код ответа и разбор ответа на локальном endpointПосылать тест в боевой API
КонфигурацияПередаёт строку явно в конструкторБерёт test-only значение из окружения и проверяет отказ при его отсутствииИспользовать production значение по умолчанию

Unit-тест: правило без настоящей БД

Ниже минимальный пример на PHPUnit 7. Он не называет объект mock только ради модного слова: репозиторий подставлен, потому что тест проверяет решение RegistrationService до момента записи. Вход и ожидаемый отказ видны прямо в коде. Такой тест быстро падает, если автор случайно удалит проверку существующего адреса, и не требует доступной БД для каждого запуска.

<?php
use PHPUnit\Framework\TestCase;

interface CustomerLookup
{
    public function existsByEmail(string $email): bool;
}

final class RegistrationService
{
    private $customers;

    public function __construct(CustomerLookup $customers)
    {
        $this->customers = $customers;
    }

    public function register(string $email): void
    {
        if ($this->customers->existsByEmail($email)) {
            throw new DomainException('Email is already registered');
        }
    }
}

final class RegistrationServiceTest extends TestCase
{
    public function testRejectsAnExistingEmail(): void
    {
        $customers = $this->createMock(CustomerLookup::class);
        $customers->method('existsByEmail')->with('anna@example.test')->willReturn(true);

        $service = new RegistrationService($customers);
        $this->expectException(DomainException::class);
        $service->register('anna@example.test');
    }
}

Этот код сознательно не содержит PDO, getenv() и URL. Если он зелёный, мы знаем ровно одно: при ответе true сервис бросает ожидаемое исключение. Мы не знаем, вернёт ли реальный запрос true, доступна ли нужная таблица и не передал ли bootstrap в репозиторий другой DSN. Чем точнее сформулирован вывод, тем меньше соблазн считать этот тест универсальной страховкой.

Integration-тест: настоящий адаптер вместо предположения

Чтобы проверить репозиторий, unit-тест выше не расширяют ожиданиями на SQL. Создают второй тест для PdoCustomerLookup. Он передаёт адаптеру PDO, подключённый только к тестовой схеме, кладёт известную строку в пределах транзакции и делает настоящий запрос. Ожидаемое значение выводится не из настройки mock-а, а из таблицы через тот же путь, по которому пойдёт приложение.

<?php
final class PdoCustomerLookupIntegrationTest extends TestCase
{
    /** @var PDO */
    private $pdo;

    protected function setUp(): void
    {
        $this->pdo = TestPdo::fromEnvironment();
        $this->pdo->beginTransaction();
        $this->pdo->prepare('INSERT INTO customers (email, name) VALUES (?, ?)')
            ->execute(array('anna@example.test', 'Анна'));
    }

    protected function tearDown(): void
    {
        if ($this->pdo->inTransaction()) {
            $this->pdo->rollBack();
        }
    }

    public function testFindsExistingEmail(): void
    {
        $lookup = new PdoCustomerLookup($this->pdo);

        $this->assertTrue($lookup->existsByEmail('anna@example.test'));
        $this->assertFalse($lookup->existsByEmail('missing@example.test'));
    }
}

Здесь целевое поведение всё ещё небольшое: два адреса, один настоящий запрос, одна транзакция. Если в таблице вместо email теперь mail, тест покажет реальную ошибку. Если драйвер возвращает строку в неожиданной кодировке или DSN не открывается, это уже не «красный unit-тест», а след того, что договор адаптера или окружения изменился.

Как появляются фальшиво-зелёные проверки

Фальшивая зелень возникает не из-за самого mock-объекта. Она появляется, когда его результат становится единственным доказательством внешней границы. Test double заранее научен вернуть true, поэтому он никогда не увидит отсутствие миграции, ошибочный DNS, пустой TEST_CALLBACK_URL или код ответа 500. Такой объект нужен для unit-вопроса, но его нельзя использовать для ответа на другой вопрос.

Зелёный тест говоритЧего он не виделМинимальная настоящая проверкаСледующее действие
Сервис вызвал save()SQL, транзакцию и индексОдин INSERT и чтение через PDO в test DBДобавить integration-тест адаптера
Клиент получил URL строкойDNS, cURL, статус и тело ответаЛокальный HTTP endpoint с ожидаемым статусомПроверить код и разбор ответа
Конструктор получил DSNКак bootstrap прочёл окружениеЗапуск с TEST_ переменными и отказ без нихЗафиксировать test-only конфигурацию
Mock вернул массивНастоящий формат строки БД или JSONАдаптер читает учебный ответ ресурсаПроверить преобразование на границе

Выбираю границу по риску, а не по названию папки

Папки tests/Unit и tests/Integration помогают ориентироваться, но не делают код правильным сами. Сначала называем побочный эффект: запись в БД, HTTP-вызов, файловая система, очередь или конфигурация. Затем оставляем реальным только один из них. Если тест одновременно поднимает БД, отправляет письмо и строит HTML, он слишком широкий для поиска причины. Если он заменяет все ресурсы, он не ловит ошибки склейки.

  1. Выписать один симптом, который прошёл мимо unit-тестов: SQL, HTTP, конфигурация или преобразование данных.
  2. Назвать класс, который владеет границей, например PdoCustomerLookup или CallbackClient.
  3. Оставить настоящий только этот адаптер, а остальные соседние части заменить простыми контролируемыми объектами.
  4. Подготовить test-only ресурс: отдельную схему, локальный HTTP endpoint или временный каталог; production ресурс не использовать.
  5. Проверить положительный и один отрицательный путь, который показывает понятную ошибку границы.
  6. Оставить unit-тест правила рядом с integration-тестом адаптера: они дополняют, а не дублируют друг друга.

Версии и ограничения нельзя прятать

Пример рассчитан на синтаксис PHP 7.2 и PHPUnit 7.5. Эти версии уже не поддерживаются на дату редакционного пересмотра, поэтому в новом проекте их не стоит выбирать по этой статье. Для исторического кода важно закрепить фактическую версию в composer.lock и сверить методы createMock(), setUp() и конфигурацию именно с ней.

Интеграционный тест не заменяет ручную проверку прав production-пользователя и не даёт разрешения обращаться к внешнему партнёру из CI. Его задача скромнее: сделать конкретную техническую границу наблюдаемой в контролируемой среде. Когда эта среда отсутствует, это известное ограничение проекта, которое нужно исправить организационно, а не спрятать под зелёным mock-ом.

Итог: два теста вместо одного громкого названия

Unit-тест быстро подтверждает локальное правило. Integration-тест подтверждает договор с настоящим адаптером. Первый должен быть маленьким и не требовать БД, второй — узким и не уходить в production. Когда в отчёте видны оба пути, зелёный цвет перестаёт означать «мы надеемся» и начинает означать конкретно проверенный переход.

Проверяемые источники