diff --git a/editorial/agent-rewrites/330.json b/editorial/agent-rewrites/330.json index 2843f44..71cf165 100644 --- a/editorial/agent-rewrites/330.json +++ b/editorial/agent-rewrites/330.json @@ -1,7 +1,7 @@ { "index": 330, "slug": "editorial-2018-11-practice-php-integration-tests", - "title": "PHP-интеграционный тест репозитория: проверить запись, чтение и границы отката", - "excerpt": "Unit-тест может быть зелёным, пока настоящий PDO не увидит схему базы. Разбираем узкий интеграционный тест PHP-репозитория: изолированное подключение, запись, чтение, откат и проверяемый предел его доказательств.", - "contentHtml": "

Unit-тест сервиса зелёный, но после отправки формы запись в таблице получает пустое поле. Иногда метод записи возвращает ID, а следующий вызов не находит строку. Другой вариант — тест проходит локально и падает на CI из-за DSN, прав пользователя или другой схемы. Цена ошибки — ложная уверенность перед релизом. Команда ищет дефект в бизнес-логике, хотя PHP, PDO, SQL и таблица никогда не проходили один путь вместе.

\n

Интеграционный тест репозитория должен оставить настоящим только нужный переход: PHP → PDO → изолированная тестовая БД → PDO → PHP. Он записывает данные через публичный метод репозитория, читает их тем же адаптером и проверяет результат. Такой тест не доказывает работу всей формы, очереди или production-БД. Он фиксирует один контракт и показывает, на какой границе он нарушился.

\n

Ниже приведён учебный пример для PHP и PHPUnit. В нём нет рабочего пароля, готового контейнера и обещания результата в конкретной среде. Названия таблицы, драйвер, способ запуска БД и версия PHPUnit зависят от проекта. Код показывает принцип, а не универсальную конфигурацию.

\n

Механизм: где заканчивается unit-тест

\n

Unit-тест проверяет решение одного класса. Репозиторий в нём можно заменить заглушкой, а ответ заглушки задать заранее. Это правильно, если вопрос звучит так: «запретит ли сервис дубликат?» Но заглушка не выполняет SQL, не читает схему и не проверяет настройки PDO.

\n

Интеграционный тест отвечает на другой вопрос: «сможет ли этот адаптер записать и прочитать данные через настоящий драйвер?» Поэтому он использует тестовую БД и реальную схему. Его граница должна быть узкой. Не нужно добавлять браузер, отправку почты и внешний API. Каждый новый ресурс добавляет собственную причину падения.

\n
\"Схема
Учебный контракт проходит через настоящий PDO и тестовую схему. Откат очищает изменения данных, но не отменяет каждый тип операции.
\n

Контракт до кода

\n

Возьмём таблицу customers с полями id, email и name. Тест получает адрес anna@example.test и имя Анна. Метод add() возвращает числовой ID. Метод findById() по этому ID возвращает те же значения. После теста строка не должна остаться в общей тестовой базе.

\n

Это не проверка всех запросов репозитория. Она проверяет минимальный маршрут записи и чтения. Если проект дополнительно нормализует регистр, проверяет уникальность или преобразует даты, для каждого такого правила нужен отдельный сценарий. Не прячьте несколько разных утверждений в одном тесте: тогда ошибка перестаёт указывать на конкретный контракт.

\n
СимптомПричинаПроверкаДействие
Не создаётся PDOПустой DSN, неверный драйвер или тест обращается не к той средеВывести безопасный идентификатор БД и проверить переменные TEST_*Остановить тест без значения по умолчанию; выдать отдельную ошибку конфигурации
INSERT проходит, чтение пустоеПерепутан столбец, имя ключа или схема отличается от миграцииПрочитать запись через findById() и сравнить каждое полеСверить SQL, схему и преобразование результата; не добавлять mock
После запуска остаются строкиНет транзакции, был commit или запись сделана другим соединениемПроверить inTransaction() и состояние БД отдельным запросомОткатывать тот же PDO; вынести DDL и чужие соединения за пределы сценария
Тест падает только параллельноОбщая схема и одинаковые данные пересекаются между процессамиЗапустить один тест и сравнить данные с параллельным запускомДать каждому процессу схему или уникальный набор данных
Unit-тест зелёный, SQL ломаетсяРепозиторий заменён заглушкойЗапустить узкий тест с настоящим PDO и тестовой схемойОставить unit-тест для правил, добавить отдельную интеграционную проверку адаптера
\n

Изолированное подключение

\n

Подключение должно быть явным. Не зашивайте в тест строку вроде mysql:host=localhost;dbname=site. По ней нельзя понять, безопасна ли база. Не используйте production DSN как запасной вариант. Если переменная отсутствует, тест обязан завершиться до первого запроса.

\n

Проверка подстроки test ниже защищает только от очевидной опечатки. Она не заменяет права доступа. Надёжнее создать отдельного пользователя без доступа к рабочей схеме, использовать отдельную сеть и передавать секреты через CI. Учебный фрагмент намеренно не содержит пароль.

\n
<?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]); } }
\n

