8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"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><?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 => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); } }</code></pre>\n<p>В реальном проекте дополнительно проверьте имя базы через конфигурацию окружения и права пользователя. Не печатайте пароль и полный DSN в лог. Сообщения теста должны помогать определить среду, но не раскрывать секреты.</p>\n<h2>Один настоящий путь записи и чтения</h2>\n<p>Тест ниже вызывает публичные методы <code>CustomerRepository</code>. Он не сравнивает SQL-строку с её копией в тесте. Доказательством служит результат чтения из БД. Если метод перепутает поля, схема не примет значение или преобразование результата изменит ключ, проверка должна упасть.</p>\n<pre><code><?php final class CustomerRepositoryIntegrationTest extends TestCase { private PDO $pdo; protected function setUp(): void { $this->pdo = TestPdo::fromEnvironment(); $this->pdo->beginTransaction(); } protected function tearDown(): void { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } } public function testStoresAndReadsCustomer(): void { $repository = new CustomerRepository($this->pdo); $id = $repository->add('anna@example.test', 'Анна'); $stored = $repository->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>"
|
||
}
|