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-тест отвечают на разные вопросы. Первый изолирует правило. Второй оставляет настоящий переход там, где важен контракт между двумя частями системы.
Unit-тест оставляет под контролем один класс. Соседей он заменяет объектами, которые возвращают заданные значения. Это полезно для проверки правил: запретить дубликат, вычислить скидку, выбрать ветку ошибки. Тест быстро показывает, что сервис делает при конкретном входе.
\nIntegration-тест оставляет настоящим один внешний переход. Для PHP-репозитория это путь PHP → PDO → тестовая БД → PDO → PHP. Внутри него работают драйвер, SQL, типы столбцов, индексы и преобразование результата. Для HTTP-клиента граница будет другой: URL, cURL, код ответа и разбор тела на управляемом endpoint. Не нужно поднимать весь сайт, если вопрос касается одного адаптера.
Mock не плох и не хорош сам по себе. Ошибка появляется, когда его ответ считают доказательством работы ресурса. Mock не увидит отсутствующую миграцию, неверный DSN, ошибочное имя столбца, отсутствие PDO-драйвера или код ответа 500. Поэтому один сценарий часто нужно разделить на два теста: локальное правило и настоящий переход.
\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 переменную окружения. Именно поэтому следующий тест должен вызвать реальный адаптер.
Интеграционный тест репозитория подключается к отдельной тестовой схеме. В ней заранее есть таблица customers с полями id, email и name. Тест кладёт известную строку через PDO, вызывает публичный метод адаптера и проверяет результат. Ожидание не задаёт mock. Его возвращает настоящий запрос.
Конфигурация должна быть 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.
Транзакция помогает убрать изменения данных после проверки. Она не очищает записи, созданные другим соединением, очередью или внешним сервисом. Она также не даёт универсального отката DDL: конкретная СУБД может неявно зафиксировать CREATE TABLE или DROP TABLE. Миграцию и подготовку схемы поэтому выполняют отдельным шагом.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Unit зелёный, INSERT падает | Mock скрывает SQL, схему или драйвер | Запустить один репозиторий с test-only PDO и настоящей таблицей | Добавить узкий integration-тест записи и чтения |
| Тест читает пустой массив | Имя столбца или ключ результата изменился | Проверить SQL, схему и фактический FETCH_ASSOC | Исправить адаптер или миграцию; не менять ожидание на пустое |
| Тест пропускается на CI | Нет DSN, пользователя или PDO-драйвера | Проверить test-only переменные и безопасную доступность схемы | Настроить изолированную среду либо честно оставить проверку отложенной |
| После теста остаются строки | Запись сделана вне текущей транзакции | Сравнить соединение репозитория с соединением теста | Передать PDO или фабрику явно; добавить уборку отдельного ресурса |
| Mock проверяет вызов HTTP | Запрос не ушёл к управляемому endpoint | Проверить URL, статус и тело на локальном endpoint | Оставить mock для правила, а сетевой контракт покрыть отдельно |
Integration-тест репозитория не доказывает, что HTML-форма передала правильное поле, cron запустился, письмо доставлено или партнёрский API доступен. Для каждого перехода нужна своя граница. Один зелёный тест не превращает mock в реальную проверку и не подтверждает состояние production.
\nЕсли изолированной БД нет, нельзя назвать объект в памяти интеграцией. Корректный отрицательный путь — остановить проверку с понятной причиной и создать безопасную среду позже. Если тест упал на подключении, не меняйте ожидаемое значение ради зелёного отчёта. Сначала исправьте DSN, права или драйвер. Если упал SQL, проверьте миграцию и имена столбцов. Если внешний endpoint вернул ошибку, не отправляйте запрос в production из CI.
\nИсторический код может использовать PHP 7.2 и PHPUnit 7.5. Эти версии не следует выбирать для нового проекта только по этому примеру. Зафиксируйте фактические версии в composer.lock и проверьте методы жизненного цикла, mock-объектов и настройки PDO по документации вашей версии.
Граница готова, если команда может назвать вход, настоящий ресурс, ожидаемый результат и безопасную уборку. Тест должен действительно выполнить запрос через PDO, прочитать запись тем же адаптером и показать отрицательный поиск. При отсутствии test-only DSN он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только после этого зелёный результат означает проверенный переход, а не удачный ответ подстановки.
\nПредставим тестовый стенд сервиса регистрации. Все модульные тесты зелёные, но первая проверка через настоящую базу заканчивается ошибкой 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() вокруг тестового окружения.