From f977d18ef87443ecf469eb2167f400ae051bc897 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 4 Sep 2026 00:23:51 +0300 Subject: [PATCH] editorial: refine article 329 integration test boundary --- editorial/agent-rewrites/329.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/329.json b/editorial/agent-rewrites/329.json index 7132f42..f1b55ea 100644 --- a/editorial/agent-rewrites/329.json +++ b/editorial/agent-rewrites/329.json @@ -3,5 +3,5 @@ "slug": "editorial-2018-11-mechanism-php-integration-tests", "title": "PHP-тесты: где mock заканчивается и начинается настоящая интеграция", "excerpt": "Зелёный unit-тест не доказывает, что PHP отправил правильный SQL, открыл нужную базу и разобрал ответ драйвера. Разбираем границу между подстановкой и настоящим переходом через PDO.", - "contentHtml": "

Все unit-тесты сервиса проходят, но первая реальная запись падает с ошибкой SQL. Иногда приложение подключается не к той базе. Иногда запрос записывает значение не в тот столбец. Цена ошибки — ложная уверенность: команда меняет бизнес-логику, хотя тест ни разу не прошёл через PDO, схему и конфигурацию.

\n

Причина обычно проста. Тест подменяет репозиторий и заранее говорит ему вернуть true или массив. Такой тест проверяет реакцию сервиса на известный ответ. Он не проверяет, сможет ли настоящий репозиторий получить этот ответ. Тезис статьи короткий: unit-тест и integration-тест отвечают на разные вопросы. Первый изолирует правило. Второй оставляет настоящий переход там, где важен контракт между двумя частями системы.

\n

Механизм: граница проходит по побочному эффекту

\n

Unit-тест оставляет под контролем один класс. Соседей он заменяет объектами, которые возвращают заданные значения. Это полезно для проверки правил: запретить дубликат, вычислить скидку, выбрать ветку ошибки. Тест быстро показывает, что сервис делает при конкретном входе.

\n

Integration-тест оставляет настоящим один внешний переход. Для PHP-репозитория это путь PHP → PDO → тестовая БД → PDO → PHP. Внутри него работают драйвер, SQL, типы столбцов, индексы и преобразование результата. Для HTTP-клиента граница будет другой: URL, cURL, код ответа и разбор тела на управляемом endpoint. Не нужно поднимать весь сайт, если вопрос касается одного адаптера.

\n

Mock не плох и не хорош сам по себе. Ошибка появляется, когда его ответ считают доказательством работы ресурса. Mock не увидит отсутствующую миграцию, неверный DSN, ошибочное имя столбца, отсутствие PDO-драйвера или код ответа 500. Поэтому один сценарий часто нужно разделить на два теста: локальное правило и настоящий переход.

\n
\"Граница
Unit-тест проверяет решение сервиса. Integration-тест проверяет договор на границе с настоящим адаптером. Пунктир нельзя пересекать незаметно.
\n

Пример: регистрация и проверка занятого email

\n

Пусть сервис регистрации не должен создавать пользователя с уже занятым адресом. Локальное правило можно проверить без базы. Подставной репозиторий сообщает, что адрес существует, а сервис должен выбросить исключение. Этот пример учебный: он показывает только вопрос сервиса и не утверждает, что выполнялся в production.

\n
<?php\nuse PHPUnit\\Framework\\TestCase;\n\ninterface CustomerLookup\n{\n    public function existsByEmail(string $email): bool;\n}\n\nfinal class RegistrationService\n{\n    private $customers;\n\n    public function __construct(CustomerLookup $customers)\n    {\n        $this->customers = $customers;\n    }\n\n    public function register(string $email): void\n    {\n        if ($this->customers->existsByEmail($email)) {\n            throw new DomainException('Email is already registered');\n        }\n    }\n}\n\nfinal class RegistrationServiceTest extends TestCase\n{\n    public function testRejectsExistingEmail(): void\n    {\n        $customers = $this->createMock(CustomerLookup::class);\n        $customers->method('existsByEmail')\n            ->with('anna@example.test')\n            ->willReturn(true);\n\n        $service = new RegistrationService($customers);\n        $this->expectException(DomainException::class);\n        $service->register('anna@example.test');\n    }\n}
\n

Если этот тест зелёный, мы знаем только одно: при ответе true сервис отклоняет регистрацию. Мы не знаем, вернёт ли настоящий запрос true. Не знаем, совпадает ли схема с SQL. Не знаем, прочитал ли bootstrap переменную окружения. Именно поэтому следующий тест должен вызвать реальный адаптер.

\n

Настоящий переход через PDO

