{ "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. Поэтому правило сервиса и настоящий переход часто покрывают двумя соседними тестами.
Пусть сервис не должен создавать пользователя с уже занятым адресом. Локальное правило можно проверить без базы: подставной репозиторий сообщает, что адрес существует, а сервис выбрасывает исключение. Это учебный пример: он показывает вопрос сервиса и не утверждает, что запускался в 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, структура таблицы, права пользователя и значение переменной окружения. Именно эти вопросы должны остаться за интеграционной проверкой.
Интеграционная проверка должна иметь явные предположения. Ниже таблица содержит ровно те поля, которые использует пример. Запись получает фиксированный идентификатор, поэтому схема не зависит от различий в автоинкременте между СУБД. Перед тестом миграцию применяют отдельно в одноразовой тестовой схеме.
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 для правила, а сетевой контракт покрыть отдельно |
Интеграционный тест репозитория не доказывает, что HTML-форма передала правильное поле, cron запустился, письмо доставлено или партнёрский API доступен. Для каждого перехода нужна своя граница. Один зелёный тест не превращает mock в реальную проверку и не подтверждает состояние production.
Если изолированной БД нет, объект в памяти нельзя назвать интеграцией. Корректный отрицательный путь — пропустить проверку с понятной причиной и создать безопасную среду позже. Если тест упал на подключении, не меняйте ожидаемое значение ради зелёного отчёта: сначала проверьте DSN, права и драйвер. Если упал SQL, сравните запрос с миграцией. Если внешний обработчик вернул ошибку, не отправляйте запрос в production из CI.
Транзакция помогает убрать изменения только для того соединения, которое её открыло. Второе соединение, очередь или внешний сервис не откатятся автоматически. Кроме того, некоторые СУБД неявно фиксируют DDL вроде CREATE TABLE или DROP TABLE. Поэтому миграцию выполняют отдельным шагом, а в транзакцию теста помещают только данные, которые этот тест создал.
Граница готова, если команда может назвать вход, настоящий ресурс, ожидаемый результат и безопасную уборку. Тест должен выполнить запрос через PDO, прочитать строку тем же адаптером и показать отрицательный поиск. При отсутствии явно разрешённого test-only DSN он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только тогда зелёный результат означает проверенный переход, а не удачный ответ подстановки.
setUp() и tearDown() вокруг тестового окружения.