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 зависят от проекта. Код показывает принцип, а не универсальную конфигурацию.
\nUnit-тест проверяет решение одного класса. Репозиторий в нём можно заменить заглушкой, а ответ заглушки задать заранее. Это правильно, если вопрос звучит так: «запретит ли сервис дубликат?» Но заглушка не выполняет SQL, не читает схему и не проверяет настройки PDO.
\nИнтеграционный тест отвечает на другой вопрос: «сможет ли этот адаптер записать и прочитать данные через настоящий драйвер?» Поэтому он использует тестовую БД и реальную схему. Его граница должна быть узкой. Не нужно добавлять браузер, отправку почты и внешний API. Каждый новый ресурс добавляет собственную причину падения.
\nВозьмём таблицу customers с полями id, email и name. Тест получает адрес anna@example.test и имя Анна. Метод add() возвращает числовой ID. Метод findById() по этому ID возвращает те же значения. После теста строка не должна остаться в общей тестовой базе.
Это не проверка всех запросов репозитория. Она проверяет минимальный маршрут записи и чтения. Если проект дополнительно нормализует регистр, проверяет уникальность или преобразует даты, для каждого такого правила нужен отдельный сценарий. Не прячьте несколько разных утверждений в одном тесте: тогда ошибка перестаёт указывать на конкретный контракт.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Не создаётся PDO | Пустой DSN, неверный драйвер или тест обращается не к той среде | Вывести безопасный идентификатор БД и проверить переменные TEST_* | Остановить тест без значения по умолчанию; выдать отдельную ошибку конфигурации |
| INSERT проходит, чтение пустое | Перепутан столбец, имя ключа или схема отличается от миграции | Прочитать запись через findById() и сравнить каждое поле | Сверить SQL, схему и преобразование результата; не добавлять mock |
| После запуска остаются строки | Нет транзакции, был commit или запись сделана другим соединением | Проверить inTransaction() и состояние БД отдельным запросом | Откатывать тот же PDO; вынести DDL и чужие соединения за пределы сценария |
| Тест падает только параллельно | Общая схема и одинаковые данные пересекаются между процессами | Запустить один тест и сравнить данные с параллельным запуском | Дать каждому процессу схему или уникальный набор данных |
| Unit-тест зелёный, SQL ломается | Репозиторий заменён заглушкой | Запустить узкий тест с настоящим PDO и тестовой схемой | Оставить unit-тест для правил, добавить отдельную интеграционную проверку адаптера |
Подключение должно быть явным. Не зашивайте в тест строку вроде mysql:host=localhost;dbname=site. По ней нельзя понять, безопасна ли база. Не используйте production DSN как запасной вариант. Если переменная отсутствует, тест обязан завершиться до первого запроса.
Проверка подстроки test ниже защищает только от очевидной опечатки. Она не заменяет права доступа. Надёжнее создать отдельного пользователя без доступа к рабочей схеме, использовать отдельную сеть и передавать секреты через CI. Учебный фрагмент намеренно не содержит пароль.
<?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Тест ниже вызывает публичные методы CustomerRepository. Он не сравнивает SQL-строку с её копией в тесте. Доказательством служит результат чтения из БД. Если метод перепутает поля, схема не примет значение или преобразование результата изменит ключ, проверка должна упасть.
<?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(), транзакция теста не контролирует его изменения. Передайте соединение или фабрику явно. Иначе зелёный откат может скрывать оставшиеся строки.
beginTransaction() отключает autocommit для соединения. rollBack() отменяет изменения данных и возвращает соединение в autocommit. Это подходит для короткого теста с INSERT, UPDATE и DELETE. Проверка inTransaction() в tearDown() не вызывает ошибку, если подготовка завершилась раньше открытия транзакции.
Транзакция не является универсальной уборкой. Некоторые СУБД выполняют неявный commit для DDL, например CREATE TABLE и DROP TABLE. Поэтому миграцию схемы выполняйте отдельным подготовительным шагом. Откат одного PDO также не уберёт запись, созданную вторым соединением, очередью или HTTP-сервисом. Это отрицательный путь: если граница не контролируется, не называйте тест изолированным.
TEST_. При пустом или подозрительном DSN остановите запуск.setUp().tearDown() откатите транзакцию, если она ещё открыта. Отдельно проверьте, что код не создаёт второе неконтролируемое соединение.Такой тест не проверяет HTML-форму, CSRF, маршрутизацию, очередь, письмо, cron и доступность партнёрского API. Для этих переходов нужны другие тесты с другими границами. Не превращайте репозиторный тест в сквозной сценарий: он станет медленнее, а ошибка — менее локальной.
\nОбщая БД плохо подходит для параллельных запусков без изоляции. Одинаковый email может столкнуться с данными соседнего процесса. Используйте отдельную схему на процесс, транзакции с контролируемым соединением или уникальные учебные значения. Если проект пока не может дать безопасную БД, честный результат — «интеграционная проверка заблокирована окружением». Mock, переименованный в integration test, пробел не закрывает.
\nУчебный пример предполагает, что таблица уже существует и поддерживает транзакции. Нельзя переносить его в production с проверкой имени базы как единственной защитой. Нельзя считать тест доказательством миграций, если миграция не участвует в подготовке тестовой схемы.
\nСценарий готов, если на чистой изолированной тестовой схеме он создаёт запись через настоящий репозиторий, читает ожидаемые поля через настоящий PDO, удаляет изменения после завершения и падает с различимым сообщением при неверном DSN или схеме. Повторный запуск не зависит от данных предыдущего запуска. При остановленной или недоступной тестовой БД тест сообщает об окружении, а не выдаёт ложный зелёный результат.
\nЭтого достаточно для первого контракта. Следующий тест добавляйте только под новое правило: уникальность адреса, преобразование даты или обработка ошибки драйвера. Сохраняйте границу узкой. Тогда падение покажет не абстрактную «проблему интеграции», а конкретный разрыв между кодом и ресурсом.
\nUnit-тест сервиса зелёный, но после отправки формы запись в таблице получает пустое поле. Бывает и так: метод записи возвращает ID, а следующий вызов не находит строку. На CI к этому добавляются другой DSN, права пользователя или схема не той версии. Цена ошибки — потерянное время на поиск дефекта в бизнес-логике, хотя PHP, PDO, SQL и таблица ещё не прошли один настоящий путь.
\nИнтеграционный тест репозитория проверяет именно переход PHP → PDO → тестовая база → PDO → PHP. Он вызывает публичный метод записи, читает результат тем же репозиторием и затем отменяет изменения. Такой тест не доказывает работу формы, очереди или рабочей базы. Он отвечает на более узкий вопрос: совпадает ли контракт адаптера с реальной схемой и драйвером.
\nРазберём пример для PHP 7.2 и PHPUnit 7.5 — стека, который соответствует времени этой заметки. Он учебный: имя таблицы, драйвер, DSN и способ запуска базы нужно заменить на значения проекта. Пароль и готовая среда здесь намеренно не приводятся, поэтому статья не выдаёт фрагмент за результат запуска.
\nUnit-тест оставляет соседние компоненты под контролем теста. Репозиторий можно заменить заглушкой и заранее вернуть из неё ID. Это полезно, когда проверяется правило сервиса: например, запрет дубликата или выбор ветки по ответу репозитория. Но заглушка не выполняет SQL, не читает индексы и не сверяет типы столбцов.
\nИнтеграционный тест оставляет настоящими только ресурсы, необходимые для вопроса. В нашем случае это PDO, подключённый к отдельной схеме, и заранее применённая миграция. Браузер, HTTP, почта и очередь в тест не входят: каждый из них добавил бы свою причину падения и сделал бы диагноз менее точным.
\nВозьмём таблицу customers с полями id, email и name. Метод add() принимает адрес и имя, записывает строку и возвращает её ID. Метод findById() получает этот ID и возвращает те же значения. После теста добавленная строка не должна остаться в схеме.
Для такого контракта нужны три независимых ожидания: запись действительно принята базой, чтение возвращает нужную строку, а откат не оставляет за собой данные. Нормализация регистра, уникальность адреса и преобразование дат — уже отдельные правила. Их лучше проверять отдельными сценариями, чтобы ошибка указывала на конкретную границу.
\n| Наблюдаемый симптом | Вероятная причина | Проверка | Следующее действие |
|---|---|---|---|
| PDO не создаётся | Пустой DSN, отсутствует драйвер или подключена не та среда | Проверить переменные TEST_* и безопасный идентификатор схемы | Остановить тест до первого запроса; не подставлять рабочий DSN |
| INSERT проходит, чтение пустое | Перепутан столбец, ключ или версия миграции | Прочитать строку через findById() и сравнить каждое поле | Сверить SQL, миграцию и преобразование результата |
| Строки остаются после теста | Не было транзакции, случился commit или использовано другое соединение | Проверить inTransaction(), затем искать второе соединение | Откатывать тот же PDO; вынести подготовку схемы из сценария |
| Падение только при параллельном запуске | Процессы используют общую схему и одинаковые значения | Сравнить одиночный и параллельный запуски | Выдать схему на процесс или уникальные тестовые данные |
| Unit зелёный, SQL ломается | Репозиторий заменён test double | Запустить узкий сценарий с настоящим PDO | Оставить оба теста: правила — в unit, адаптер — в integration |
Миграция должна применяться до теста, а не создаваться молча в setUp(). Для MySQL важно выбрать транзакционный движок таблицы, например InnoDB. Иначе вызов beginTransaction() может формально пройти, но откат не даст ожидаемой защиты для нетранзакционных таблиц.
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 и пароль не должны попадать в лог.
<?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}\ngetenv() возвращает false, если переменная не задана, поэтому это состояние нужно отличать от пустого значения. Здесь тест не получает запасное подключение: при ошибке конфигурации он завершается до SQL. Проверку имени базы следует дополнить настройками CI и правами пользователя, а не считать её единственной защитой.
Репозиторий принимает PDO через конструктор. Это небольшая, но важная граница: тест управляет тем же соединением, через которое выполняются INSERT и SELECT. Если add() внутри создаёт новое подключение, транзакция теста не контролирует его изменения.
<?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, а в том, что запись и чтение идут через реальный адаптер.
В PHPUnit 7.5 методы setUp() и tearDown() вызываются вокруг каждого тестового метода. Открываем транзакцию после создания PDO, выполняем один сценарий и в очистке откатываем её, если она ещё открыта. Нет смысла проверять внутреннее свойство репозитория: наблюдаемым доказательством служит строка, которую вернул запрос.
<?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 отдельным шагом инфраструктурного теста или командой проверки схемы. Пока транзакция открыта, второе соединение может не увидеть незакоммиченные данные, поэтому проверку выполняют после завершения сценария.
beginTransaction() выключает autocommit для данного объекта PDO. rollBack() отменяет изменения, сделанные в этой транзакции, и возвращает соединение в autocommit. Это подходит для коротких INSERT, UPDATE и DELETE, если таблицы и драйвер действительно поддерживают транзакции.
Rollback не является общей уборкой базы. MySQL и некоторые другие СУБД выполняют неявный COMMIT для DDL вроде CREATE TABLE и DROP TABLE. Поэтому схему подготавливают до тестового маршрута. Откат одного PDO также не удалит запись, которую создало другое соединение, очередь или HTTP-сервис. Эти операции требуют отдельной изоляции и собственной проверки.
Есть и менее очевидная граница: соединение может быть закрыто или транзакция может завершиться раньше из-за кода репозитория. Проверка inTransaction() в tearDown() защищает очистку от повторного rollback, но не обнаруживает каждый внешний commit. Если репозиторий владеет транзакцией сам, контракт нужно оформить отдельно: тест не должен молча предполагать чужую границу.
TEST_. При пропавшей переменной остановите тест.PDO::ERRMODE_EXCEPTION и начните транзакцию в setUp().tearDown() откатите транзакцию, если она ещё открыта. Не создавайте второе соединение внутри репозитория.Этот сценарий не проверяет HTML-форму, CSRF, маршрутизацию, очередь, письмо, cron и доступность партнёрского API. Для них нужны другие тесты с другими границами. Если добавить всё сразу, тест станет медленнее, а причина падения потеряется между ресурсами.
\nОбщая схема плохо подходит для параллельных запусков без дополнительной изоляции. Одинаковый email может столкнуться с данными соседнего процесса, а внешний commit — обойти rollback. Используйте схему на процесс, уникальные тестовые значения или другой согласованный механизм. Если безопасной тестовой базы нет, честный результат — заблокированное окружением интеграционное испытание; mock не превращается в настоящий SQL от другого названия.
\nСценарий готов, когда на чистой тестовой схеме он создаёт строку через настоящий репозиторий, читает ожидаемые поля, не зависит от данных прошлого запуска и не оставляет изменения после rollback. При неверном DSN или несовместимой схеме он должен завершиться различимой ошибкой. Это доказательство одного контракта, а не сертификат всей системы.
\nСледующий тест добавляйте под новое правило: уникальность адреса, преобразование даты или обработку исключения драйвера. Сохраняйте вопрос узким. Тогда падение покажет конкретный разрыв между PHP-кодом и ресурсом, а unit-тесты продолжат быстро проверять правила без подключения к базе.
\n