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.

\n
\"Трасса
Один учебный идентификатор связывает запись в БД, HTTP-попытку и проверку ответа. Это помогает разделить соседние ошибки, а не заменить наблюдение общим «тест упал».
\n

Что именно ломает зелёный тест

\n

Unit-тест обычно передаёт сервису память вместо репозитория и 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

Сначала отделите среду от кода

\n

Тест должен остановиться до соединения, если не задана test-only конфигурация. Не используйте production DSN как значение по умолчанию. Префикс TEST_ не заменяет права доступа, но делает намерение видимым. Отдельный пользователь БД, отдельная схема и запрет на production DNS важнее проверки имени переменной.

\n
<?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 — только учебный предохранитель от очевидной ошибки. Она не доказывает изоляцию. В рабочем проекте проверяйте разрешённый хост, имя схемы и пользователя отдельными настройками запуска. Пароль не выводите в исключение и отчёт.

\n

Разделите симптом, причину, проверку и действие

\n
СимптомПричинаПроверкаДействие
Тест падает до INSERTПустой DSN, драйвер или праваВывести безопасное имя схемы и тип исключенияПроверить test-only окружение и миграцию
INSERT проходит, чтение пустоеНеверный столбец, фильтр или схемаПрочитать запись тем же репозиторием по IDСравнить SQL, миграцию и типы результата
В логах нет HTTP-попыткиСервис остановился после ошибки БДСвязать шаги одним request IDСначала исправить границу БД
HTTP вернул 200 вместо ожидаемого 202Маршрут или callback не тотПроверить URL без секретов и HTTP-кодИсправить test-only URL или договор ответа
Повторный прогон видит старые данныеТранзакция не откатилась или второе соединение обошло еёПроверить inTransaction() и соединенияОткатывать PDO и отдельно чистить следы HTTP
\n

Логируйте только то, что помогает выбрать следующую проверку: учебный ID, тип ошибки, имя тестовой схемы, HTTP-код. Не записывайте токены, пароли и полное тело запроса, если оно может содержать персональные данные.

\n

Настоящий PDO, но только тестовая база

\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() меняет имя ключа, тест падает на конкретной границе. Если соединение не открывается, это тоже результат: окружение не выполнило контракт.

\n

Транзакция очищает изменения только в этом соединении. Запись, сделанная вторым PDO, очередью или внешним сервисом, не исчезнет после rollBack(). MySQL и некоторые другие СУБД также могут фиксировать DDL неявно. Поэтому схему готовьте заранее, а сетевые следы удаляйте отдельным шагом.

\n

Проверьте HTTP без вызова партнёра

\n

Для проверки cURL достаточно локального callback. В учебной конфигурации TEST_CALLBACK_URL указывает на 127.0.0.1. Обработчик принимает JSON, проверяет формат ID и отвечает 202. Это доказывает работу нашего клиента и обработку конкретного ответа. Это не доказывает доступность партнёрского API, его SLA или production-сертификат.

\n
<?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() и сравнить с договором.

\n
<?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 партнёра как запасной вариант.

\n

Порядок действий

\n
  1. Создайте отдельную тестовую схему и пользователя без доступа к production. Сохраните DSN только в test-only окружении.
  2. Примените миграцию к тестовой схеме отдельной командой и проверьте версию схемы до запуска PHPUnit.
  3. Запустите локальный callback на свободном адресе. Убедитесь, что его каталог принадлежит тестам и не содержит рабочих данных.
  4. Передайте один ID, например registration-test-42, в запись БД и заголовок HTTP. Не печатайте пароль и секреты.
  5. Запустите один интеграционный класс. Если он падает, сначала определите границу: конфигурация, PDO, SQL, транспорт или ответ.
  6. После успешного теста проверьте откат строки в БД и удаление локального JSON-файла. Если второе соединение оставляет данные, исправьте изоляцию.
  7. Добавьте отдельный сценарий только для нового контракта: уникальность, таймаут или ошибочный статус. Не превращайте один тест в проверку всего приложения.
\n

Ограничения и отрицательный путь

\n