В реальном проекте дополнительно проверьте имя базы через конфигурацию окружения и права пользователя. Не печатайте пароль и полный DSN в лог. Сообщения теста должны помогать определить среду, но не раскрывать секреты.

\n

Один настоящий путь записи и чтения

\n

Тест ниже вызывает публичные методы CustomerRepository. Он не сравнивает SQL-строку с её копией в тесте. Доказательством служит результат чтения из БД. Если метод перепутает поля, схема не примет значение или преобразование результата изменит ключ, проверка должна упасть.

\n
<?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']); } }
\n

Полевая версия класса должна принимать PDO через конструктор. Если репозиторий создаёт новое соединение внутри add(), транзакция теста не контролирует его изменения. Передайте соединение или фабрику явно. Иначе зелёный откат может скрывать оставшиеся строки.

\n

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

\n

beginTransaction() отключает autocommit для соединения. rollBack() отменяет изменения данных и возвращает соединение в autocommit. Это подходит для короткого теста с INSERT, UPDATE и DELETE. Проверка inTransaction() в tearDown() не вызывает ошибку, если подготовка завершилась раньше открытия транзакции.

\n

Транзакция не является универсальной уборкой. Некоторые СУБД выполняют неявный commit для DDL, например CREATE TABLE и DROP TABLE. Поэтому миграцию схемы выполняйте отдельным подготовительным шагом. Откат одного PDO также не уберёт запись, созданную вторым соединением, очередью или HTTP-сервисом. Это отрицательный путь: если граница не контролируется, не называйте тест изолированным.

\n

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

\n
  1. Создайте отдельную тестовую схему и пользователя. Запретите этому пользователю доступ к рабочей базе.
  2. Примените к тестовой схеме ту версию миграции, которую должен видеть репозиторий. Не создавайте таблицу молча внутри теста.
  3. Передайте DSN, пользователя и пароль через тестовое окружение с префиксом TEST_. При пустом или подозрительном DSN остановите запуск.
  4. Откройте один PDO с режимом исключений и начните транзакцию в setUp().
  5. Вызовите один публичный метод записи и сохраните его ID. Не проверяйте внутренние свойства репозитория.
  6. Прочитайте запись тем же репозиторием и сравните ID, адрес и имя. Каждое важное поле должно иметь отдельное утверждение.
  7. В tearDown() откатите транзакцию, если она ещё открыта. Отдельно проверьте, что код не создаёт второе неконтролируемое соединение.
  8. Запустите сначала один класс, затем тот же сценарий в режиме проекта. При падении разделите ошибку конфигурации, подключения, SQL, схемы и ожидания результата.
\n

Ограничения

\n

Такой тест не проверяет HTML-форму, CSRF, маршрутизацию, очередь, письмо, cron и доступность партнёрского API. Для этих переходов нужны другие тесты с другими границами. Не превращайте репозиторный тест в сквозной сценарий: он станет медленнее, а ошибка — менее локальной.

\n

Общая БД плохо подходит для параллельных запусков без изоляции. Одинаковый email может столкнуться с данными соседнего процесса. Используйте отдельную схему на процесс, транзакции с контролируемым соединением или уникальные учебные значения. Если проект пока не может дать безопасную БД, честный результат — «интеграционная проверка заблокирована окружением». Mock, переименованный в integration test, пробел не закрывает.

\n

Учебный пример предполагает, что таблица уже существует и поддерживает транзакции. Нельзя переносить его в production с проверкой имени базы как единственной защитой. Нельзя считать тест доказательством миграций, если миграция не участвует в подготовке тестовой схемы.

\n

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

\n

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

\n

Этого достаточно для первого контракта. Следующий тест добавляйте только под новое правило: уникальность адреса, преобразование даты или обработка ошибки драйвера. Сохраняйте границу узкой. Тогда падение покажет не абстрактную «проблему интеграции», а конкретный разрыв между кодом и ресурсом.

\n

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

" + "title": "PHP-интеграционный тест репозитория: проверить запись, чтение и откат", + "excerpt": "Unit-тест может быть зелёным, пока настоящий PDO не видит схему базы. Разбираем узкий интеграционный тест PHP-репозитория: отдельное подключение, запись, чтение, rollback и границы доказательства.", + "contentHtml": "

