{ "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-тест отвечают на разные вопросы. Первый изолирует правило. Второй оставляет настоящий переход там, где важен контракт между двумя частями системы.
Unit-тест оставляет под контролем один класс. Соседей он заменяет объектами, которые возвращают заданные значения. Это полезно для проверки правил: запретить дубликат, вычислить скидку, выбрать ветку ошибки. Тест быстро показывает, что сервис делает при конкретном входе.
\nIntegration-тест оставляет настоящим один внешний переход. Для PHP-репозитория это путь PHP → PDO → тестовая БД → PDO → PHP. Внутри него работают драйвер, SQL, типы столбцов, индексы и преобразование результата. Для HTTP-клиента граница будет другой: URL, cURL, код ответа и разбор тела на управляемом endpoint. Не нужно поднимать весь сайт, если вопрос касается одного адаптера.
Mock не плох и не хорош сам по себе. Ошибка появляется, когда его ответ считают доказательством работы ресурса. Mock не увидит отсутствующую миграцию, неверный DSN, ошибочное имя столбца, отсутствие PDO-драйвера или код ответа 500. Поэтому один сценарий часто нужно разделить на два теста: локальное правило и настоящий переход.
\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 переменную окружения. Именно поэтому следующий тест должен вызвать реальный адаптер.
Интеграционный тест репозитория подключается к отдельной тестовой схеме. В ней заранее есть таблица customers с полями id, email и name. Тест кладёт известную строку через PDO, вызывает публичный метод адаптера и проверяет результат. Ожидание не задаёт mock. Его возвращает настоящий запрос.
Конфигурация должна быть 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.
Транзакция помогает убрать изменения данных после проверки. Она не очищает записи, созданные другим соединением, очередью или внешним сервисом. Она также не даёт универсального отката DDL: конкретная СУБД может неявно зафиксировать CREATE TABLE или DROP TABLE. Миграцию и подготовку схемы поэтому выполняют отдельным шагом.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Unit зелёный, INSERT падает | Mock скрывает SQL, схему или драйвер | Запустить один репозиторий с test-only PDO и настоящей таблицей | Добавить узкий integration-тест записи и чтения |
| Тест читает пустой массив | Имя столбца или ключ результата изменился | Проверить SQL, схему и фактический FETCH_ASSOC | Исправить адаптер или миграцию; не менять ожидание на пустое |
| Тест пропускается на CI | Нет DSN, пользователя или PDO-драйвера | Проверить test-only переменные и безопасную доступность схемы | Настроить изолированную среду либо честно оставить проверку отложенной |
| После теста остаются строки | Запись сделана вне текущей транзакции | Сравнить соединение репозитория с соединением теста | Передать PDO или фабрику явно; добавить уборку отдельного ресурса |
| Mock проверяет вызов HTTP | Запрос не ушёл к управляемому endpoint | Проверить URL, статус и тело на локальном endpoint | Оставить mock для правила, а сетевой контракт покрыть отдельно |
Integration-тест репозитория не доказывает, что HTML-форма передала правильное поле, cron запустился, письмо доставлено или партнёрский API доступен. Для каждого перехода нужна своя граница. Один зелёный тест не превращает mock в реальную проверку и не подтверждает состояние production.
\nЕсли изолированной БД нет, нельзя назвать объект в памяти интеграцией. Корректный отрицательный путь — остановить проверку с понятной причиной и создать безопасную среду позже. Если тест упал на подключении, не меняйте ожидаемое значение ради зелёного отчёта. Сначала исправьте DSN, права или драйвер. Если упал SQL, проверьте миграцию и имена столбцов. Если внешний endpoint вернул ошибку, не отправляйте запрос в production из CI.
\nИсторический код может использовать PHP 7.2 и PHPUnit 7.5. Эти версии не следует выбирать для нового проекта только по этому примеру. Зафиксируйте фактические версии в composer.lock и проверьте методы жизненного цикла, mock-объектов и настройки PDO по документации вашей версии.
Граница готова, если команда может назвать вход, настоящий ресурс, ожидаемый результат и безопасную уборку. Тест должен действительно выполнить запрос через PDO, прочитать запись тем же адаптером и показать отрицательный поиск. При отсутствии test-only DSN он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только после этого зелёный результат означает проверенный переход, а не удачный ответ подстановки.
\n