{ "index": 329, "slug": "editorial-2018-11-mechanism-php-integration-tests", "title": "PHP-тесты: где mock заканчивается и начинается настоящая интеграция", "excerpt": "Зелёный unit-тест не доказывает, что PHP отправил правильный SQL, открыл нужную базу и разобрал ответ драйвера. Разбираем границу между подстановкой и настоящим переходом через PDO.", "contentHtml": "

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

\n

Причина обычно проста. Тест подменяет репозиторий и заранее говорит ему вернуть true или массив. Такой тест проверяет реакцию сервиса на известный ответ. Он не проверяет, сможет ли настоящий репозиторий получить этот ответ. Тезис статьи короткий: unit-тест и integration-тест отвечают на разные вопросы. Первый изолирует правило. Второй оставляет настоящий переход там, где важен контракт между двумя частями системы.

\n

Механизм: граница проходит по побочному эффекту

\n

Unit-тест оставляет под контролем один класс. Соседей он заменяет объектами, которые возвращают заданные значения. Это полезно для проверки правил: запретить дубликат, вычислить скидку, выбрать ветку ошибки. Тест быстро показывает, что сервис делает при конкретном входе.

\n

Integration-тест оставляет настоящим один внешний переход. Для PHP-репозитория это путь PHP → PDO → тестовая БД → PDO → PHP. Внутри него работают драйвер, SQL, типы столбцов, индексы и преобразование результата. Для HTTP-клиента граница будет другой: URL, cURL, код ответа и разбор тела на управляемом endpoint. Не нужно поднимать весь сайт, если вопрос касается одного адаптера.

\n

Mock не плох и не хорош сам по себе. Ошибка появляется, когда его ответ считают доказательством работы ресурса. Mock не увидит отсутствующую миграцию, неверный DSN, ошибочное имя столбца, отсутствие PDO-драйвера или код ответа 500. Поэтому один сценарий часто нужно разделить на два теста: локальное правило и настоящий переход.

\n
\"Граница
Unit-тест проверяет решение сервиса. Integration-тест проверяет договор на границе с настоящим адаптером. Пунктир нельзя пересекать незаметно.
\n

Пример: регистрация и проверка занятого email

\n

Пусть сервис регистрации не должен создавать пользователя с уже занятым адресом. Локальное правило можно проверить без базы. Подставной репозиторий сообщает, что адрес существует, а сервис должен выбросить исключение. Этот пример учебный: он показывает только вопрос сервиса и не утверждает, что выполнялся в production.

\n
<?php\nuse PHPUnit\\Framework\\TestCase;\n\ninterface CustomerLookup\n{\n    public function existsByEmail(string $email): bool;\n}\n\nfinal class RegistrationService\n{\n    private $customers;\n\n    public function __construct(CustomerLookup $customers)\n    {\n        $this->customers = $customers;\n    }\n\n    public function register(string $email): void\n    {\n        if ($this->customers->existsByEmail($email)) {\n            throw new DomainException('Email is already registered');\n        }\n    }\n}\n\nfinal class RegistrationServiceTest extends TestCase\n{\n    public function testRejectsExistingEmail(): void\n    {\n        $customers = $this->createMock(CustomerLookup::class);\n        $customers->method('existsByEmail')\n            ->with('anna@example.test')\n            ->willReturn(true);\n\n        $service = new RegistrationService($customers);\n        $this->expectException(DomainException::class);\n        $service->register('anna@example.test');\n    }\n}
\n

Если этот тест зелёный, мы знаем только одно: при ответе true сервис отклоняет регистрацию. Мы не знаем, вернёт ли настоящий запрос true. Не знаем, совпадает ли схема с SQL. Не знаем, прочитал ли bootstrap переменную окружения. Именно поэтому следующий тест должен вызвать реальный адаптер.

\n

Настоящий переход через PDO

\n

Интеграционный тест репозитория подключается к отдельной тестовой схеме. В ней заранее есть таблица customers с полями id, email и name. Тест кладёт известную строку через PDO, вызывает публичный метод адаптера и проверяет результат. Ожидание не задаёт mock. Его возвращает настоящий запрос.

\n

Конфигурация должна быть test-only. Не подставляйте production DSN по умолчанию. Отдельный пользователь должен не иметь доступа к рабочей схеме. Пароль нельзя хранить в коде. Проверка имени базы в примере ниже — лишь аварийный барьер от очевидной ошибки. Она не заменяет права доступа, отдельную сеть и безопасное окружение.

\n
<?php\nfinal class PdoCustomerLookupIntegrationTest extends TestCase\n{\n    /** @var PDO */\n    private $pdo;\n\n    protected function setUp(): void\n    {\n        $dsn = (string) getenv('TEST_DATABASE_DSN');\n        if ($dsn === '' || strpos($dsn, 'test') === false) {\n            $this->markTestSkipped('An isolated test DSN is required');\n        }\n\n        $this->pdo = new PDO(\n            $dsn,\n            (string) getenv('TEST_DATABASE_USER'),\n            (string) getenv('TEST_DATABASE_PASSWORD'),\n            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,\n             PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]\n        );\n        $this->pdo->beginTransaction();\n        $this->pdo->prepare(\n            'INSERT INTO customers (email, name) VALUES (?, ?)'\n        )->execute(['anna@example.test', 'Анна']);\n    }\n\n    protected function tearDown(): void\n    {\n        if ($this->pdo instanceof PDO && $this->pdo->inTransaction()) {\n            $this->pdo->rollBack();\n        }\n    }\n\n    public function testFindsExistingEmail(): void\n    {\n        $lookup = new PdoCustomerLookup($this->pdo);\n\n        self::assertTrue($lookup->existsByEmail('anna@example.test'));\n        self::assertFalse($lookup->existsByEmail('missing@example.test'));\n    }\n}
\n

