Files
progcode/editorial/agent-rewrites/330.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": 330,
"slug": "editorial-2018-11-practice-php-integration-tests",
"title": "PHP-интеграционный тест репозитория: проверить запись, чтение и границы отката",
"excerpt": "Unit-тест может быть зелёным, пока настоящий PDO не увидит схему базы. Разбираем узкий интеграционный тест PHP-репозитория: изолированное подключение, запись, чтение, откат и проверяемый предел его доказательств.",
"contentHtml": "<p>Unit-тест сервиса зелёный, но после отправки формы запись в таблице получает пустое поле. Иногда метод записи возвращает ID, а следующий вызов не находит строку. Другой вариант — тест проходит локально и падает на CI из-за DSN, прав пользователя или другой схемы. Цена ошибки — ложная уверенность перед релизом. Команда ищет дефект в бизнес-логике, хотя PHP, PDO, SQL и таблица никогда не проходили один путь вместе.</p>\n<p><strong>Интеграционный тест репозитория должен оставить настоящим только нужный переход: PHP → PDO → изолированная тестовая БД → PDO → PHP.</strong> Он записывает данные через публичный метод репозитория, читает их тем же адаптером и проверяет результат. Такой тест не доказывает работу всей формы, очереди или production-БД. Он фиксирует один контракт и показывает, на какой границе он нарушился.</p>\n<p>Ниже приведён учебный пример для PHP и PHPUnit. В нём нет рабочего пароля, готового контейнера и обещания результата в конкретной среде. Названия таблицы, драйвер, способ запуска БД и версия PHPUnit зависят от проекта. Код показывает принцип, а не универсальную конфигурацию.</p>\n<h2>Механизм: где заканчивается unit-тест</h2>\n<p>Unit-тест проверяет решение одного класса. Репозиторий в нём можно заменить заглушкой, а ответ заглушки задать заранее. Это правильно, если вопрос звучит так: «запретит ли сервис дубликат?» Но заглушка не выполняет SQL, не читает схему и не проверяет настройки PDO.</p>\n<p>Интеграционный тест отвечает на другой вопрос: «сможет ли этот адаптер записать и прочитать данные через настоящий драйвер?» Поэтому он использует тестовую БД и реальную схему. Его граница должна быть узкой. Не нужно добавлять браузер, отправку почты и внешний API. Каждый новый ресурс добавляет собственную причину падения.</p>\n<figure><img src=\"/assets/editorial/2018/php-integration-contract-2018.svg\" alt=\"Схема PHP-интеграционного теста: PHPUnit передаёт репозиторию PDO, репозиторий пишет и читает изолированную тестовую БД, затем тест откатывает транзакцию\" /><figcaption>Учебный контракт проходит через настоящий PDO и тестовую схему. Откат очищает изменения данных, но не отменяет каждый тип операции.</figcaption></figure>\n<h2>Контракт до кода</h2>\n<p>Возьмём таблицу <code>customers</code> с полями <code>id</code>, <code>email</code> и <code>name</code>. Тест получает адрес <code>anna@example.test</code> и имя <code>Анна</code>. Метод <code>add()</code> возвращает числовой ID. Метод <code>findById()</code> по этому ID возвращает те же значения. После теста строка не должна остаться в общей тестовой базе.</p>\n<p>Это не проверка всех запросов репозитория. Она проверяет минимальный маршрут записи и чтения. Если проект дополнительно нормализует регистр, проверяет уникальность или преобразует даты, для каждого такого правила нужен отдельный сценарий. Не прячьте несколько разных утверждений в одном тесте: тогда ошибка перестаёт указывать на конкретный контракт.</p>\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>Не создаётся PDO</td><td>Пустой DSN, неверный драйвер или тест обращается не к той среде</td><td>Вывести безопасный идентификатор БД и проверить переменные <code>TEST_*</code></td><td>Остановить тест без значения по умолчанию; выдать отдельную ошибку конфигурации</td></tr><tr><td>INSERT проходит, чтение пустое</td><td>Перепутан столбец, имя ключа или схема отличается от миграции</td><td>Прочитать запись через <code>findById()</code> и сравнить каждое поле</td><td>Сверить SQL, схему и преобразование результата; не добавлять mock</td></tr><tr><td>После запуска остаются строки</td><td>Нет транзакции, был commit или запись сделана другим соединением</td><td>Проверить <code>inTransaction()</code> и состояние БД отдельным запросом</td><td>Откатывать тот же PDO; вынести DDL и чужие соединения за пределы сценария</td></tr><tr><td>Тест падает только параллельно</td><td>Общая схема и одинаковые данные пересекаются между процессами</td><td>Запустить один тест и сравнить данные с параллельным запуском</td><td>Дать каждому процессу схему или уникальный набор данных</td></tr><tr><td>Unit-тест зелёный, SQL ломается</td><td>Репозиторий заменён заглушкой</td><td>Запустить узкий тест с настоящим PDO и тестовой схемой</td><td>Оставить unit-тест для правил, добавить отдельную интеграционную проверку адаптера</td></tr></tbody></table></div>\n<h2>Изолированное подключение</h2>\n<p>Подключение должно быть явным. Не зашивайте в тест строку вроде <code>mysql:host=localhost;dbname=site</code>. По ней нельзя понять, безопасна ли база. Не используйте production DSN как запасной вариант. Если переменная отсутствует, тест обязан завершиться до первого запроса.</p>\n<p>Проверка подстроки <code>test</code> ниже защищает только от очевидной опечатки. Она не заменяет права доступа. Надёжнее создать отдельного пользователя без доступа к рабочей схеме, использовать отдельную сеть и передавать секреты через CI. Учебный фрагмент намеренно не содержит пароль.</p>\n<pre><code>&lt;?php final class TestPdo { public static function fromEnvironment(): PDO { $dsn = (string) getenv('TEST_DATABASE_DSN'); $user = (string) getenv('TEST_DATABASE_USER'); $password = (string) getenv('TEST_DATABASE_PASSWORD'); if ($dsn === '' || strpos($dsn, 'test') === false) { throw new RuntimeException('TEST_DATABASE_DSN must name an isolated test database'); } return new PDO($dsn, $user, $password, [PDO::ATTR_ERRMODE =&gt; PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE =&gt; PDO::FETCH_ASSOC]); } }</code></pre>\n<p>В реальном проекте дополнительно проверьте имя базы через конфигурацию окружения и права пользователя. Не печатайте пароль и полный DSN в лог. Сообщения теста должны помогать определить среду, но не раскрывать секреты.</p>\n<h2>Один настоящий путь записи и чтения</h2>\n<p>Тест ниже вызывает публичные методы <code>CustomerRepository</code>. Он не сравнивает SQL-строку с её копией в тесте. Доказательством служит результат чтения из БД. Если метод перепутает поля, схема не примет значение или преобразование результата изменит ключ, проверка должна упасть.</p>\n<pre><code>&lt;?php final class CustomerRepositoryIntegrationTest extends TestCase { private PDO $pdo; protected function setUp(): void { $this-&gt;pdo = TestPdo::fromEnvironment(); $this-&gt;pdo-&gt;beginTransaction(); } protected function tearDown(): void { if ($this-&gt;pdo-&gt;inTransaction()) { $this-&gt;pdo-&gt;rollBack(); } } public function testStoresAndReadsCustomer(): void { $repository = new CustomerRepository($this-&gt;pdo); $id = $repository-&gt;add('anna@example.test', 'Анна'); $stored = $repository-&gt;findById($id); self::assertIsInt($id); self::assertSame('anna@example.test', $stored['email']); self::assertSame('Анна', $stored['name']); } }</code></pre>\n<p>Полевая версия класса должна принимать PDO через конструктор. Если репозиторий создаёт новое соединение внутри <code>add()</code>, транзакция теста не контролирует его изменения. Передайте соединение или фабрику явно. Иначе зелёный откат может скрывать оставшиеся строки.</p>\n<h2>Очистка и отрицательный путь</h2>\n<p><code>beginTransaction()</code> отключает autocommit для соединения. <code>rollBack()</code> отменяет изменения данных и возвращает соединение в autocommit. Это подходит для короткого теста с <code>INSERT</code>, <code>UPDATE</code> и <code>DELETE</code>. Проверка <code>inTransaction()</code> в <code>tearDown()</code> не вызывает ошибку, если подготовка завершилась раньше открытия транзакции.</p>\n<p>Транзакция не является универсальной уборкой. Некоторые СУБД выполняют неявный commit для DDL, например <code>CREATE TABLE</code> и <code>DROP TABLE</code>. Поэтому миграцию схемы выполняйте отдельным подготовительным шагом. Откат одного PDO также не уберёт запись, созданную вторым соединением, очередью или HTTP-сервисом. Это отрицательный путь: если граница не контролируется, не называйте тест изолированным.</p>\n<h2>Порядок действий</h2>\n<ol><li>Создайте отдельную тестовую схему и пользователя. Запретите этому пользователю доступ к рабочей базе.</li><li>Примените к тестовой схеме ту версию миграции, которую должен видеть репозиторий. Не создавайте таблицу молча внутри теста.</li><li>Передайте DSN, пользователя и пароль через тестовое окружение с префиксом <code>TEST_</code>. При пустом или подозрительном DSN остановите запуск.</li><li>Откройте один PDO с режимом исключений и начните транзакцию в <code>setUp()</code>.</li><li>Вызовите один публичный метод записи и сохраните его ID. Не проверяйте внутренние свойства репозитория.</li><li>Прочитайте запись тем же репозиторием и сравните ID, адрес и имя. Каждое важное поле должно иметь отдельное утверждение.</li><li>В <code>tearDown()</code> откатите транзакцию, если она ещё открыта. Отдельно проверьте, что код не создаёт второе неконтролируемое соединение.</li><li>Запустите сначала один класс, затем тот же сценарий в режиме проекта. При падении разделите ошибку конфигурации, подключения, SQL, схемы и ожидания результата.</li></ol>\n<h2>Ограничения</h2>\n<p>Такой тест не проверяет HTML-форму, CSRF, маршрутизацию, очередь, письмо, cron и доступность партнёрского API. Для этих переходов нужны другие тесты с другими границами. Не превращайте репозиторный тест в сквозной сценарий: он станет медленнее, а ошибка — менее локальной.</p>\n<p>Общая БД плохо подходит для параллельных запусков без изоляции. Одинаковый email может столкнуться с данными соседнего процесса. Используйте отдельную схему на процесс, транзакции с контролируемым соединением или уникальные учебные значения. Если проект пока не может дать безопасную БД, честный результат — «интеграционная проверка заблокирована окружением». Mock, переименованный в integration test, пробел не закрывает.</p>\n<p>Учебный пример предполагает, что таблица уже существует и поддерживает транзакции. Нельзя переносить его в production с проверкой имени базы как единственной защитой. Нельзя считать тест доказательством миграций, если миграция не участвует в подготовке тестовой схемы.</p>\n<h2>Критерий готовности</h2>\n<p>Сценарий готов, если на чистой изолированной тестовой схеме он создаёт запись через настоящий репозиторий, читает ожидаемые поля через настоящий PDO, удаляет изменения после завершения и падает с различимым сообщением при неверном DSN или схеме. Повторный запуск не зависит от данных предыдущего запуска. При остановленной или недоступной тестовой БД тест сообщает об окружении, а не выдаёт ложный зелёный результат.</p>\n<p>Этого достаточно для первого контракта. Следующий тест добавляйте только под новое правило: уникальность адреса, преобразование даты или обработка ошибки драйвера. Сохраняйте границу узкой. Тогда падение покажет не абстрактную «проблему интеграции», а конкретный разрыв между кодом и ресурсом.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://docs.phpunit.de/en/12.5/fixtures.html\" target=\"_blank\" rel=\"noopener noreferrer\">PHPUnit 12.5 Manual: Fixtures</a></li><li><a href=\"https://www.php.net/manual/en/pdo.transactions.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: Transactions and auto-commit</a></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>"
}