Unit-тест сервиса зелёный, но после отправки формы запись в таблице получает пустое поле. Бывает и так: метод записи возвращает ID, а следующий вызов не находит строку. На CI к этому добавляются другой DSN, права пользователя или схема не той версии. Цена ошибки — потерянное время на поиск дефекта в бизнес-логике, хотя PHP, PDO, SQL и таблица ещё не прошли один настоящий путь.

\n

Интеграционный тест репозитория проверяет именно переход PHP → PDO → тестовая база → PDO → PHP. Он вызывает публичный метод записи, читает результат тем же репозиторием и затем отменяет изменения. Такой тест не доказывает работу формы, очереди или рабочей базы. Он отвечает на более узкий вопрос: совпадает ли контракт адаптера с реальной схемой и драйвером.

\n

Разберём пример для PHP 7.2 и PHPUnit 7.5 — стека, который соответствует времени этой заметки. Он учебный: имя таблицы, драйвер, DSN и способ запуска базы нужно заменить на значения проекта. Пароль и готовая среда здесь намеренно не приводятся, поэтому статья не выдаёт фрагмент за результат запуска.

\n

Сначала зафиксируем границу

\n

Unit-тест оставляет соседние компоненты под контролем теста. Репозиторий можно заменить заглушкой и заранее вернуть из неё ID. Это полезно, когда проверяется правило сервиса: например, запрет дубликата или выбор ветки по ответу репозитория. Но заглушка не выполняет SQL, не читает индексы и не сверяет типы столбцов.

\n

Интеграционный тест оставляет настоящими только ресурсы, необходимые для вопроса. В нашем случае это PDO, подключённый к отдельной схеме, и заранее применённая миграция. Браузер, HTTP, почта и очередь в тест не входят: каждый из них добавил бы свою причину падения и сделал бы диагноз менее точным.

\n
\"Схема
Проверяемый маршрут проходит через настоящий PDO и тестовую схему. Rollback очищает изменения данных в этой транзакции, но не отменяет любую операцию в базе.
\n

Контракт до кода

\n

Возьмём таблицу customers с полями id, email и name. Метод add() принимает адрес и имя, записывает строку и возвращает её ID. Метод findById() получает этот ID и возвращает те же значения. После теста добавленная строка не должна остаться в схеме.

\n

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

\n
Наблюдаемый симптомВероятная причинаПроверкаСледующее действие
PDO не создаётсяПустой DSN, отсутствует драйвер или подключена не та средаПроверить переменные TEST_* и безопасный идентификатор схемыОстановить тест до первого запроса; не подставлять рабочий DSN
INSERT проходит, чтение пустоеПерепутан столбец, ключ или версия миграцииПрочитать строку через findById() и сравнить каждое полеСверить SQL, миграцию и преобразование результата
Строки остаются после тестаНе было транзакции, случился commit или использовано другое соединениеПроверить inTransaction(), затем искать второе соединениеОткатывать тот же PDO; вынести подготовку схемы из сценария
Падение только при параллельном запускеПроцессы используют общую схему и одинаковые значенияСравнить одиночный и параллельный запускиВыдать схему на процесс или уникальные тестовые данные
Unit зелёный, SQL ломаетсяРепозиторий заменён test doubleЗапустить узкий сценарий с настоящим PDOОставить оба теста: правила — в unit, адаптер — в integration
\n

Схема и подключение

\n

Миграция должна применяться до теста, а не создаваться молча в setUp(). Для MySQL важно выбрать транзакционный движок таблицы, например InnoDB. Иначе вызов beginTransaction() может формально пройти, но откат не даст ожидаемой защиты для нетранзакционных таблиц.

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

Эта схема — только минимальный контракт примера. В проекте её заменяет конкретная миграция с теми индексами, ограничениями и типами, которые должен увидеть репозиторий. Если тест создаёт упрощённую таблицу вместо миграции, он может пройти и при несовместимой рабочей схеме.

\n

Подключение берём только из тестового окружения. Проверка слова test в DSN защищает от очевидной опечатки, но не доказывает безопасность: строка может содержать это слово в имени хоста. Надёжнее использовать отдельную базу, отдельного пользователя без прав на рабочую схему и секреты CI. Полный DSN и пароль не должны попадать в лог.

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

getenv() возвращает false, если переменная не задана, поэтому это состояние нужно отличать от пустого значения. Здесь тест не получает запасное подключение: при ошибке конфигурации он завершается до SQL. Проверку имени базы следует дополнить настройками CI и правами пользователя, а не считать её единственной защитой.