Это учебный каркас, а не готовый файл для любого проекта. В нём предполагаются PHPUnit, PDO, заранее применённая миграция и класс PdoCustomerLookup. Он не создаёт контейнер, не содержит пароль и не вызывает рабочую БД. Команда должна подставить собственную тестовую схему и сверить синтаксис с закреплённой версией PHP и PHPUnit.

\n

Транзакция помогает убрать изменения данных после проверки. Она не очищает записи, созданные другим соединением, очередью или внешним сервисом. Она также не даёт универсального отката DDL: конкретная СУБД может неявно зафиксировать CREATE TABLE или DROP TABLE. Миграцию и подготовку схемы поэтому выполняют отдельным шагом.

\n

Симптом → причина → проверка → действие

\n
Диагностика границы между подстановкой и интеграцией
СимптомПричинаПроверкаДействие
Unit зелёный, INSERT падаетMock скрывает SQL, схему или драйверЗапустить один репозиторий с test-only PDO и настоящей таблицейДобавить узкий integration-тест записи и чтения
Тест читает пустой массивИмя столбца или ключ результата изменилсяПроверить SQL, схему и фактический FETCH_ASSOCИсправить адаптер или миграцию; не менять ожидание на пустое
Тест пропускается на CIНет DSN, пользователя или PDO-драйвераПроверить test-only переменные и безопасную доступность схемыНастроить изолированную среду либо честно оставить проверку отложенной
После теста остаются строкиЗапись сделана вне текущей транзакцииСравнить соединение репозитория с соединением тестаПередать PDO или фабрику явно; добавить уборку отдельного ресурса
Mock проверяет вызов HTTPЗапрос не ушёл к управляемому endpointПроверить URL, статус и тело на локальном endpointОставить mock для правила, а сетевой контракт покрыть отдельно
\n

Порядок проверки

\n
  1. Зафиксируйте симптом, который прошёл мимо unit-теста: ошибка SQL, неверное значение, пустой результат или неправильный статус HTTP.
  2. Назовите владельца границы: репозиторий PDO, HTTP-клиент, файловый адаптер или загрузчик конфигурации.
  3. Оставьте настоящий только этот переход. Остальные части замените простыми контролируемыми объектами.
  4. Подготовьте test-only ресурс: отдельную схему, локальный endpoint или временный каталог. Production-ресурс не используйте.
  5. Проверьте положительный путь и один отрицательный. Для БД это найденный и отсутствующий email; для HTTP — ожидаемый статус и отказ.
  6. Ограничьте время и объём данных. Один тест должен объяснять одну границу, а не поднимать БД, очередь, письмо и HTML одновременно.
  7. Убедитесь, что после теста не остаются строки, файлы, запросы или фоновые задачи. Если очистка невозможна, сделайте след операции уникальным и удаляемым.
  8. Сохраните unit-тест рядом с integration-тестом. Они дополняют друг друга: один защищает правило, другой — склейку с ресурсом.
\n

Ограничения и отрицательный путь

\n

Integration-тест репозитория не доказывает, что HTML-форма передала правильное поле, cron запустился, письмо доставлено или партнёрский API доступен. Для каждого перехода нужна своя граница. Один зелёный тест не превращает mock в реальную проверку и не подтверждает состояние production.

\n

Если изолированной БД нет, нельзя назвать объект в памяти интеграцией. Корректный отрицательный путь — остановить проверку с понятной причиной и создать безопасную среду позже. Если тест упал на подключении, не меняйте ожидаемое значение ради зелёного отчёта. Сначала исправьте DSN, права или драйвер. Если упал SQL, проверьте миграцию и имена столбцов. Если внешний endpoint вернул ошибку, не отправляйте запрос в production из CI.

\n

Исторический код может использовать PHP 7.2 и PHPUnit 7.5. Эти версии не следует выбирать для нового проекта только по этому примеру. Зафиксируйте фактические версии в composer.lock и проверьте методы жизненного цикла, mock-объектов и настройки PDO по документации вашей версии.

\n

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

\n

Граница готова, если команда может назвать вход, настоящий ресурс, ожидаемый результат и безопасную уборку. Тест должен действительно выполнить запрос через PDO, прочитать запись тем же адаптером и показать отрицательный поиск. При отсутствии test-only DSN он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только после этого зелёный результат означает проверенный переход, а не удачный ответ подстановки.

\n

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

" }