\n

Интеграционный тест репозитория подключается к отдельной тестовой схеме. В ней заранее есть таблица customers с полями id, email и name. Тест кладёт известную строку через PDO, вызывает публичный метод адаптера и проверяет результат. Ожидание не задаёт mock. Его возвращает настоящий запрос.

\n

Конфигурация должна быть test-only. Не подставляйте production DSN по умолчанию. Отдельный пользователь должен не иметь доступа к рабочей схеме. Пароль нельзя хранить в коде. Проверка имени базы в примере ниже — лишь аварийный барьер от очевидной ошибки. Она не заменяет права доступа, отдельную сеть и безопасное окружение.

\n
<?php\nfinal class PdoCustomerLookupIntegrationTest extends TestCase\n{\n    /** @var PDO */\n    private $pdo;\n\n    protected function setUp(): void\n    {\n        $dsn = (string) getenv('TEST_DATABASE_DSN');\n        if ($dsn === '' || strpos($dsn, 'test') === false) {\n            $this->markTestSkipped('An isolated test DSN is required');\n        }\n\n        $this->pdo = new PDO(\n            $dsn,\n            (string) getenv('TEST_DATABASE_USER'),\n            (string) getenv('TEST_DATABASE_PASSWORD'),\n            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,\n             PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]\n        );\n        $this->pdo->beginTransaction();\n        $this->pdo->prepare(\n            'INSERT INTO customers (email, name) VALUES (?, ?)'\n        )->execute(['anna@example.test', 'Анна']);\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 testFindsExistingEmail(): void\n    {\n        $lookup = new PdoCustomerLookup($this->pdo);\n\n        self::assertTrue($lookup->existsByEmail('anna@example.test'));\n        self::assertFalse($lookup->existsByEmail('missing@example.test'));\n    }\n}
\n

Это учебный каркас, а не готовый файл для любого проекта. В нём предполагаются PHPUnit, PDO, заранее применённая миграция и класс PdoCustomerLookup. Он не создаёт контейнер, не содержит пароль и не вызывает рабочую БД. Команда должна подставить собственную тестовую схему и сверить синтаксис с закреплённой версией PHP и PHPUnit.

\n

Транзакция помогает убрать изменения данных после проверки. Она не очищает записи, созданные другим соединением, очередью или внешним сервисом. Она также не даёт универсального отката DDL: конкретная СУБД может неявно зафиксировать CREATE TABLE или DROP TABLE. Миграцию и подготовку схемы поэтому выполняют отдельным шагом.

\n

Симптом → причина → проверка → действие

\n
Диагностика границы между подстановкой и интеграцией
СимптомПричинаПроверкаДействие
Unit зелёный, INSERT падаетMock скрывает SQL, схему или драйверЗапустить один репозиторий с test-only PDO и настоящей таблицейДобавить узкий integration-тест записи и чтения
Тест читает пустой массивИмя столбца или ключ результата изменилсяПроверить SQL, схему и фактический FETCH_ASSOCИсправить адаптер или миграцию; не менять ожидание на пустое
Тест пропускается на CIНет DSN, пользователя или PDO-драйвераПроверить test-only переменные и безопасную доступность схемыНастроить изолированную среду либо честно оставить проверку отложенной
После теста остаются строкиЗапись сделана вне текущей транзакцииСравнить соединение репозитория с соединением тестаПередать PDO или фабрику явно; добавить уборку отдельного ресурса
Mock проверяет вызов HTTPЗапрос не ушёл к управляемому endpointПроверить URL, статус и тело на локальном endpointОставить mock для правила, а сетевой контракт покрыть отдельно
\n

Порядок проверки

\n
  1. Зафиксируйте симптом, который прошёл мимо unit-теста: ошибка SQL, неверное значение, пустой результат или неправильный статус HTTP.
  2. Назовите владельца границы: репозиторий PDO, HTTP-клиент, файловый адаптер или загрузчик конфигурации.
  3. Оставьте настоящий только этот переход. Остальные части замените простыми контролируемыми объектами.
  4. Подготовьте test-only ресурс: отдельную схему, локальный endpoint или временный каталог. Production-ресурс не используйте.
  5. Проверьте положительный путь и один отрицательный. Для БД это найденный и отсутствующий email; для HTTP — ожидаемый статус и отказ.
  6. Ограничьте время и объём данных. Один тест должен объяснять одну границу, а не поднимать БД, очередь, письмо и HTML одновременно.
  7. Убедитесь, что после теста не остаются строки, файлы, запросы или фоновые задачи. Если очистка невозможна, сделайте след операции уникальным и удаляемым.
  8. Сохраните unit-тест рядом с integration-тестом. Они дополняют друг друга: один защищает правило, другой — склейку с ресурсом.