Этот тест не проверяет HTML-форму, браузерную валидацию, cron, доставку письма и реальную доступность партнёра. Для формы нужен отдельный пользовательский или HTTP-тест. Для партнёра нужен согласованный стенд или контрактный тест. Один локальный callback не может заменить эти проверки.

\n

Если отдельной базы нет, честный результат — «контур не готов», а не зелёный unit-тест с названием integration. Если код сам создаёт PDO внутри сервиса, сначала вынесите фабрику или передайте адаптер через конструктор. Иначе тест не сможет доказать, что сервис использовал именно безопасное соединение.

\n

Если ответ callback изменился, исправляйте договор или клиент после проверки причины. Не принимайте любой код от 200 до 299 без решения о семантике ответа. Если сеть недоступна, не повторяйте запрос бесконечно: короткий timeout должен показать проблему и остановить сценарий.

\n

Критерий готовности

\n

Сценарий готов, когда он проходит на чистой тестовой схеме, читает созданную запись обратно через настоящий репозиторий, получает ожидаемый статус локального callback и после завершения не оставляет данные в БД и временной директории. При пустом DSN, неверной схеме, недоступном callback и неожиданном статусе он падает с различимым сообщением. Этот критерий проверяем командой проекта, а не объявляем по наличию файла теста.

\n

Такой интеграционный тест не делает систему безошибочной. Он делает одну границу наблюдаемой. Unit-тест отвечает за локальное правило. Тест с PDO и локальным HTTP-обработчиком отвечает за связку конфигурации, БД, транспорта и ответа. Их зелёный результат имеет смысл только в пределах этих явно названных условий.

\n

Проверяемые источники

" + "contentHtml": "

В учебном сценарии форма регистрации отвечает 500 после отправки, хотя unit-тест сервиса зелёный. Иногда запись в БД создаётся, но уведомление не уходит. Иногда запрос даже не доходит до базы. Цена ошибки — потерянное время на исправление бизнес-логики и риск замаскировать проблему новым mock-ом. Такой тест снова станет зелёным, но не проверит DSN, SQL, cURL и код ответа.

\n

Интеграционный тест нужен там, где ошибка возникает на стыке компонентов. Для PHP это может быть путь «конфигурация → PDO → тестовая БД → сервис → локальный HTTP callback». Unit-тест оставляем для правил внутри класса. Интеграционный тест проходит через настоящий адаптер и проверяет наблюдаемый результат. Ниже — учебный пример. Он не вызывает партнёрский URL и не утверждает, что команды уже выполнялись в production.

\n
\"Трасса
Один учебный идентификатор связывает запись в БД, HTTP-попытку и проверку ответа. Это помогает разделить соседние ошибки, а не заменить наблюдение общим «тест упал».
\n

Что именно ломает зелёный тест

\n

Unit-тест обычно передаёт сервису память вместо репозитория и тестовый двойник вместо 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

Сначала отделите среду от кода

\n

Тест должен остановиться до соединения, если не задана test-only конфигурация. Не используйте production DSN как значение по умолчанию. Префикс TEST_ не заменяет права доступа, но делает намерение видимым. Отдельный пользователь БД, отдельная схема и запрет на production DNS важнее проверки имени переменной.

\n
<?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

Разделите симптом, причину, проверку и действие

\n
СимптомПричинаПроверкаДействие
Тест падает до INSERTПустой DSN, драйвер или праваВывести безопасное имя схемы и тип исключенияПроверить test-only окружение и миграцию
INSERT проходит, чтение пустоеНеверный столбец, фильтр или схемаПрочитать запись тем же репозиторием по IDСравнить SQL, миграцию и типы результата
В логах нет HTTP-попыткиСервис остановился после ошибки БДСвязать шаги одним request IDСначала исправить границу БД
HTTP вернул 200 вместо ожидаемого 202Маршрут или callback не тотПроверить URL без секретов и HTTP-кодИсправить test-only URL или договор ответа
Повторный прогон видит старые данныеТранзакция не откатилась или второе соединение обошло еёПроверить inTransaction() и соединенияОткатывать PDO и отдельно чистить следы HTTP
\n

