Files
progcode/editorial/agent-rewrites/329.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>&lt;?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-&gt;customers = $customers;\n }\n\n public function register(string $email): void\n {\n if ($this-&gt;customers-&gt;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-&gt;createMock(CustomerLookup::class);\n $customers-&gt;method('existsByEmail')\n -&gt;with('anna@example.test')\n -&gt;willReturn(true);\n\n $service = new RegistrationService($customers);\n $this-&gt;expectException(DomainException::class);\n $service-&gt;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>&lt;?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-&gt;markTestSkipped('An isolated test DSN is required');\n }\n\n $this-&gt;pdo = new PDO(\n $dsn,\n (string) getenv('TEST_DATABASE_USER'),\n (string) getenv('TEST_DATABASE_PASSWORD'),\n [PDO::ATTR_ERRMODE =&gt; PDO::ERRMODE_EXCEPTION,\n PDO::ATTR_DEFAULT_FETCH_MODE =&gt; PDO::FETCH_ASSOC]\n );\n $this-&gt;pdo-&gt;beginTransaction();\n $this-&gt;pdo-&gt;prepare(\n 'INSERT INTO customers (email, name) VALUES (?, ?)'\n )-&gt;execute(['anna@example.test', 'Анна']);\n }\n\n protected function tearDown(): void\n {\n if ($this-&gt;pdo instanceof PDO &amp;&amp; $this-&gt;pdo-&gt;inTransaction()) {\n $this-&gt;pdo-&gt;rollBack();\n }\n }\n\n public function testFindsExistingEmail(): void\n {\n $lookup = new PdoCustomerLookup($this-&gt;pdo);\n\n self::assertTrue($lookup-&gt;existsByEmail('anna@example.test'));\n self::assertFalse($lookup-&gt;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>"
}