\n

Один настоящий путь записи и чтения

\n

Репозиторий принимает PDO через конструктор. Это небольшая, но важная граница: тест управляет тем же соединением, через которое выполняются INSERT и SELECT. Если add() внутри создаёт новое подключение, транзакция теста не контролирует его изменения.

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

Приведение результата lastInsertId() к целому — решение этого учебного контракта. PDO возвращает идентификатор как строку, а некоторые драйверы имеют собственные ограничения; поэтому тип и диапазон ID нужно согласовать со схемой проекта. Смысл проверки не в конкретном cast, а в том, что запись и чтение идут через реальный адаптер.

\n

Тест с транзакцией

\n

В PHPUnit 7.5 методы setUp() и tearDown() вызываются вокруг каждого тестового метода. Открываем транзакцию после создания PDO, выполняем один сценарий и в очистке откатываем её, если она ещё открыта. Нет смысла проверять внутреннее свойство репозитория: наблюдаемым доказательством служит строка, которую вернул запрос.

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

Вызов assertSame() для каждого существенного поля делает ошибку локальной: станет видно, сломался ID, адрес или имя. В реальном проекте добавьте проверку отсутствия строки после rollback отдельным шагом инфраструктурного теста или командой проверки схемы. Пока транзакция открыта, второе соединение может не увидеть незакоммиченные данные, поэтому проверку выполняют после завершения сценария.

\n

Что именно откатывается

\n

beginTransaction() выключает autocommit для данного объекта PDO. rollBack() отменяет изменения, сделанные в этой транзакции, и возвращает соединение в autocommit. Это подходит для коротких INSERT, UPDATE и DELETE, если таблицы и драйвер действительно поддерживают транзакции.

\n

Rollback не является общей уборкой базы. MySQL и некоторые другие СУБД выполняют неявный COMMIT для DDL вроде CREATE TABLE и DROP TABLE. Поэтому схему подготавливают до тестового маршрута. Откат одного PDO также не удалит запись, которую создало другое соединение, очередь или HTTP-сервис. Эти операции требуют отдельной изоляции и собственной проверки.

\n

Есть и менее очевидная граница: соединение может быть закрыто или транзакция может завершиться раньше из-за кода репозитория. Проверка inTransaction() в tearDown() защищает очистку от повторного rollback, но не обнаруживает каждый внешний commit. Если репозиторий владеет транзакцией сам, контракт нужно оформить отдельно: тест не должен молча предполагать чужую границу.

\n

Порядок диагностики

\n
  1. Создайте отдельную тестовую схему и пользователя. Запретите пользователю доступ к рабочей базе.
  2. Примените к схеме ту миграцию, которую должен видеть репозиторий. Для MySQL проверьте транзакционный движок таблицы.
  3. Передайте DSN, пользователя и пароль через тестовое окружение с префиксом TEST_. При пропавшей переменной остановите тест.
  4. Откройте один PDO с PDO::ERRMODE_EXCEPTION и начните транзакцию в setUp().
  5. Вызовите один публичный метод записи и сохраните его ID. Не подменяйте репозиторий заглушкой.
  6. Прочитайте строку тем же репозиторием и сравните ID, адрес и имя отдельными утверждениями.
  7. В tearDown() откатите транзакцию, если она ещё открыта. Не создавайте второе соединение внутри репозитория.
  8. Сначала запустите один класс, затем тот же сценарий в режиме проекта. Разделите ошибку конфигурации, подключения, SQL, схемы и ожидания результата.
\n

Ограничения и критерий готовности

\n

Этот сценарий не проверяет HTML-форму, CSRF, маршрутизацию, очередь, письмо, cron и доступность партнёрского API. Для них нужны другие тесты с другими границами. Если добавить всё сразу, тест станет медленнее, а причина падения потеряется между ресурсами.

\n

Общая схема плохо подходит для параллельных запусков без дополнительной изоляции. Одинаковый email может столкнуться с данными соседнего процесса, а внешний commit — обойти rollback. Используйте схему на процесс, уникальные тестовые значения или другой согласованный механизм. Если безопасной тестовой базы нет, честный результат — заблокированное окружением интеграционное испытание; mock не превращается в настоящий SQL от другого названия.

\n

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

\n

Следующий тест добавляйте под новое правило: уникальность адреса, преобразование даты или обработку исключения драйвера. Сохраняйте вопрос узким. Тогда падение покажет конкретный разрыв между PHP-кодом и ресурсом, а unit-тесты продолжат быстро проверять правила без подключения к базе.

\n

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

" }