\n

Ограничения и отрицательный путь

\n

Integration-тест репозитория не доказывает, что HTML-форма передала правильное поле, cron запустился, письмо доставлено или партнёрский API доступен. Для каждого перехода нужна своя граница. Один зелёный тест не превращает mock в реальную проверку и не подтверждает состояние production.

\n

Если изолированной БД нет, нельзя назвать объект в памяти интеграцией. Корректный отрицательный путь — остановить проверку с понятной причиной и создать безопасную среду позже. Если тест упал на подключении, не меняйте ожидаемое значение ради зелёного отчёта. Сначала исправьте DSN, права или драйвер. Если упал SQL, проверьте миграцию и имена столбцов. Если внешний endpoint вернул ошибку, не отправляйте запрос в production из CI.

\n

Исторический код может использовать PHP 7.2 и PHPUnit 7.5. Эти версии не следует выбирать для нового проекта только по этому примеру. Зафиксируйте фактические версии в composer.lock и проверьте методы жизненного цикла, mock-объектов и настройки PDO по документации вашей версии.

\n

Проверяемый критерий готовности

\n

Граница готова, если команда может назвать вход, настоящий ресурс, ожидаемый результат и безопасную уборку. Тест должен действительно выполнить запрос через PDO, прочитать запись тем же адаптером и показать отрицательный поиск. При отсутствии test-only DSN он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только после этого зелёный результат означает проверенный переход, а не удачный ответ подстановки.

\n

Проверяемые источники

" + "contentHtml": "

Представим тестовый стенд сервиса регистрации. Все модульные тесты зелёные, но первая проверка через настоящую базу заканчивается ошибкой SQL. В другом запуске сервис подключается не к той схеме или не находит только что добавленную строку. Цена ошибки — часы отладки и риск менять бизнес-логику, хотя тест ни разу не прошёл через PDO, схему и конфигурацию.

Сначала команда смотрит на mock-объект: он вернул true, значит правило сработало. Это верное наблюдение, но не полный вывод. Подстановка проверяет реакцию сервиса на заранее заданный ответ. Интеграционная проверка должна дополнительно выполнить настоящий переход к выбранному ресурсу и проверить его контракт. Эти два вопроса нужно разделить, иначе зелёный unit-тест создаёт ложную уверенность.

Механизм: граница проходит по настоящему переходу

Unit-тест оставляет под контролем один класс, а соседей заменяет объектами с управляемым поведением. Так удобно проверять правило: запретить дубликат, вычислить скидку или выбрать ветку ошибки. Ответ подставного объекта известен заранее, поэтому тест быстро показывает поведение самого сервиса.

Интеграционный тест оставляет настоящим один внешний переход. Для PHP-репозитория это цепочка PHP → PDO → тестовая БД → PDO → PHP. В неё входят драйвер, SQL, типы столбцов, индексы и преобразование результата. Для HTTP-клиента граница будет другой: адрес, запрос, код ответа и разбор тела на управляемом локальном обработчике. Не нужно поднимать весь сайт, если вопрос касается одного адаптера.

Mock не плох и не хорош сам по себе. Он становится проблемой, когда его ответ принимают за доказательство работы ресурса. Mock не увидит отсутствующую миграцию, неверный DSN, ошибочное имя столбца, недоступный PDO-драйвер или ответ 500. Поэтому правило сервиса и настоящий переход часто покрывают двумя соседними тестами.

\"Граница
Unit-тест проверяет решение сервиса. Интеграционный тест проверяет договор на границе с настоящим адаптером. Переход к ресурсу нельзя незаметно заменить готовым ответом.

Пример: правило регистрации без базы

Пусть сервис не должен создавать пользователя с уже занятым адресом. Локальное правило можно проверить без базы: подставной репозиторий сообщает, что адрес существует, а сервис выбрасывает исключение. Это учебный пример: он показывает вопрос сервиса и не утверждает, что запускался в production.

<?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');
    }
}

Зелёный результат здесь доказывает, что RegistrationService отклоняет регистрацию, когда сосед отвечает true. Вызова базы в тесте нет. Не проверяются SQL, структура таблицы, права пользователя и значение переменной окружения. Именно эти вопросы должны остаться за интеграционной проверкой.

Минимальный настоящий переход через PDO

Интеграционная проверка должна иметь явные предположения. Ниже таблица содержит ровно те поля, которые использует пример. Запись получает фиксированный идентификатор, поэтому схема не зависит от различий в автоинкременте между СУБД. Перед тестом миграцию применяют отдельно в одноразовой тестовой схеме.