Логируйте только то, что помогает выбрать следующую проверку: учебный ID, тип ошибки, имя тестовой схемы, HTTP-код. Не записывайте токены, пароли и полное тело запроса, если оно может содержать персональные данные.

\n

Настоящий PDO, но только тестовая база

\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() меняет имя ключа, тест падает на конкретной границе. Если соединение не открывается, это тоже результат: окружение не выполнило контракт.

\n

Транзакция очищает изменения только в этом соединении. Запись, сделанная вторым PDO, очередью или внешним сервисом, не исчезнет после rollBack(). MySQL и некоторые другие СУБД также могут фиксировать DDL неявно. Поэтому схему готовьте заранее, а сетевые следы удаляйте отдельным шагом.

\n

Проверьте HTTP без вызова партнёра

\n

Для проверки cURL достаточно локального callback. В учебной конфигурации TEST_CALLBACK_URL указывает на 127.0.0.1. Обработчик проверяет заголовок и JSON-поле registrationId, затем отвечает 202. Это доказывает, что клиент отправил запрос на заданный локальный endpoint и обработал договорённый ответ. Это не доказывает доступность партнёрского API, его SLA или production-сертификат.

\n
<?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, поэтому фрагмент не зависит от локальной переменной предыдущего теста.

\n
<?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 партнёра как запасной вариант.

\n

Порядок действий

\n
  1. Создайте отдельную тестовую схему и пользователя без доступа к production. Сохраните DSN только в test-only окружении.
  2. Примените миграцию к тестовой схеме отдельной командой и проверьте версию схемы до запуска PHPUnit.
  3. Запустите локальный callback на свободном адресе. Убедитесь, что его каталог принадлежит тестам и не содержит рабочих данных.
  4. Передайте один ID, например registration-test-42, в запись БД и заголовок HTTP. Не печатайте пароль и секреты.
  5. Запустите один интеграционный класс. Если он падает, сначала определите границу: конфигурация, PDO, SQL, транспорт или ответ.
  6. После успешного теста проверьте откат строки в БД и удаление локального JSON-файла. Если второе соединение оставляет данные, исправьте изоляцию.
  7. Добавьте отдельный сценарий только для нового контракта: уникальность, таймаут или ошибочный статус. Не превращайте один тест в проверку всего приложения.
\n

Ограничения и отрицательный путь

\n

Этот тест не проверяет HTML-форму, браузерную валидацию, cron, доставку письма и реальную доступность партнёра. Для формы нужен отдельный пользовательский или HTTP-тест. Для партнёра нужен согласованный стенд или контрактный тест. Один локальный callback не может заменить эти проверки.

\n

Если отдельной базы нет, честный результат — «контур не готов», а не зелёный unit-тест с названием integration. Если код сам создаёт PDO внутри сервиса, сначала вынесите фабрику или передайте адаптер через конструктор. Иначе тест не сможет доказать, что сервис использовал именно безопасное соединение.

\n

Если ответ callback изменился, исправляйте договор или клиент после проверки причины. Не принимайте любой код от 200 до 299 без решения о семантике ответа. Если сеть недоступна, не повторяйте запрос бесконечно: короткий timeout должен показать проблему и остановить сценарий.

\n

Критерий готовности

\n

Сценарий готов, когда он проходит на чистой тестовой схеме, читает созданную запись обратно через настоящий репозиторий, получает ожидаемый статус локального callback и после завершения не оставляет данные в БД и временной директории. При пустом DSN, неверной схеме, недоступном callback и неожиданном статусе он падает с различимым сообщением. Этот критерий проверяем командой проекта, а не объявляем по наличию файла теста.

\n

Такой интеграционный тест не делает систему безошибочной. Он делает одну границу наблюдаемой. Unit-тест отвечает за локальное правило. Тест с PDO и локальным HTTP-обработчиком отвечает за связку конфигурации, БД, транспорта и ответа. Их зелёный результат имеет смысл только в пределах этих явно названных условий.

\n

Проверяемые источники

Пример намеренно сохраняет синтаксис PHP 7.2 и базовый API PHPUnit 7.5, близкие периоду статьи. В современных версиях PHP и PHPUnit названия и ограничения API могут отличаться; сверяйте пример с версией проекта.

" }