Files
progcode/editorial/agent-rewrites/330.json
T

8 lines
22 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-репозитория: отдельное подключение, запись, чтение, rollback и границы доказательства.",
"contentHtml": "<p>Unit-тест сервиса зелёный, но после отправки формы запись в таблице получает пустое поле. Бывает и так: метод записи возвращает ID, а следующий вызов не находит строку. На CI к этому добавляются другой DSN, права пользователя или схема не той версии. Цена ошибки — потерянное время на поиск дефекта в бизнес-логике, хотя PHP, PDO, SQL и таблица ещё не прошли один настоящий путь.</p>\n<p><strong>Интеграционный тест репозитория проверяет именно переход PHP → PDO → тестовая база → PDO → PHP.</strong> Он вызывает публичный метод записи, читает результат тем же репозиторием и затем отменяет изменения. Такой тест не доказывает работу формы, очереди или рабочей базы. Он отвечает на более узкий вопрос: совпадает ли контракт адаптера с реальной схемой и драйвером.</p>\n<p>Разберём пример для PHP 7.2 и PHPUnit 7.5 — стека, который соответствует времени этой заметки. Он учебный: имя таблицы, драйвер, DSN и способ запуска базы нужно заменить на значения проекта. Пароль и готовая среда здесь намеренно не приводятся, поэтому статья не выдаёт фрагмент за результат запуска.</p>\n<h2>Сначала зафиксируем границу</h2>\n<p>Unit-тест оставляет соседние компоненты под контролем теста. Репозиторий можно заменить заглушкой и заранее вернуть из неё ID. Это полезно, когда проверяется правило сервиса: например, запрет дубликата или выбор ветки по ответу репозитория. Но заглушка не выполняет SQL, не читает индексы и не сверяет типы столбцов.</p>\n<p>Интеграционный тест оставляет настоящими только ресурсы, необходимые для вопроса. В нашем случае это PDO, подключённый к отдельной схеме, и заранее применённая миграция. Браузер, HTTP, почта и очередь в тест не входят: каждый из них добавил бы свою причину падения и сделал бы диагноз менее точным.</p>\n<figure><img src=\"/assets/editorial/2018/php-integration-contract-2018.svg\" alt=\"Схема PHP-интеграционного теста: PHPUnit передаёт репозиторию PDO, репозиторий пишет и читает изолированной тестовой базы, затем тест откатывает транзакцию\" /><figcaption>Проверяемый маршрут проходит через настоящий PDO и тестовую схему. Rollback очищает изменения данных в этой транзакции, но не отменяет любую операцию в базе.</figcaption></figure>\n<h2>Контракт до кода</h2>\n<p>Возьмём таблицу <code>customers</code> с полями <code>id</code>, <code>email</code> и <code>name</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>Остановить тест до первого запроса; не подставлять рабочий DSN</td></tr><tr><td>INSERT проходит, чтение пустое</td><td>Перепутан столбец, ключ или версия миграции</td><td>Прочитать строку через <code>findById()</code> и сравнить каждое поле</td><td>Сверить SQL, миграцию и преобразование результата</td></tr><tr><td>Строки остаются после теста</td><td>Не было транзакции, случился <code>commit</code> или использовано другое соединение</td><td>Проверить <code>inTransaction()</code>, затем искать второе соединение</td><td>Откатывать тот же PDO; вынести подготовку схемы из сценария</td></tr><tr><td>Падение только при параллельном запуске</td><td>Процессы используют общую схему и одинаковые значения</td><td>Сравнить одиночный и параллельный запуски</td><td>Выдать схему на процесс или уникальные тестовые данные</td></tr><tr><td>Unit зелёный, SQL ломается</td><td>Репозиторий заменён test double</td><td>Запустить узкий сценарий с настоящим PDO</td><td>Оставить оба теста: правила — в unit, адаптер — в integration</td></tr></tbody></table></div>\n<h2>Схема и подключение</h2>\n<p>Миграция должна применяться до теста, а не создаваться молча в <code>setUp()</code>. Для MySQL важно выбрать транзакционный движок таблицы, например InnoDB. Иначе вызов <code>beginTransaction()</code> может формально пройти, но откат не даст ожидаемой защиты для нетранзакционных таблиц.</p>\n<pre><code>CREATE TABLE customers (\n id INT UNSIGNED NOT NULL AUTO_INCREMENT,\n email VARCHAR(255) NOT NULL,\n name VARCHAR(255) NOT NULL,\n PRIMARY KEY (id)\n) ENGINE=InnoDB;</code></pre>\n<p>Эта схема — только минимальный контракт примера. В проекте её заменяет конкретная миграция с теми индексами, ограничениями и типами, которые должен увидеть репозиторий. Если тест создаёт упрощённую таблицу вместо миграции, он может пройти и при несовместимой рабочей схеме.</p>\n<p>Подключение берём только из тестового окружения. Проверка слова <code>test</code> в DSN защищает от очевидной опечатки, но не доказывает безопасность: строка может содержать это слово в имени хоста. Надёжнее использовать отдельную базу, отдельного пользователя без прав на рабочую схему и секреты CI. Полный DSN и пароль не должны попадать в лог.</p>\n<pre><code>&lt;?php\nfinal class TestPdo\n{\n public static function fromEnvironment()\n {\n $dsn = getenv('TEST_DATABASE_DSN');\n $user = getenv('TEST_DATABASE_USER');\n $password = getenv('TEST_DATABASE_PASSWORD');\n\n if ($dsn === false || $dsn === '' || stripos($dsn, 'test') === false) {\n throw new RuntimeException(\n 'TEST_DATABASE_DSN must name an isolated test database'\n );\n }\n\n return new PDO(\n $dsn,\n $user === false ? null : $user,\n $password === false ? null : $password,\n array(\n PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,\n PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,\n )\n );\n }\n}</code></pre>\n<p><code>getenv()</code> возвращает <code>false</code>, если переменная не задана, поэтому это состояние нужно отличать от пустого значения. Здесь тест не получает запасное подключение: при ошибке конфигурации он завершается до SQL. Проверку имени базы следует дополнить настройками CI и правами пользователя, а не считать её единственной защитой.</p>\n<h2>Один настоящий путь записи и чтения</h2>\n<p>Репозиторий принимает PDO через конструктор. Это небольшая, но важная граница: тест управляет тем же соединением, через которое выполняются <code>INSERT</code> и <code>SELECT</code>. Если <code>add()</code> внутри создаёт новое подключение, транзакция теста не контролирует его изменения.</p>\n<pre><code>&lt;?php\nfinal class CustomerRepository\n{\n private $pdo;\n\n public function __construct(PDO $pdo)\n {\n $this->pdo = $pdo;\n }\n\n public function add($email, $name)\n {\n $statement = $this->pdo->prepare(\n 'INSERT INTO customers (email, name) VALUES (:email, :name)'\n );\n $statement->execute(array(':email' => $email, ':name' => $name));\n\n return (int) $this->pdo->lastInsertId();\n }\n\n public function findById($id)\n {\n $statement = $this->pdo->prepare(\n 'SELECT id, email, name FROM customers WHERE id = :id'\n );\n $statement->execute(array(':id' => $id));\n $row = $statement->fetch();\n\n if ($row === false) {\n return null;\n }\n\n $row['id'] = (int) $row['id'];\n return $row;\n }\n}</code></pre>\n<p>Приведение результата <code>lastInsertId()</code> к целому — решение этого учебного контракта. PDO возвращает идентификатор как строку, а некоторые драйверы имеют собственные ограничения; поэтому тип и диапазон ID нужно согласовать со схемой проекта. Смысл проверки не в конкретном cast, а в том, что запись и чтение идут через реальный адаптер.</p>\n<h2>Тест с транзакцией</h2>\n<p>В PHPUnit 7.5 методы <code>setUp()</code> и <code>tearDown()</code> вызываются вокруг каждого тестового метода. Открываем транзакцию после создания PDO, выполняем один сценарий и в очистке откатываем её, если она ещё открыта. Нет смысла проверять внутреннее свойство репозитория: наблюдаемым доказательством служит строка, которую вернул запрос.</p>\n<pre><code>&lt;?php\nuse PHPUnit\\Framework\\TestCase;\n\nfinal class CustomerRepositoryIntegrationTest extends TestCase\n{\n private $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 instanceof PDO && $this->pdo->inTransaction()) {\n $this->pdo->rollBack();\n }\n }\n\n public function testStoresAndReadsCustomer(): void\n {\n $repository = new CustomerRepository($this->pdo);\n\n $id = $repository->add('anna@example.test', 'Анна');\n $stored = $repository->findById($id);\n\n $this->assertInternalType('int', $id);\n $this->assertGreaterThan(0, $id);\n $this->assertSame($id, $stored['id']);\n $this->assertSame('anna@example.test', $stored['email']);\n $this->assertSame('Анна', $stored['name']);\n }\n}</code></pre>\n<p>Вызов <code>assertSame()</code> для каждого существенного поля делает ошибку локальной: станет видно, сломался ID, адрес или имя. В реальном проекте добавьте проверку отсутствия строки после rollback отдельным шагом инфраструктурного теста или командой проверки схемы. Пока транзакция открыта, второе соединение может не увидеть незакоммиченные данные, поэтому проверку выполняют после завершения сценария.</p>\n<h2>Что именно откатывается</h2>\n<p><code>beginTransaction()</code> выключает autocommit для данного объекта PDO. <code>rollBack()</code> отменяет изменения, сделанные в этой транзакции, и возвращает соединение в autocommit. Это подходит для коротких <code>INSERT</code>, <code>UPDATE</code> и <code>DELETE</code>, если таблицы и драйвер действительно поддерживают транзакции.</p>\n<p>Rollback не является общей уборкой базы. MySQL и некоторые другие СУБД выполняют неявный <code>COMMIT</code> для DDL вроде <code>CREATE TABLE</code> и <code>DROP TABLE</code>. Поэтому схему подготавливают до тестового маршрута. Откат одного PDO также не удалит запись, которую создало другое соединение, очередь или HTTP-сервис. Эти операции требуют отдельной изоляции и собственной проверки.</p>\n<p>Есть и менее очевидная граница: соединение может быть закрыто или транзакция может завершиться раньше из-за кода репозитория. Проверка <code>inTransaction()</code> в <code>tearDown()</code> защищает очистку от повторного rollback, но не обнаруживает каждый внешний commit. Если репозиторий владеет транзакцией сам, контракт нужно оформить отдельно: тест не должен молча предполагать чужую границу.</p>\n<h2>Порядок диагностики</h2>\n<ol><li>Создайте отдельную тестовую схему и пользователя. Запретите пользователю доступ к рабочей базе.</li><li>Примените к схеме ту миграцию, которую должен видеть репозиторий. Для MySQL проверьте транзакционный движок таблицы.</li><li>Передайте DSN, пользователя и пароль через тестовое окружение с префиксом <code>TEST_</code>. При пропавшей переменной остановите тест.</li><li>Откройте один PDO с <code>PDO::ERRMODE_EXCEPTION</code> и начните транзакцию в <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 может столкнуться с данными соседнего процесса, а внешний commit — обойти rollback. Используйте схему на процесс, уникальные тестовые значения или другой согласованный механизм. Если безопасной тестовой базы нет, честный результат — заблокированное окружением интеграционное испытание; mock не превращается в настоящий SQL от другого названия.</p>\n<p>Сценарий готов, когда на чистой тестовой схеме он создаёт строку через настоящий репозиторий, читает ожидаемые поля, не зависит от данных прошлого запуска и не оставляет изменения после rollback. При неверном DSN или несовместимой схеме он должен завершиться различимой ошибкой. Это доказательство одного контракта, а не сертификат всей системы.</p>\n<p>Следующий тест добавляйте под новое правило: уникальность адреса, преобразование даты или обработку исключения драйвера. Сохраняйте вопрос узким. Тогда падение покажет конкретный разрыв между PHP-кодом и ресурсом, а unit-тесты продолжат быстро проверять правила без подключения к базе.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://docs.phpunit.de/en/7.5/fixtures.html\" target=\"_blank\" rel=\"noopener noreferrer\">PHPUnit 7.5 Manual: Fixtures</a></li><li><a href=\"https://docs.phpunit.de/en/7.5/test-doubles.html\" target=\"_blank\" rel=\"noopener noreferrer\">PHPUnit 7.5 Manual: Test Doubles</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><li><a href=\"https://www.php.net/manual/en/function.getenv.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: getenv</a></li></ul>"
}