8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 329,
|
||
"slug": "editorial-2018-11-mechanism-php-integration-tests",
|
||
"title": "PHP-тесты: где mock заканчивается и начинается настоящая интеграция",
|
||
"excerpt": "Зелёный unit-тест не доказывает, что PHP отправил правильный SQL, открыл нужную базу и разобрал ответ драйвера. Разбираем границу между подстановкой и настоящим переходом через PDO.",
|
||
"contentHtml": "<p>Все unit-тесты сервиса проходят, но первая реальная запись падает с ошибкой SQL. Иногда приложение подключается не к той базе. Иногда запрос записывает значение не в тот столбец. Цена ошибки — ложная уверенность: команда меняет бизнес-логику, хотя тест ни разу не прошёл через PDO, схему и конфигурацию.</p>\n<p>Причина обычно проста. Тест подменяет репозиторий и заранее говорит ему вернуть <code>true</code> или массив. Такой тест проверяет реакцию сервиса на известный ответ. Он не проверяет, сможет ли настоящий репозиторий получить этот ответ. Тезис статьи короткий: unit-тест и integration-тест отвечают на разные вопросы. Первый изолирует правило. Второй оставляет настоящий переход там, где важен контракт между двумя частями системы.</p>\n<h2>Механизм: граница проходит по побочному эффекту</h2>\n<p>Unit-тест оставляет под контролем один класс. Соседей он заменяет объектами, которые возвращают заданные значения. Это полезно для проверки правил: запретить дубликат, вычислить скидку, выбрать ветку ошибки. Тест быстро показывает, что сервис делает при конкретном входе.</p>\n<p>Integration-тест оставляет настоящим один внешний переход. Для PHP-репозитория это путь <code>PHP → PDO → тестовая БД → PDO → PHP</code>. Внутри него работают драйвер, SQL, типы столбцов, индексы и преобразование результата. Для HTTP-клиента граница будет другой: URL, cURL, код ответа и разбор тела на управляемом endpoint. Не нужно поднимать весь сайт, если вопрос касается одного адаптера.</p>\n<p>Mock не плох и не хорош сам по себе. Ошибка появляется, когда его ответ считают доказательством работы ресурса. Mock не увидит отсутствующую миграцию, неверный DSN, ошибочное имя столбца, отсутствие PDO-драйвера или код ответа 500. Поэтому один сценарий часто нужно разделить на два теста: локальное правило и настоящий переход.</p>\n<figure><img src=\"/assets/editorial/2018/php-test-boundary-2018.svg\" alt=\"Граница между unit-тестом с подставным репозиторием и интеграционным тестом с настоящим PDO-адаптером и тестовой базой\" /><figcaption>Unit-тест проверяет решение сервиса. Integration-тест проверяет договор на границе с настоящим адаптером. Пунктир нельзя пересекать незаметно.</figcaption></figure>\n<h2>Пример: регистрация и проверка занятого email</h2>\n<p>Пусть сервис регистрации не должен создавать пользователя с уже занятым адресом. Локальное правило можно проверить без базы. Подставной репозиторий сообщает, что адрес существует, а сервис должен выбросить исключение. Этот пример учебный: он показывает только вопрос сервиса и не утверждает, что выполнялся в production.</p>\n<pre><code><?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}</code></pre>\n<p>Если этот тест зелёный, мы знаем только одно: при ответе <code>true</code> сервис отклоняет регистрацию. Мы не знаем, вернёт ли настоящий запрос <code>true</code>. Не знаем, совпадает ли схема с SQL. Не знаем, прочитал ли bootstrap переменную окружения. Именно поэтому следующий тест должен вызвать реальный адаптер.</p>\n<h2>Настоящий переход через PDO</h2>\n<p>Интеграционный тест репозитория подключается к отдельной тестовой схеме. В ней заранее есть таблица <code>customers</code> с полями <code>id</code>, <code>email</code> и <code>name</code>. Тест кладёт известную строку через PDO, вызывает публичный метод адаптера и проверяет результат. Ожидание не задаёт mock. Его возвращает настоящий запрос.</p>\n<p>Конфигурация должна быть test-only. Не подставляйте production DSN по умолчанию. Отдельный пользователь должен не иметь доступа к рабочей схеме. Пароль нельзя хранить в коде. Проверка имени базы в примере ниже — лишь аварийный барьер от очевидной ошибки. Она не заменяет права доступа, отдельную сеть и безопасное окружение.</p>\n<pre><code><?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}</code></pre>\n<p>Это учебный каркас, а не готовый файл для любого проекта. В нём предполагаются PHPUnit, PDO, заранее применённая миграция и класс <code>PdoCustomerLookup</code>. Он не создаёт контейнер, не содержит пароль и не вызывает рабочую БД. Команда должна подставить собственную тестовую схему и сверить синтаксис с закреплённой версией PHP и PHPUnit.</p>\n<p>Транзакция помогает убрать изменения данных после проверки. Она не очищает записи, созданные другим соединением, очередью или внешним сервисом. Она также не даёт универсального отката DDL: конкретная СУБД может неявно зафиксировать <code>CREATE TABLE</code> или <code>DROP TABLE</code>. Миграцию и подготовку схемы поэтому выполняют отдельным шагом.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика границы между подстановкой и интеграцией</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Unit зелёный, INSERT падает</td><td>Mock скрывает SQL, схему или драйвер</td><td>Запустить один репозиторий с test-only PDO и настоящей таблицей</td><td>Добавить узкий integration-тест записи и чтения</td></tr><tr><td>Тест читает пустой массив</td><td>Имя столбца или ключ результата изменился</td><td>Проверить SQL, схему и фактический <code>FETCH_ASSOC</code></td><td>Исправить адаптер или миграцию; не менять ожидание на пустое</td></tr><tr><td>Тест пропускается на CI</td><td>Нет DSN, пользователя или PDO-драйвера</td><td>Проверить test-only переменные и безопасную доступность схемы</td><td>Настроить изолированную среду либо честно оставить проверку отложенной</td></tr><tr><td>После теста остаются строки</td><td>Запись сделана вне текущей транзакции</td><td>Сравнить соединение репозитория с соединением теста</td><td>Передать PDO или фабрику явно; добавить уборку отдельного ресурса</td></tr><tr><td>Mock проверяет вызов HTTP</td><td>Запрос не ушёл к управляемому endpoint</td><td>Проверить URL, статус и тело на локальном endpoint</td><td>Оставить mock для правила, а сетевой контракт покрыть отдельно</td></tr></tbody></table></div>\n<h2>Порядок проверки</h2>\n<ol><li>Зафиксируйте симптом, который прошёл мимо unit-теста: ошибка SQL, неверное значение, пустой результат или неправильный статус HTTP.</li><li>Назовите владельца границы: репозиторий PDO, HTTP-клиент, файловый адаптер или загрузчик конфигурации.</li><li>Оставьте настоящий только этот переход. Остальные части замените простыми контролируемыми объектами.</li><li>Подготовьте test-only ресурс: отдельную схему, локальный endpoint или временный каталог. Production-ресурс не используйте.</li><li>Проверьте положительный путь и один отрицательный. Для БД это найденный и отсутствующий email; для HTTP — ожидаемый статус и отказ.</li><li>Ограничьте время и объём данных. Один тест должен объяснять одну границу, а не поднимать БД, очередь, письмо и HTML одновременно.</li><li>Убедитесь, что после теста не остаются строки, файлы, запросы или фоновые задачи. Если очистка невозможна, сделайте след операции уникальным и удаляемым.</li><li>Сохраните unit-тест рядом с integration-тестом. Они дополняют друг друга: один защищает правило, другой — склейку с ресурсом.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Integration-тест репозитория не доказывает, что HTML-форма передала правильное поле, cron запустился, письмо доставлено или партнёрский API доступен. Для каждого перехода нужна своя граница. Один зелёный тест не превращает mock в реальную проверку и не подтверждает состояние production.</p>\n<p>Если изолированной БД нет, нельзя назвать объект в памяти интеграцией. Корректный отрицательный путь — остановить проверку с понятной причиной и создать безопасную среду позже. Если тест упал на подключении, не меняйте ожидаемое значение ради зелёного отчёта. Сначала исправьте DSN, права или драйвер. Если упал SQL, проверьте миграцию и имена столбцов. Если внешний endpoint вернул ошибку, не отправляйте запрос в production из CI.</p>\n<p>Исторический код может использовать PHP 7.2 и PHPUnit 7.5. Эти версии не следует выбирать для нового проекта только по этому примеру. Зафиксируйте фактические версии в <code>composer.lock</code> и проверьте методы жизненного цикла, mock-объектов и настройки PDO по документации вашей версии.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Граница готова, если команда может назвать вход, настоящий ресурс, ожидаемый результат и безопасную уборку. Тест должен действительно выполнить запрос через PDO, прочитать запись тем же адаптером и показать отрицательный поиск. При отсутствии test-only DSN он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только после этого зелёный результат означает проверенный переход, а не удачный ответ подстановки.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://docs.phpunit.de/en/12.4/test-doubles.html\" target=\"_blank\" rel=\"noopener noreferrer\">PHPUnit: Test Doubles</a> — официальное описание подстановок и их роли в тестах.</li><li><a href=\"https://www.php.net/manual/en/pdo.begintransaction.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: PDO::beginTransaction</a> — границы транзакции PDO.</li><li><a href=\"https://www.php.net/manual/en/pdo.rollback.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: PDO::rollBack</a> — откат изменений и его ограничения.</li></ul>"
|
||
}
|