diff --git a/editorial/agent-rewrites/328.json b/editorial/agent-rewrites/328.json index 552e283..0775070 100644 --- a/editorial/agent-rewrites/328.json +++ b/editorial/agent-rewrites/328.json @@ -3,5 +3,5 @@ "slug": "editorial-2018-11-field-php-integration-tests", "title": "PHP: зелёный тест и нерабочая форма — как проверить БД, HTTP и конфигурацию", "excerpt": "Модульный тест может пройти, пока форма падает на настоящем DSN, SQL или HTTP-ответе. Разбираем короткую интеграционную трассу PHP с тестовой БД, локальным callback и явным критерием готовности.", - "contentHtml": "
Форма регистрации отвечает 500 после отправки, хотя unit-тест сервиса зелёный. Иногда запись в БД создаётся, но уведомление не уходит. Иногда запрос даже не доходит до базы. Цена ошибки — потерянное время на исправление бизнес-логики и риск замаскировать проблему новым mock-ом. Такой тест снова станет зелёным, но не проверит DSN, SQL, cURL и код ответа.
\nИнтеграционный тест нужен там, где ошибка возникает на стыке компонентов. Для PHP это может быть путь «конфигурация → PDO → тестовая БД → сервис → локальный HTTP callback». Unit-тест оставляем для правил внутри класса. Интеграционный тест проходит через настоящий адаптер и проверяет наблюдаемый результат. Ниже — учебный пример. Он не вызывает партнёрский URL и не утверждает, что команды уже выполнялись в production.
\nUnit-тест обычно передаёт сервису память вместо репозитория и spy вместо HTTP-клиента. Он проверяет порядок вызовов: сначала создать регистрацию, потом отправить уведомление. Это полезное утверждение, но оно не открывает PDO, не читает переменную окружения и не получает ответ сервера.
\n<?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);\nЭтот код может пройти при пустом DSN, отсутствии таблицы и неверном URL callback. В нём нет дефекта. Ошибка появляется, когда его называют проверкой всей регистрации. Название теста не расширяет его границу.
\nИнтеграционный тест фиксирует более узкий контракт: тестовая конфигурация разрешает соединение; репозиторий записывает валидные поля; чтение возвращает их без потери типа; клиент отправляет запрос на локальный endpoint; код принимает только ожидаемый статус и тело. Почта, браузер и доступность партнёра остаются другими контрактами.
\nТест должен остановиться до соединения, если не задана test-only конфигурация. Не используйте production DSN как значение по умолчанию. Префикс TEST_ не заменяет права доступа, но делает намерение видимым. Отдельный пользователь БД, отдельная схема и запрет на production DNS важнее проверки имени переменной.
<?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}\nПроверка подстроки test — только учебный предохранитель от очевидной ошибки. Она не доказывает изоляцию. В рабочем проекте проверяйте разрешённый хост, имя схемы и пользователя отдельными настройками запуска. Пароль не выводите в исключение и отчёт.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Тест падает до INSERT | Пустой DSN, драйвер или права | Вывести безопасное имя схемы и тип исключения | Проверить test-only окружение и миграцию |
| INSERT проходит, чтение пустое | Неверный столбец, фильтр или схема | Прочитать запись тем же репозиторием по ID | Сравнить SQL, миграцию и типы результата |
| В логах нет HTTP-попытки | Сервис остановился после ошибки БД | Связать шаги одним request ID | Сначала исправить границу БД |
| HTTP вернул 200 вместо ожидаемого 202 | Маршрут или callback не тот | Проверить URL без секретов и HTTP-код | Исправить test-only URL или договор ответа |
| Повторный прогон видит старые данные | Транзакция не откатилась или второе соединение обошло её | Проверить inTransaction() и соединения | Откатывать PDO и отдельно чистить следы HTTP |
Логируйте только то, что помогает выбрать следующую проверку: учебный ID, тип ошибки, имя тестовой схемы, HTTP-код. Не записывайте токены, пароли и полное тело запроса, если оно может содержать персональные данные.
\nТест ниже предполагает, что таблица уже создана миграцией в выделенной схеме. Миграцию не запускайте внутри сценария: DDL может сделать неявный commit, и откат данных перестанет быть надёжным. Проверяем один путь записи и чтения через публичные методы репозитория.
\n<?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}\nДоказательство появляется после чтения обратно. Если create() перепутал поля, миграция отличается от ожидания или findByRequestId() меняет имя ключа, тест падает на конкретной границе. Если соединение не открывается, это тоже результат: окружение не выполнило контракт.
Транзакция очищает изменения только в этом соединении. Запись, сделанная вторым PDO, очередью или внешним сервисом, не исчезнет после rollBack(). MySQL и некоторые другие СУБД также могут фиксировать DDL неявно. Поэтому схему готовьте заранее, а сетевые следы удаляйте отдельным шагом.
Для проверки cURL достаточно локального callback. В учебной конфигурации TEST_CALLBACK_URL указывает на 127.0.0.1. Обработчик принимает JSON, проверяет формат ID и отвечает 202. Это доказывает работу нашего клиента и обработку конкретного ответа. Это не доказывает доступность партнёрского API, его SLA или production-сертификат.
<?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\nКлиент должен различать транспортную ошибку и HTTP-ответ. Непустое тело не означает успех. curl_exec() может вернуть false; код ответа надо получить через curl_getinfo() и сравнить с договором.
<?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}\nУчебный allowlist 127.0.0.1 намеренно узкий. Если PHPUnit работает в контейнере, localhost внутри него может не совпасть с localhost хоста. Тогда задайте отдельное имя test-only сервиса, разрешите только его и проверьте, из какого сетевого пространства идёт запрос. Не подставляйте URL партнёра как запасной вариант.
registration-test-42, в запись БД и заголовок HTTP. Не печатайте пароль и секреты.Этот тест не проверяет HTML-форму, браузерную валидацию, cron, доставку письма и реальную доступность партнёра. Для формы нужен отдельный пользовательский или HTTP-тест. Для партнёра нужен согласованный стенд или контрактный тест. Один локальный callback не может заменить эти проверки.
\nЕсли отдельной базы нет, честный результат — «контур не готов», а не зелёный unit-тест с названием integration. Если код сам создаёт PDO внутри сервиса, сначала вынесите фабрику или передайте адаптер через конструктор. Иначе тест не сможет доказать, что сервис использовал именно безопасное соединение.
\nЕсли ответ callback изменился, исправляйте договор или клиент после проверки причины. Не принимайте любой код от 200 до 299 без решения о семантике ответа. Если сеть недоступна, не повторяйте запрос бесконечно: короткий timeout должен показать проблему и остановить сценарий.
\nСценарий готов, когда он проходит на чистой тестовой схеме, читает созданную запись обратно через настоящий репозиторий, получает ожидаемый статус локального callback и после завершения не оставляет данные в БД и временной директории. При пустом DSN, неверной схеме, недоступном callback и неожиданном статусе он падает с различимым сообщением. Этот критерий проверяем командой проекта, а не объявляем по наличию файла теста.
\nТакой интеграционный тест не делает систему безошибочной. Он делает одну границу наблюдаемой. Unit-тест отвечает за локальное правило. Тест с PDO и локальным HTTP-обработчиком отвечает за связку конфигурации, БД, транспорта и ответа. Их зелёный результат имеет смысл только в пределах этих явно названных условий.
\nВ учебном сценарии форма регистрации отвечает 500 после отправки, хотя unit-тест сервиса зелёный. Иногда запись в БД создаётся, но уведомление не уходит. Иногда запрос даже не доходит до базы. Цена ошибки — потерянное время на исправление бизнес-логики и риск замаскировать проблему новым mock-ом. Такой тест снова станет зелёным, но не проверит DSN, SQL, cURL и код ответа.
\nИнтеграционный тест нужен там, где ошибка возникает на стыке компонентов. Для PHP это может быть путь «конфигурация → PDO → тестовая БД → сервис → локальный HTTP callback». Unit-тест оставляем для правил внутри класса. Интеграционный тест проходит через настоящий адаптер и проверяет наблюдаемый результат. Ниже — учебный пример. Он не вызывает партнёрский URL и не утверждает, что команды уже выполнялись в production.
\nUnit-тест обычно передаёт сервису память вместо репозитория и тестовый двойник вместо HTTP-клиента. В PHPUnit stub управляет входом, а mock проверяет вызовы; ниже для краткости оставим условный SpyCallbackClient. Такой тест проверяет порядок вызовов: сначала создать регистрацию, потом отправить уведомление. Это полезное утверждение, но оно не открывает PDO, не читает переменную окружения и не получает ответ сервера.
\n<?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);\nЭтот код может пройти при пустом DSN, отсутствии таблицы и неверном URL callback. В нём нет дефекта. Ошибка появляется, когда его называют проверкой всей регистрации. Название теста не расширяет его границу.
\nИнтеграционный тест фиксирует более узкий контракт: тестовая конфигурация разрешает соединение; репозиторий записывает валидные поля; чтение возвращает нужную запись, а приложение приводит значения к ожидаемым типам; клиент отправляет запрос на локальный endpoint; код принимает только ожидаемый статус и тело. Почта, браузер и доступность партнёра остаются другими контрактами.
\nТест должен остановиться до соединения, если не задана test-only конфигурация. Не используйте production DSN как значение по умолчанию. Префикс TEST_ не заменяет права доступа, но делает намерение видимым. Отдельный пользователь БД, отдельная схема и запрет на production DNS важнее проверки имени переменной.
<?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}\nТочное сравнение DSN — проектное правило примера, а не универсальный способ определить безопасную базу. В рабочем проекте зафиксируйте разрешённый хост, имя схемы и пользователя отдельными настройками запуска и не собирайте DSN из непроверенного ввода. Пароль не выводите в исключение и отчёт.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Тест падает до INSERT | Пустой DSN, драйвер или права | Вывести безопасное имя схемы и тип исключения | Проверить test-only окружение и миграцию |
| INSERT проходит, чтение пустое | Неверный столбец, фильтр или схема | Прочитать запись тем же репозиторием по ID | Сравнить SQL, миграцию и типы результата |
| В логах нет HTTP-попытки | Сервис остановился после ошибки БД | Связать шаги одним request ID | Сначала исправить границу БД |
| HTTP вернул 200 вместо ожидаемого 202 | Маршрут или callback не тот | Проверить URL без секретов и HTTP-код | Исправить test-only URL или договор ответа |
| Повторный прогон видит старые данные | Транзакция не откатилась или второе соединение обошло её | Проверить inTransaction() и соединения | Откатывать PDO и отдельно чистить следы HTTP |
Логируйте только то, что помогает выбрать следующую проверку: учебный ID, тип ошибки, имя тестовой схемы, HTTP-код. Не записывайте токены, пароли и полное тело запроса, если оно может содержать персональные данные.
\nТест ниже предполагает, что таблица уже создана миграцией в выделенной схеме. Миграцию не запускайте внутри сценария: некоторые СУБД выполняют неявный commit на DDL, и откат данных перестаёт быть надёжным. Проверяем один путь записи и чтения через публичные методы репозитория. Синтаксис примера не использует typed properties и рассчитан на PHP 7.2.
\n<?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}\nДоказательство появляется после чтения обратно. Если create() перепутал поля, миграция отличается от ожидания или findByRequestId() меняет имя ключа, тест падает на конкретной границе. Если соединение не открывается, это тоже результат: окружение не выполнило контракт.
Транзакция очищает изменения только в этом соединении. Запись, сделанная вторым PDO, очередью или внешним сервисом, не исчезнет после rollBack(). MySQL и некоторые другие СУБД также могут фиксировать DDL неявно. Поэтому схему готовьте заранее, а сетевые следы удаляйте отдельным шагом.
Для проверки cURL достаточно локального callback. В учебной конфигурации TEST_CALLBACK_URL указывает на 127.0.0.1. Обработчик проверяет заголовок и JSON-поле registrationId, затем отвечает 202. Это доказывает, что клиент отправил запрос на заданный локальный endpoint и обработал договорённый ответ. Это не доказывает доступность партнёрского API, его SLA или production-сертификат.
<?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\nКлиент должен различать транспортную ошибку и HTTP-ответ. Непустое тело не означает успех. curl_exec() может вернуть false; код ответа надо получить через curl_getinfo() и сравнить с договором. В примере ниже переменная $registrationId определена рядом с payload, поэтому фрагмент не зависит от локальной переменной предыдущего теста.
<?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}\nУчебный allowlist 127.0.0.1 намеренно узкий. Если PHPUnit работает в контейнере, localhost внутри него может не совпасть с localhost хоста. Тогда задайте отдельное имя test-only сервиса, разрешите только его и проверьте, из какого сетевого пространства идёт запрос. Не подставляйте URL партнёра как запасной вариант.
registration-test-42, в запись БД и заголовок HTTP. Не печатайте пароль и секреты.Этот тест не проверяет HTML-форму, браузерную валидацию, cron, доставку письма и реальную доступность партнёра. Для формы нужен отдельный пользовательский или HTTP-тест. Для партнёра нужен согласованный стенд или контрактный тест. Один локальный callback не может заменить эти проверки.
\nЕсли отдельной базы нет, честный результат — «контур не готов», а не зелёный unit-тест с названием integration. Если код сам создаёт PDO внутри сервиса, сначала вынесите фабрику или передайте адаптер через конструктор. Иначе тест не сможет доказать, что сервис использовал именно безопасное соединение.
\nЕсли ответ callback изменился, исправляйте договор или клиент после проверки причины. Не принимайте любой код от 200 до 299 без решения о семантике ответа. Если сеть недоступна, не повторяйте запрос бесконечно: короткий timeout должен показать проблему и остановить сценарий.
\nСценарий готов, когда он проходит на чистой тестовой схеме, читает созданную запись обратно через настоящий репозиторий, получает ожидаемый статус локального callback и после завершения не оставляет данные в БД и временной директории. При пустом DSN, неверной схеме, недоступном callback и неожиданном статусе он падает с различимым сообщением. Этот критерий проверяем командой проекта, а не объявляем по наличию файла теста.
\nТакой интеграционный тест не делает систему безошибочной. Он делает одну границу наблюдаемой. Unit-тест отвечает за локальное правило. Тест с PDO и локальным HTTP-обработчиком отвечает за связку конфигурации, БД, транспорта и ответа. Их зелёный результат имеет смысл только в пределах этих явно названных условий.
\nПример намеренно сохраняет синтаксис PHP 7.2 и базовый API PHPUnit 7.5, близкие периоду статьи. В современных версиях PHP и PHPUnit названия и ограничения API могут отличаться; сверяйте пример с версией проекта.
" }