{ "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Этого достаточно для первого контракта. Следующий тест добавляйте только под новое правило: уникальность адреса, преобразование даты или обработка ошибки драйвера. Сохраняйте границу узкой. Тогда падение покажет не абстрактную «проблему интеграции», а конкретный разрыв между кодом и ресурсом.
\n