Unit-тест сервиса зелёный, а после отправки формы в таблице появляется запись с пустым полем или её нельзя прочитать тем же кодом. Цена ошибки — не один 500-й ответ: команда может неделю менять бизнес-логику, хотя запрос, тип столбца или строка подключения никогда не были проверены вместе.
Разберём один вопрос: какой контракт должен зафиксировать интеграционный тест PHP-репозитория, чтобы он действительно проверял работу с БД? Ниже учебный пример для PHP 7.2 и PHPUnit 7.5. Он не запускает контейнер из статьи и не содержит рабочего пароля: тестовую БД, пользователя и способ её запуска определяет конкретный проект.
Интеграция начинается там, где PHP перестаёт быть единственным исполнителем
В unit-тесте мы можем передать репозиторию подставной объект и проверить решение внутри класса. Это полезно для правил валидации, расчётов и веток ошибок. Но такой тест не отправляет SQL драйверу, не знает схему таблицы и не читает переменную окружения. Когда важен путь PHP → PDO → тестовая БД → PDO → PHP, его нужно пройти настоящим адаптером.
Контракт здесь короткий: при заданных данных репозиторий записывает ровно те поля, которые нужны сценарию; затем он читает ту же запись и возвращает ожидаемые значения. В него не надо включать весь сайт, почту и внешний API. Чем уже граница, тем понятнее причина падения: конфигурация, соединение, SQL, схема или преобразование результата.
Сначала называю вход, выход и следы операции
Перед кодом полезно записать контракт словами. Для примера возьмём таблицу customers с полями id, email и name. Вход — валидный адрес и имя. Выход — идентификатор, а после findById() тот же адрес и имя. След операции — одна строка в тестовой БД. Если вместо этого нужен уникальный индекс, нормализация регистра или часовой пояс, это уже отдельный проверяемый случай, а не скрытая деталь первого теста.
| Часть контракта | Что задаём | Что проверяем | Что не доказывает тест |
|---|---|---|---|
| Конфигурация | TEST_DATABASE_DSN, пользователь, пароль вне репозитория | Подключение создаётся только к тестовой БД | Доступность production-БД или права боевого пользователя |
| Запись | Адрес anna@example.test и имя Анна | Метод вернул числовой ID и SQL принял значения | Работу формы, шаблона и браузера |
| Чтение | ID из той же операции | Поля не потерялись и не поменяли тип без причины | Все возможные выборки каталога |
| Очистка | Открытая транзакция на соединении теста | После теста данные не остаются в этой транзакции | Откат DDL или вызова внешнего HTTP-сервиса |
| Ошибка | Отсутствующий DSN или неверная схема | Падение объясняет границу, а не маскируется пустым массивом | Что ошибка автоматически исправится на стенде |
Тестовая конфигурация должна быть отдельной
Подключение нельзя прятать в конструкторе репозитория под строкой mysql:host=localhost;dbname=site. В тесте это опасно: читатель не видит, к какой базе обратится команда, а случайно оставленный пароль легко попадёт в Git. Берём DSN и учётные данные из переменных с префиксом TEST_. Сам префикс не является защитой, поэтому ниже есть явная проверка имени базы и понятная остановка при пустом значении.
Тестовая БД может жить в отдельном контейнере, локальном сервисе или выделенном стенде. Контракт от этого не меняется, но окружение должно быть изолировано от рабочих данных. В этой заметке не утверждается, что какой-либо контейнер был запущен: команда запуска и образ зависят от версии MySQL, драйвера PDO и правил проекта. Сначала проверяем адрес, затем разрешаем тесту открыть соединение.
<?php
// tests/Support/TestPdo.php — PHP 7.2
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, array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
));
}
}
Проверка слова test — только страховка от очевидной опечатки, а не модель прав доступа. В реальном проекте надёжнее отдельный пользователь без доступа к production-схемам, отдельная сеть и имя БД из закрытой тестовой конфигурации. Если DSN пустой или выглядит сомнительно, лучше остановить запуск с ошибкой, чем заменить его значением по умолчанию.
Пишу один путь через настоящий PDO-репозиторий
Следующий фрагмент показывает форму теста, а не готовый слой доступа к данным для любого проекта. CustomerRepository здесь использует переданный PDO, поэтому тот же SQL увидят драйвер и тестовая схема. В setUp() создаётся фикстура и открывается транзакция; в tearDown() она откатывается даже после падения проверки. PHPUnit вызывает эти методы вокруг теста, но их конкретные сигнатуры стоит сверить с закреплённой версией фреймворка.
<?php
use PHPUnit\Framework\TestCase;
final class CustomerRepositoryIntegrationTest extends TestCase
{
/** @var PDO */
private $pdo;
protected function setUp(): void
{
$this->pdo = TestPdo::fromEnvironment();
$this->pdo->beginTransaction();
}
protected function tearDown(): void
{
if ($this->pdo instanceof PDO && $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);
$this->assertSame('anna@example.test', $stored['email']);
$this->assertSame('Анна', $stored['name']);
}
}
Важно, что тест не проверяет SQL строкой или моковым ожиданием. Он вызывает публичные методы репозитория, а доказательство получает после чтения обратно. Если в add() перепутан столбец, драйвер не принимает тип или findById() меняет имя ключа, зелёный результат невозможен при корректно настроенной тестовой схеме. Если же упало соединение, это тоже полезный сигнал: контракт конфигурации пока не выполнен.
Транзакция чистит данные, но не отменяет всё
PDO переводит соединение в режим транзакции после beginTransaction(); rollBack() возвращает изменения данных назад и включает autocommit. Это удобно для коротких тестов, которые делают INSERT, UPDATE и DELETE. Но MySQL может сделать неявный commit при DDL, например CREATE TABLE или DROP TABLE. Поэтому миграции и создание схемы не прячем внутрь этого теста: их выполняют отдельным подготовительным шагом.
Ещё одна граница — несколько соединений. Откат одного PDO не очистит запись, сделанную вторым соединением, очередью или HTTP-клиентом. Если код открывает соединение сам, сначала передайте ему тестовую фабрику или выделите адаптер. Только после этого можно честно сказать, что тест контролирует следы операции.
Порядок запуска без случайного доступа к данным
- Создать отдельную схему и пользователя для тестов по правилам проекта; не копировать production DSN в команду PHPUnit.
- Применить к тестовой схеме заранее подготовленную миграцию и отдельно записать её версию.
- Перед запуском вывести только имя тестовой базы или иной безопасный идентификатор, не печатая пароль.
- Запустить один класс через
./vendor/bin/phpunit tests/Integration/CustomerRepositoryIntegrationTest.phpпосле проверки версии PHPUnit. - Если тест падает, сначала разделить ошибку подключения, SQL и ожидание результата; не заменять реальный репозиторий моком ради зелёного вывода.
- После прохождения добавить второй короткий тест лишь для следующего контракта, например уникальности, а не раздувать один сценарий до проверки всего приложения.
Когда этот тест не подходит
Интеграционный тест репозитория не доказывает, что HTML-форма передала нужное поле, cron запущен, письмо доставлено или партнёрский API отвечает. Для каждого такого перехода нужна своя небольшая граница. Не стоит также запускать десятки одинаковых записей против общей БД параллельно без изоляции: тогда тест может падать от чужих данных, а не от кода.
Если проект пока не имеет отдельной схемы, честный статус — «интеграционный тест отложен из-за отсутствия безопасной среды», а не mock, названный интеграцией. Сначала сделайте минимальную тестовую конфигурацию и один путь записи-чтения. После этого остальные адаптеры можно покрывать тем же спокойным правилом: настоящий ресурс, узкий контракт, наблюдаемый результат и явная уборка.
Итог: проверяем не название теста, а путь данных
Зелёный unit-тест полезен, но он не заменяет путь через PDO и схему. Для репозитория достаточно начать с одного контракта: безопасная тестовая конфигурация, настоящая запись, чтение тем же адаптером и контролируемая очистка. Когда этот маршрут падает, он показывает границу ошибки; когда проходит, он оставляет следующему разработчику воспроизводимый способ проверить ту же связку.