8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 328,
|
||
"slug": "editorial-2018-11-field-php-integration-tests",
|
||
"title": "PHP: зелёный тест и нерабочая форма — как проверить БД, HTTP и конфигурацию",
|
||
"excerpt": "Модульный тест может пройти, пока форма падает на настоящем DSN, SQL или HTTP-ответе. Разбираем короткую интеграционную трассу PHP с тестовой БД, локальным callback и явным критерием готовности.",
|
||
"contentHtml": "<p>В учебном сценарии форма регистрации отвечает 500 после отправки, хотя unit-тест сервиса зелёный. Иногда запись в БД создаётся, но уведомление не уходит. Иногда запрос даже не доходит до базы. Цена ошибки — потерянное время на исправление бизнес-логики и риск замаскировать проблему новым mock-ом. Такой тест снова станет зелёным, но не проверит DSN, SQL, cURL и код ответа.</p>\n<p><strong>Интеграционный тест нужен там, где ошибка возникает на стыке компонентов.</strong> Для PHP это может быть путь «конфигурация → PDO → тестовая БД → сервис → локальный HTTP callback». Unit-тест оставляем для правил внутри класса. Интеграционный тест проходит через настоящий адаптер и проверяет наблюдаемый результат. Ниже — учебный пример. Он не вызывает партнёрский URL и не утверждает, что команды уже выполнялись в production.</p>\n<figure><img src=\"/assets/editorial/2018/php-false-green-trace-2018.svg\" alt=\"Трасса PHP-теста от test-only конфигурации через PDO и тестовую базу к локальному HTTP callback\" /><figcaption>Один учебный идентификатор связывает запись в БД, HTTP-попытку и проверку ответа. Это помогает разделить соседние ошибки, а не заменить наблюдение общим «тест упал».</figcaption></figure>\n<h2>Что именно ломает зелёный тест</h2>\n<p>Unit-тест обычно передаёт сервису память вместо репозитория и тестовый двойник вместо HTTP-клиента. В PHPUnit stub управляет входом, а mock проверяет вызовы; ниже для краткости оставим условный SpyCallbackClient. Такой тест проверяет порядок вызовов: сначала создать регистрацию, потом отправить уведомление. Это полезное утверждение, но оно не открывает PDO, не читает переменную окружения и не получает ответ сервера.</p>\n<pre><code><?php\n$repository = new MemoryRegistrationRepository();\n$callback = new SpyCallbackClient();\n$service = new RegistrationService($repository, $callback);\n\n$service->register('registration-test-42', 'anna@example.test');\n\n$this->assertSame(\n [['registration-test-42', 42]],\n $callback->messages\n);</code></pre>\n<p>Этот код может пройти при пустом DSN, отсутствии таблицы и неверном URL callback. В нём нет дефекта. Ошибка появляется, когда его называют проверкой всей регистрации. Название теста не расширяет его границу.</p>\n<p>Интеграционный тест фиксирует более узкий контракт: тестовая конфигурация разрешает соединение; репозиторий записывает валидные поля; чтение возвращает нужную запись, а приложение приводит значения к ожидаемым типам; клиент отправляет запрос на локальный endpoint; код принимает только ожидаемый статус и тело. Почта, браузер и доступность партнёра остаются другими контрактами.</p>\n<h2>Сначала отделите среду от кода</h2>\n<p>Тест должен остановиться до соединения, если не задана test-only конфигурация. Не используйте production DSN как значение по умолчанию. Префикс <code>TEST_</code> не заменяет права доступа, но делает намерение видимым. Отдельный пользователь БД, отдельная схема и запрет на production DNS важнее проверки имени переменной.</p>\n<pre><code><?php\nfinal class TestPdo\n{\n public static function fromEnvironment(): PDO\n {\n $dsn = (string) getenv('TEST_DATABASE_DSN');\n $user = (string) getenv('TEST_DATABASE_USER');\n $password = (string) getenv('TEST_DATABASE_PASSWORD');\n\n if ($dsn !== 'mysql:host=127.0.0.1;dbname=registration_test') {\n throw new RuntimeException(\n 'TEST_DATABASE_DSN must equal the isolated test DSN'\n );\n }\n\n return new PDO($dsn, $user, $password, [\n PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,\n PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,\n ]);\n }\n}</code></pre>\n<p>Точное сравнение DSN — проектное правило примера, а не универсальный способ определить безопасную базу. В рабочем проекте зафиксируйте разрешённый хост, имя схемы и пользователя отдельными настройками запуска и не собирайте DSN из непроверенного ввода. Пароль не выводите в исключение и отчёт.</p>\n<h2>Разделите симптом, причину, проверку и действие</h2>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Тест падает до INSERT</td><td>Пустой DSN, драйвер или права</td><td>Вывести безопасное имя схемы и тип исключения</td><td>Проверить test-only окружение и миграцию</td></tr><tr><td>INSERT проходит, чтение пустое</td><td>Неверный столбец, фильтр или схема</td><td>Прочитать запись тем же репозиторием по ID</td><td>Сравнить SQL, миграцию и типы результата</td></tr><tr><td>В логах нет HTTP-попытки</td><td>Сервис остановился после ошибки БД</td><td>Связать шаги одним request ID</td><td>Сначала исправить границу БД</td></tr><tr><td>HTTP вернул 200 вместо ожидаемого 202</td><td>Маршрут или callback не тот</td><td>Проверить URL без секретов и HTTP-код</td><td>Исправить test-only URL или договор ответа</td></tr><tr><td>Повторный прогон видит старые данные</td><td>Транзакция не откатилась или второе соединение обошло её</td><td>Проверить <code>inTransaction()</code> и соединения</td><td>Откатывать PDO и отдельно чистить следы HTTP</td></tr></tbody></table></div>\n<p>Логируйте только то, что помогает выбрать следующую проверку: учебный ID, тип ошибки, имя тестовой схемы, HTTP-код. Не записывайте токены, пароли и полное тело запроса, если оно может содержать персональные данные.</p>\n<h2>Настоящий PDO, но только тестовая база</h2>\n<p>Тест ниже предполагает, что таблица уже создана миграцией в выделенной схеме. Миграцию не запускайте внутри сценария: некоторые СУБД выполняют неявный commit на DDL, и откат данных перестаёт быть надёжным. Проверяем один путь записи и чтения через публичные методы репозитория. Синтаксис примера не использует typed properties и рассчитан на PHP 7.2.</p>\n<pre><code><?php\nfinal class RegistrationIntegrationTest extends TestCase\n{\n private $pdo;\n\n protected function setUp()\n {\n $this->pdo = TestPdo::fromEnvironment();\n $this->pdo->beginTransaction();\n }\n\n protected function tearDown()\n {\n if ($this->pdo instanceof PDO && $this->pdo->inTransaction()) {\n $this->pdo->rollBack();\n }\n }\n\n public function testStoresAndReadsRegistration()\n {\n $repository = new PdoRegistrationRepository($this->pdo);\n $id = $repository->create(\n 'registration-test-42',\n 'anna@example.test'\n );\n\n $stored = $repository->findByRequestId('registration-test-42');\n\n self::assertSame((int) $id, (int) $stored['id']);\n self::assertSame('anna@example.test', $stored['email']);\n }\n}</code></pre>\n<p>Доказательство появляется после чтения обратно. Если <code>create()</code> перепутал поля, миграция отличается от ожидания или <code>findByRequestId()</code> меняет имя ключа, тест падает на конкретной границе. Если соединение не открывается, это тоже результат: окружение не выполнило контракт.</p>\n<p>Транзакция очищает изменения только в этом соединении. Запись, сделанная вторым PDO, очередью или внешним сервисом, не исчезнет после <code>rollBack()</code>. MySQL и некоторые другие СУБД также могут фиксировать DDL неявно. Поэтому схему готовьте заранее, а сетевые следы удаляйте отдельным шагом.</p>\n<h2>Проверьте HTTP без вызова партнёра</h2>\n<p>Для проверки cURL достаточно локального callback. В учебной конфигурации <code>TEST_CALLBACK_URL</code> указывает на <code>127.0.0.1</code>. Обработчик проверяет заголовок и JSON-поле <code>registrationId</code>, затем отвечает <code>202</code>. Это доказывает, что клиент отправил запрос на заданный локальный endpoint и обработал договорённый ответ. Это не доказывает доступность партнёрского API, его SLA или production-сертификат.</p>\n<pre><code><?php\n// tests/fixtures/callback.php\n$requestId = $_SERVER['HTTP_X_TEST_REQUEST_ID'] ?? '';\n$payload = file_get_contents('php://input');\n$data = is_string($payload) ? json_decode($payload, true) : null;\n\nif (!preg_match('/^[a-z0-9-]{1,40}$/', $requestId)\n || !is_array($data)\n || !isset($data['registrationId'])\n || $data['registrationId'] === '') {\n http_response_code(400);\n echo 'bad request';\n return;\n}\n\nif (file_put_contents(\n sys_get_temp_dir() . '/callback-' . $requestId . '.json',\n $payload\n) === false) {\n http_response_code(500);\n echo 'cannot store callback';\n return;\n}\n\nheader('Content-Type: application/json');\nhttp_response_code(202);\necho json_encode(['accepted' => true]);\n\n// Учебный запуск: php -S 127.0.0.1:8088 -t tests/fixtures</code></pre>\n<p>Клиент должен различать транспортную ошибку и HTTP-ответ. Непустое тело не означает успех. <code>curl_exec()</code> может вернуть <code>false</code>; код ответа надо получить через <code>curl_getinfo()</code> и сравнить с договором. В примере ниже переменная <code>$registrationId</code> определена рядом с payload, поэтому фрагмент не зависит от локальной переменной предыдущего теста.</p>\n<pre><code><?php\n$registrationId = 'registration-test-42';\n$payload = json_encode(['registrationId' => $registrationId]);\nif ($payload === false) {\n throw new RuntimeException('Cannot encode callback payload');\n}\n\n$handle = curl_init((string) getenv('TEST_CALLBACK_URL'));\nif ($handle === false) {\n throw new RuntimeException('Cannot initialize cURL');\n}\n\ncurl_setopt_array($handle, [\n CURLOPT_POST => true,\n CURLOPT_HTTPHEADER => [\n 'Content-Type: application/json',\n 'X-Test-Request-Id: ' . $registrationId,\n ],\n CURLOPT_POSTFIELDS => $payload,\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CONNECTTIMEOUT => 1,\n CURLOPT_TIMEOUT => 3,\n]);\n\n$body = curl_exec($handle);\n$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);\n$error = curl_error($handle);\ncurl_close($handle);\n\nif ($body === false || $status !== 202 || $body !== '{\"accepted\":true}') {\n throw new RuntimeException(\n 'Test callback failed: status=' . $status . ' error=' . $error\n );\n}</code></pre>\n<p>Учебный allowlist <code>127.0.0.1</code> намеренно узкий. Если PHPUnit работает в контейнере, localhost внутри него может не совпасть с localhost хоста. Тогда задайте отдельное имя test-only сервиса, разрешите только его и проверьте, из какого сетевого пространства идёт запрос. Не подставляйте URL партнёра как запасной вариант.</p>\n<h2>Порядок действий</h2>\n<ol><li>Создайте отдельную тестовую схему и пользователя без доступа к production. Сохраните DSN только в test-only окружении.</li><li>Примените миграцию к тестовой схеме отдельной командой и проверьте версию схемы до запуска PHPUnit.</li><li>Запустите локальный callback на свободном адресе. Убедитесь, что его каталог принадлежит тестам и не содержит рабочих данных.</li><li>Передайте один ID, например <code>registration-test-42</code>, в запись БД и заголовок HTTP. Не печатайте пароль и секреты.</li><li>Запустите один интеграционный класс. Если он падает, сначала определите границу: конфигурация, PDO, SQL, транспорт или ответ.</li><li>После успешного теста проверьте откат строки в БД и удаление локального JSON-файла. Если второе соединение оставляет данные, исправьте изоляцию.</li><li>Добавьте отдельный сценарий только для нового контракта: уникальность, таймаут или ошибочный статус. Не превращайте один тест в проверку всего приложения.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Этот тест не проверяет HTML-форму, браузерную валидацию, cron, доставку письма и реальную доступность партнёра. Для формы нужен отдельный пользовательский или HTTP-тест. Для партнёра нужен согласованный стенд или контрактный тест. Один локальный callback не может заменить эти проверки.</p>\n<p>Если отдельной базы нет, честный результат — «контур не готов», а не зелёный unit-тест с названием integration. Если код сам создаёт PDO внутри сервиса, сначала вынесите фабрику или передайте адаптер через конструктор. Иначе тест не сможет доказать, что сервис использовал именно безопасное соединение.</p>\n<p>Если ответ callback изменился, исправляйте договор или клиент после проверки причины. Не принимайте любой код от 200 до 299 без решения о семантике ответа. Если сеть недоступна, не повторяйте запрос бесконечно: короткий timeout должен показать проблему и остановить сценарий.</p>\n<h2>Критерий готовности</h2>\n<p>Сценарий готов, когда он проходит на чистой тестовой схеме, читает созданную запись обратно через настоящий репозиторий, получает ожидаемый статус локального callback и после завершения не оставляет данные в БД и временной директории. При пустом DSN, неверной схеме, недоступном callback и неожиданном статусе он падает с различимым сообщением. Этот критерий проверяем командой проекта, а не объявляем по наличию файла теста.</p>\n<p>Такой интеграционный тест не делает систему безошибочной. Он делает одну границу наблюдаемой. Unit-тест отвечает за локальное правило. Тест с PDO и локальным HTTP-обработчиком отвечает за связку конфигурации, БД, транспорта и ответа. Их зелёный результат имеет смысл только в пределах этих явно названных условий.</p>\n<h2>Проверяемые источники</h2><p>Пример намеренно сохраняет синтаксис PHP 7.2 и базовый API PHPUnit 7.5, близкие периоду статьи. В современных версиях PHP и PHPUnit названия и ограничения API могут отличаться; сверяйте пример с версией проекта.</p><ul><li><a href=\"https://www.php.net/manual/en/pdo.begintransaction.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: PDO::beginTransaction</a></li><li><a href=\"https://www.php.net/manual/en/function.curl-getinfo.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: curl_getinfo</a></li><li><a href=\"https://phpunit.de/getting-started/phpunit-7.html\" target=\"_blank\" rel=\"noopener noreferrer\">PHPUnit 7.5 Manual</a></li></ul>"
|
||
}
|