8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
|
"index": 329,
|
|
"slug": "editorial-2018-11-mechanism-php-integration-tests",
|
|
"title": "PHP-тесты: где mock заканчивается и начинается настоящая интеграция",
|
|
"excerpt": "Зелёный unit-тест не доказывает, что PHP отправил правильный SQL, открыл нужную базу и разобрал ответ драйвера. Разбираем границу между подстановкой и настоящим переходом через PDO.",
|
|
"contentHtml": "<p>Представим тестовый стенд сервиса регистрации. Все модульные тесты зелёные, но первая проверка через настоящую базу заканчивается ошибкой SQL. В другом запуске сервис подключается не к той схеме или не находит только что добавленную строку. Цена ошибки — часы отладки и риск менять бизнес-логику, хотя тест ни разу не прошёл через PDO, схему и конфигурацию.</p><p>Сначала команда смотрит на mock-объект: он вернул <code>true</code>, значит правило сработало. Это верное наблюдение, но не полный вывод. Подстановка проверяет реакцию сервиса на заранее заданный ответ. Интеграционная проверка должна дополнительно выполнить настоящий переход к выбранному ресурсу и проверить его контракт. Эти два вопроса нужно разделить, иначе зелёный unit-тест создаёт ложную уверенность.</p><h2>Механизм: граница проходит по настоящему переходу</h2><p>Unit-тест оставляет под контролем один класс, а соседей заменяет объектами с управляемым поведением. Так удобно проверять правило: запретить дубликат, вычислить скидку или выбрать ветку ошибки. Ответ подставного объекта известен заранее, поэтому тест быстро показывает поведение самого сервиса.</p><p>Интеграционный тест оставляет настоящим один внешний переход. Для PHP-репозитория это цепочка <code>PHP → PDO → тестовая БД → PDO → PHP</code>. В неё входят драйвер, SQL, типы столбцов, индексы и преобразование результата. Для HTTP-клиента граница будет другой: адрес, запрос, код ответа и разбор тела на управляемом локальном обработчике. Не нужно поднимать весь сайт, если вопрос касается одного адаптера.</p><p>Mock не плох и не хорош сам по себе. Он становится проблемой, когда его ответ принимают за доказательство работы ресурса. Mock не увидит отсутствующую миграцию, неверный DSN, ошибочное имя столбца, недоступный PDO-драйвер или ответ 500. Поэтому правило сервиса и настоящий переход часто покрывают двумя соседними тестами.</p><figure><img src=\"/assets/editorial/2018/php-test-boundary-2018.svg\" alt=\"Граница между unit-тестом с подставным репозиторием и интеграционным тестом с настоящим PDO-адаптером и тестовой базой\" /><figcaption>Unit-тест проверяет решение сервиса. Интеграционный тест проверяет договор на границе с настоящим адаптером. Переход к ресурсу нельзя незаметно заменить готовым ответом.</figcaption></figure><h2>Пример: правило регистрации без базы</h2><p>Пусть сервис не должен создавать пользователя с уже занятым адресом. Локальное правило можно проверить без базы: подставной репозиторий сообщает, что адрес существует, а сервис выбрасывает исключение. Это учебный пример: он показывает вопрос сервиса и не утверждает, что запускался в production.</p><pre><code><?php use PHPUnit\\Framework\\TestCase; interface CustomerLookup { public function existsByEmail(string $email): bool; } final class RegistrationService { private $customers; public function __construct(CustomerLookup $customers) { $this->customers = $customers; } public function register(string $email): void { if ($this->customers->existsByEmail($email)) { throw new DomainException('Email is already registered'); } } } final class RegistrationServiceTest extends TestCase { public function testRejectsExistingEmail(): void { $customers = $this->createMock(CustomerLookup::class); $customers->expects($this->once()) ->method('existsByEmail') ->with('anna@example.test') ->willReturn(true); $service = new RegistrationService($customers); $this->expectException(DomainException::class); $service->register('anna@example.test'); } }</code></pre><p>Зелёный результат здесь доказывает, что <code>RegistrationService</code> отклоняет регистрацию, когда сосед отвечает <code>true</code>. Вызова базы в тесте нет. Не проверяются SQL, структура таблицы, права пользователя и значение переменной окружения. Именно эти вопросы должны остаться за интеграционной проверкой.</p><h2>Минимальный настоящий переход через PDO</h2><p>Интеграционная проверка должна иметь явные предположения. Ниже таблица содержит ровно те поля, которые использует пример. Запись получает фиксированный идентификатор, поэтому схема не зависит от различий в автоинкременте между СУБД. Перед тестом миграцию применяют отдельно в одноразовой тестовой схеме.</p><pre><code>CREATE TABLE customers ( id INTEGER PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL );</code></pre><p>Адаптер ниже делает один подготовленный запрос. Тест проверяет найденный и отсутствующий адрес через тот же публичный метод. Переменная <code>TEST_DATABASE_ALLOW</code> — дополнительный ручной барьер: без явного значения <code>1</code> тест пропускается. Она не заменяет отдельную схему, ограниченные права и секреты CI.</p><pre><code><?php final class PdoCustomerLookup implements CustomerLookup { private $pdo; public function __construct(PDO $pdo) { $this->pdo = $pdo; } public function existsByEmail(string $email): bool { $statement = $this->pdo->prepare( 'SELECT 1 FROM customers WHERE email = ?' ); $statement->execute([$email]); return $statement->fetchColumn() !== false; } } final class PdoCustomerLookupIntegrationTest extends TestCase { private $pdo; protected function setUp(): void { $dsn = getenv('TEST_DATABASE_DSN'); if ($dsn === false || getenv('TEST_DATABASE_ALLOW') !== '1') { $this->markTestSkipped('An explicitly enabled test DSN is required'); } $this->pdo = new PDO( $dsn, (string) getenv('TEST_DATABASE_USER'), (string) getenv('TEST_DATABASE_PASSWORD'), [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ] ); $this->pdo->beginTransaction(); $this->pdo->prepare( 'INSERT INTO customers (id, email, name) VALUES (?, ?, ?)' )->execute([1, 'anna@example.test', 'Анна']); } protected function tearDown(): void { if (isset($this->pdo) && $this->pdo->inTransaction()) { $this->pdo->rollBack(); } parent::tearDown(); } public function testFindsExistingEmailAndRejectsMissingEmail(): void { $lookup = new PdoCustomerLookup($this->pdo); self::assertTrue($lookup->existsByEmail('anna@example.test')); self::assertFalse($lookup->existsByEmail('missing@example.test')); } }</code></pre><p>Этот фрагмент предполагает PHP 7.2, PHPUnit 7.5, включённый PDO-драйвер, применённую миграцию и класс теста в том же пространстве имён, что и <code>CustomerLookup</code>. Он не содержит пароль и не подключается к рабочей базе. Для конкретной СУБД нужно сверить синтаксис миграции и правила транзакций.</p><h2>Симптом → причина → проверка → действие</h2><div class=\"table-scroll\"><table><caption>Диагностика границы между подстановкой и интеграцией</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Unit зелёный, INSERT падает</td><td>Mock скрывает SQL, схему или драйвер</td><td>Запустить один репозиторий с test-only PDO и настоящей таблицей</td><td>Добавить узкий тест записи и чтения</td></tr><tr><td>Тест читает пустой результат</td><td>Имя столбца или ключ результата изменился</td><td>Сверить SQL, схему и фактический результат <code>fetchColumn()</code></td><td>Исправить адаптер или миграцию; не менять ожидание на пустое</td></tr><tr><td>Тест пропускается в CI</td><td>Нет DSN, доступа или PDO-драйвера</td><td>Проверить test-only переменные и права отдельной схемы</td><td>Настроить безопасную среду или честно оставить проверку отложенной</td></tr><tr><td>После теста остаётся строка</td><td>Запись сделана другим соединением или транзакция завершилась раньше</td><td>Сопоставить соединение адаптера и теста; проверить <code>inTransaction()</code></td><td>Передать PDO явно и убрать внешний ресурс отдельно</td></tr><tr><td>Mock проверяет вызов HTTP</td><td>Настоящий запрос не ушёл на управляемый обработчик</td><td>Проверить адрес, статус и тело локального endpoint</td><td>Оставить mock для правила, а сетевой контракт покрыть отдельно</td></tr></tbody></table></div><h2>Порядок проверки</h2><ol><li>Зафиксируйте симптом до изменения кода: ошибка SQL, неверное значение, пустой результат или неправильный HTTP-статус.</li><li>Назовите владельца границы: PDO-репозиторий, HTTP-клиент, файловый адаптер или загрузчик конфигурации.</li><li>Оставьте настоящим только этот переход. Остальные части замените простыми контролируемыми объектами.</li><li>Подготовьте test-only ресурс: отдельную схему, локальный обработчик или временный каталог. Рабочий ресурс не используйте.</li><li>Проверьте положительный и отрицательный путь. Для БД это найденный и отсутствующий email; для HTTP — ожидаемый статус и отказ.</li><li>Сопоставьте вход теста с наблюдаемым результатом: SQL действительно выполнился, строка прочитана тем же адаптером, а не подставлена.</li><li>Убедитесь, что после теста не остаются строки, файлы, запросы или фоновые задачи. Если очистка невозможна, сделайте след операции уникальным и удаляемым.</li><li>Сохраните unit-тест рядом с интеграционным. Один защищает правило, другой — склейку с ресурсом.</li></ol><h2>Ограничения и отрицательный путь</h2><p>Интеграционный тест репозитория не доказывает, что HTML-форма передала правильное поле, cron запустился, письмо доставлено или партнёрский API доступен. Для каждого перехода нужна своя граница. Один зелёный тест не превращает mock в реальную проверку и не подтверждает состояние production.</p><p>Если изолированной БД нет, объект в памяти нельзя назвать интеграцией. Корректный отрицательный путь — пропустить проверку с понятной причиной и создать безопасную среду позже. Если тест упал на подключении, не меняйте ожидаемое значение ради зелёного отчёта: сначала проверьте DSN, права и драйвер. Если упал SQL, сравните запрос с миграцией. Если внешний обработчик вернул ошибку, не отправляйте запрос в production из CI.</p><p>Транзакция помогает убрать изменения только для того соединения, которое её открыло. Второе соединение, очередь или внешний сервис не откатятся автоматически. Кроме того, некоторые СУБД неявно фиксируют DDL вроде <code>CREATE TABLE</code> или <code>DROP TABLE</code>. Поэтому миграцию выполняют отдельным шагом, а в транзакцию теста помещают только данные, которые этот тест создал.</p><h2>Проверяемый критерий готовности</h2><p>Граница готова, если команда может назвать вход, настоящий ресурс, ожидаемый результат и безопасную уборку. Тест должен выполнить запрос через PDO, прочитать строку тем же адаптером и показать отрицательный поиск. При отсутствии явно разрешённого test-only DSN он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только тогда зелёный результат означает проверенный переход, а не удачный ответ подстановки.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://docs.phpunit.de/en/7.5/test-doubles.html\" target=\"_blank\" rel=\"noopener noreferrer\">PHPUnit 7.5: Test Doubles</a> — официальное описание подстановок и изоляции тестируемого кода от зависимостей.</li><li><a href=\"https://docs.phpunit.de/en/7.5/fixtures.html\" target=\"_blank\" rel=\"noopener noreferrer\">PHPUnit 7.5: Fixtures</a> — официальное описание <code>setUp()</code> и <code>tearDown()</code> вокруг тестового окружения.</li><li><a href=\"https://www.php.net/manual/en/pdo.prepare.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: PDO::prepare</a> — подготовка SQL-выражения перед передачей параметров.</li><li><a href=\"https://www.php.net/manual/en/pdo.begintransaction.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: PDO::beginTransaction</a> — отключение autocommit, границы транзакции и оговорка о DDL.</li><li><a href=\"https://www.php.net/manual/en/pdo.rollback.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: PDO::rollBack</a> — откат изменений и возврат соединения в режим autocommit.</li></ul>"
|
|
}
|