{ "index": 329, "slug": "editorial-2018-11-mechanism-php-integration-tests", "title": "PHP-тесты: где mock заканчивается и начинается настоящая интеграция", "excerpt": "Зелёный unit-тест не доказывает, что PHP отправил правильный SQL, открыл нужную базу и разобрал ответ драйвера. Разбираем границу между подстановкой и настоящим переходом через PDO.", "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 он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только тогда зелёный результат означает проверенный переход, а не удачный ответ подстановки.

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

" }