Files

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>&lt;?php&#10;use PHPUnit\\Framework\\TestCase;&#10;&#10;interface CustomerLookup&#10;{&#10; public function existsByEmail(string $email): bool;&#10;}&#10;&#10;final class RegistrationService&#10;{&#10; private $customers;&#10;&#10; public function __construct(CustomerLookup $customers)&#10; {&#10; $this-&gt;customers = $customers;&#10; }&#10;&#10; public function register(string $email): void&#10; {&#10; if ($this-&gt;customers-&gt;existsByEmail($email)) {&#10; throw new DomainException(&#039;Email is already registered&#039;);&#10; }&#10; }&#10;}&#10;&#10;final class RegistrationServiceTest extends TestCase&#10;{&#10; public function testRejectsExistingEmail(): void&#10; {&#10; $customers = $this-&gt;createMock(CustomerLookup::class);&#10; $customers-&gt;expects($this-&gt;once())&#10; -&gt;method(&#039;existsByEmail&#039;)&#10; -&gt;with(&#039;anna@example.test&#039;)&#10; -&gt;willReturn(true);&#10;&#10; $service = new RegistrationService($customers);&#10; $this-&gt;expectException(DomainException::class);&#10; $service-&gt;register(&#039;anna@example.test&#039;);&#10; }&#10;}</code></pre><p>Зелёный результат здесь доказывает, что <code>RegistrationService</code> отклоняет регистрацию, когда сосед отвечает <code>true</code>. Вызова базы в тесте нет. Не проверяются SQL, структура таблицы, права пользователя и значение переменной окружения. Именно эти вопросы должны остаться за интеграционной проверкой.</p><h2>Минимальный настоящий переход через PDO</h2><p>Интеграционная проверка должна иметь явные предположения. Ниже таблица содержит ровно те поля, которые использует пример. Запись получает фиксированный идентификатор, поэтому схема не зависит от различий в автоинкременте между СУБД. Перед тестом миграцию применяют отдельно в одноразовой тестовой схеме.</p><pre><code>CREATE TABLE customers (&#10; id INTEGER PRIMARY KEY,&#10; email VARCHAR(255) NOT NULL UNIQUE,&#10; name VARCHAR(100) NOT NULL&#10;);</code></pre><p>Адаптер ниже делает один подготовленный запрос. Тест проверяет найденный и отсутствующий адрес через тот же публичный метод. Переменная <code>TEST_DATABASE_ALLOW</code> — дополнительный ручной барьер: без явного значения <code>1</code> тест пропускается. Она не заменяет отдельную схему, ограниченные права и секреты CI.</p><pre><code>&lt;?php&#10;final class PdoCustomerLookup implements CustomerLookup&#10;{&#10; private $pdo;&#10;&#10; public function __construct(PDO $pdo)&#10; {&#10; $this-&gt;pdo = $pdo;&#10; }&#10;&#10; public function existsByEmail(string $email): bool&#10; {&#10; $statement = $this-&gt;pdo-&gt;prepare(&#10; &#039;SELECT 1 FROM customers WHERE email = ?&#039;&#10; );&#10; $statement-&gt;execute([$email]);&#10;&#10; return $statement-&gt;fetchColumn() !== false;&#10; }&#10;}&#10;&#10;final class PdoCustomerLookupIntegrationTest extends TestCase&#10;{&#10; private $pdo;&#10;&#10; protected function setUp(): void&#10; {&#10; $dsn = getenv(&#039;TEST_DATABASE_DSN&#039;);&#10; if ($dsn === false || getenv(&#039;TEST_DATABASE_ALLOW&#039;) !== &#039;1&#039;) {&#10; $this-&gt;markTestSkipped(&#039;An explicitly enabled test DSN is required&#039;);&#10; }&#10;&#10; $this-&gt;pdo = new PDO(&#10; $dsn,&#10; (string) getenv(&#039;TEST_DATABASE_USER&#039;),&#10; (string) getenv(&#039;TEST_DATABASE_PASSWORD&#039;),&#10; [&#10; PDO::ATTR_ERRMODE =&gt; PDO::ERRMODE_EXCEPTION,&#10; PDO::ATTR_DEFAULT_FETCH_MODE =&gt; PDO::FETCH_ASSOC,&#10; ]&#10; );&#10; $this-&gt;pdo-&gt;beginTransaction();&#10; $this-&gt;pdo-&gt;prepare(&#10; &#039;INSERT INTO customers (id, email, name) VALUES (?, ?, ?)&#039;&#10; )-&gt;execute([1, &#039;anna@example.test&#039;, &#039;Анна&#039;]);&#10; }&#10;&#10; protected function tearDown(): void&#10; {&#10; if (isset($this-&gt;pdo) &amp;&amp; $this-&gt;pdo-&gt;inTransaction()) {&#10; $this-&gt;pdo-&gt;rollBack();&#10; }&#10; parent::tearDown();&#10; }&#10;&#10; public function testFindsExistingEmailAndRejectsMissingEmail(): void&#10; {&#10; $lookup = new PdoCustomerLookup($this-&gt;pdo);&#10;&#10; self::assertTrue($lookup-&gt;existsByEmail(&#039;anna@example.test&#039;));&#10; self::assertFalse($lookup-&gt;existsByEmail(&#039;missing@example.test&#039;));&#10; }&#10;}</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>"
}