CREATE TABLE customers (
    id INTEGER PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(100) NOT NULL
);

Адаптер ниже делает один подготовленный запрос. Тест проверяет найденный и отсутствующий адрес через тот же публичный метод. Переменная TEST_DATABASE_ALLOW — дополнительный ручной барьер: без явного значения 1 тест пропускается. Она не заменяет отдельную схему, ограниченные права и секреты CI.

<?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'));
    }
}

Этот фрагмент предполагает PHP 7.2, PHPUnit 7.5, включённый PDO-драйвер, применённую миграцию и класс теста в том же пространстве имён, что и CustomerLookup. Он не содержит пароль и не подключается к рабочей базе. Для конкретной СУБД нужно сверить синтаксис миграции и правила транзакций.

Симптом → причина → проверка → действие

Диагностика границы между подстановкой и интеграцией
СимптомПричинаПроверкаДействие
Unit зелёный, INSERT падаетMock скрывает SQL, схему или драйверЗапустить один репозиторий с test-only PDO и настоящей таблицейДобавить узкий тест записи и чтения
Тест читает пустой результатИмя столбца или ключ результата изменилсяСверить SQL, схему и фактический результат fetchColumn()Исправить адаптер или миграцию; не менять ожидание на пустое
Тест пропускается в CIНет DSN, доступа или PDO-драйвераПроверить test-only переменные и права отдельной схемыНастроить безопасную среду или честно оставить проверку отложенной
После теста остаётся строкаЗапись сделана другим соединением или транзакция завершилась раньшеСопоставить соединение адаптера и теста; проверить inTransaction()Передать PDO явно и убрать внешний ресурс отдельно
Mock проверяет вызов HTTPНастоящий запрос не ушёл на управляемый обработчикПроверить адрес, статус и тело локального endpointОставить mock для правила, а сетевой контракт покрыть отдельно

Порядок проверки

  1. Зафиксируйте симптом до изменения кода: ошибка SQL, неверное значение, пустой результат или неправильный HTTP-статус.
  2. Назовите владельца границы: PDO-репозиторий, HTTP-клиент, файловый адаптер или загрузчик конфигурации.
  3. Оставьте настоящим только этот переход. Остальные части замените простыми контролируемыми объектами.
  4. Подготовьте test-only ресурс: отдельную схему, локальный обработчик или временный каталог. Рабочий ресурс не используйте.
  5. Проверьте положительный и отрицательный путь. Для БД это найденный и отсутствующий email; для HTTP — ожидаемый статус и отказ.
  6. Сопоставьте вход теста с наблюдаемым результатом: SQL действительно выполнился, строка прочитана тем же адаптером, а не подставлена.
  7. Убедитесь, что после теста не остаются строки, файлы, запросы или фоновые задачи. Если очистка невозможна, сделайте след операции уникальным и удаляемым.
  8. Сохраните unit-тест рядом с интеграционным. Один защищает правило, другой — склейку с ресурсом.

Ограничения и отрицательный путь

Интеграционный тест репозитория не доказывает, что HTML-форма передала правильное поле, cron запустился, письмо доставлено или партнёрский API доступен. Для каждого перехода нужна своя граница. Один зелёный тест не превращает mock в реальную проверку и не подтверждает состояние production.

Если изолированной БД нет, объект в памяти нельзя назвать интеграцией. Корректный отрицательный путь — пропустить проверку с понятной причиной и создать безопасную среду позже. Если тест упал на подключении, не меняйте ожидаемое значение ради зелёного отчёта: сначала проверьте DSN, права и драйвер. Если упал SQL, сравните запрос с миграцией. Если внешний обработчик вернул ошибку, не отправляйте запрос в production из CI.

Транзакция помогает убрать изменения только для того соединения, которое её открыло. Второе соединение, очередь или внешний сервис не откатятся автоматически. Кроме того, некоторые СУБД неявно фиксируют DDL вроде CREATE TABLE или DROP TABLE. Поэтому миграцию выполняют отдельным шагом, а в транзакцию теста помещают только данные, которые этот тест создал.

Проверяемый критерий готовности

Граница готова, если команда может назвать вход, настоящий ресурс, ожидаемый результат и безопасную уборку. Тест должен выполнить запрос через PDO, прочитать строку тем же адаптером и показать отрицательный поиск. При отсутствии явно разрешённого test-only DSN он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только тогда зелёный результат означает проверенный переход, а не удачный ответ подстановки.

Проверяемые источники

" }