8 lines
19 KiB
JSON
8 lines
19 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-тест обычно передаёт сервису память вместо репозитория и spy вместо HTTP-клиента. Он проверяет порядок вызовов: сначала создать регистрацию, потом отправить уведомление. Это полезное утверждение, но оно не открывает 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 === '' || strpos($dsn, 'test') === false) {\n throw new RuntimeException(\n 'TEST_DATABASE_DSN must point to an isolated test database'\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>Проверка подстроки <code>test</code> — только учебный предохранитель от очевидной ошибки. Она не доказывает изоляцию. В рабочем проекте проверяйте разрешённый хост, имя схемы и пользователя отдельными настройками запуска. Пароль не выводите в исключение и отчёт.</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>Тест ниже предполагает, что таблица уже создана миграцией в выделенной схеме. Миграцию не запускайте внутри сценария: DDL может сделать неявный commit, и откат данных перестанет быть надёжным. Проверяем один путь записи и чтения через публичные методы репозитория.</p>\n<pre><code><?php\nfinal class RegistrationIntegrationTest extends TestCase\n{\n private PDO $pdo;\n\n protected function setUp(): void\n {\n $this->pdo = TestPdo::fromEnvironment();\n $this->pdo->beginTransaction();\n }\n\n protected function tearDown(): void\n {\n if ($this->pdo->inTransaction()) {\n $this->pdo->rollBack();\n }\n }\n\n public function testStoresAndReadsRegistration(): void\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($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, проверяет формат ID и отвечает <code>202</code>. Это доказывает работу нашего клиента и обработку конкретного ответа. Это не доказывает доступность партнёрского API, его SLA или production-сертификат.</p>\n<pre><code><?php\n// tests/fixtures/callback.php\n$requestId = $_SERVER['HTTP_X_TEST_REQUEST_ID'] ?? '';\nif (!preg_match('/^[a-z0-9-]{1,40}$/', $requestId)) {\n http_response_code(400);\n echo 'bad request id';\n return;\n}\n\nfile_put_contents(\n sys_get_temp_dir() . '/callback-' . $requestId . '.json',\n file_get_contents('php://input')\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> и сравнить с договором.</p>\n<pre><code><?php\n$handle = curl_init((string) getenv('TEST_CALLBACK_URL'));\ncurl_setopt_array($handle, [\n CURLOPT_POST => true,\n CURLOPT_HTTPHEADER => [\n 'Content-Type: application/json',\n 'X-Test-Request-Id: registration-test-42',\n ],\n CURLOPT_POSTFIELDS => json_encode(['registrationId' => $id]),\n CURLOPT_RETURNTRANSFER => true,\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><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://docs.phpunit.de/en/12.5/test-doubles.html\" target=\"_blank\" rel=\"noopener noreferrer\">PHPUnit Manual: Test Doubles</a></li></ul>"
|
||
}
|