Все unit-тесты сервиса зелёные, но первая реальная запись падает с ошибкой SQL или читает не то значение из конфигурации. Цена такой зелени — ложная уверенность при рефакторинге: mock подтвердил договор с самим тестом, а не с драйвером БД, схемой и внешним адресом.
Один вопрос этой заметки: как провести границу между unit- и integration-тестом PHP, чтобы не назвать mock настоящей проверкой? В 2018 году достаточно простой модели. Сначала проверяем правило в классе без сети и БД, затем отдельно проводим один реальный переход через адаптер. Не надо заставлять каждый тест поднимать всё приложение.
Слово «unit» описывает контролируемую границу
Unit-тест оставляет под контролем сам класс и подменяет его соседей. Подстановка нужна не потому, что БД «плохая», а потому, что мы хотим быстро проверить одно правило: например, запрещает ли сервис дублирующий адрес до записи. В таком тесте объект-заглушка возвращает заранее выбранный ответ, а test case проверяет реакцию сервиса. Он не зависит от таблицы, сети и текущего значения переменной окружения.
Integration-тест оставляет настоящий переход там, где важен договор двух частей. Для PHP-репозитория это драйвер PDO, запрос и тестовая схема. Для HTTP-клиента это формирование запроса, cURL и управляемый тестовый endpoint. Оба вида тестов могут вызывать один сервис, но отвечают на разные вопросы. Ошибка начинается, когда они получают одинаковое название и от одного ждут доказательства другого.
Один сценарий можно разложить на два точных вопроса
Представим регистрацию пользователя. Правило «не создавать запись для уже занятого email» удобно проверить unit-тестом: он получает подставной репозиторий, который сообщает, что адрес существует. Но сам SQL с WHERE email = ?, тип колонки и сопоставление строки с массивом PHP остаются за границей. Их должен покрыть отдельный integration-тест конкретного PDO-репозитория.
| Вопрос | Unit-тест | Integration-тест | Признак лишней работы |
|---|---|---|---|
| Правило дубликата | Подставной репозиторий отвечает true | Не обязателен в каждом варианте правила | Поднимать БД, чтобы проверить одно условие if |
| SQL и имена столбцов | Не проверяет | Выполняет настоящий запрос в тестовой схеме | Сравнивать строку SQL с копией этой же строки в тесте |
| Тип результата PDO | Можно задать массив вручную | Показывает фактический FETCH_ASSOC и преобразование | Считать mock доказательством работы драйвера |
| HTTP-запрос | Проверяет, что клиент был вызван с нужными данными | Проверяет URL, код ответа и разбор ответа на локальном endpoint | Посылать тест в боевой API |
| Конфигурация | Передаёт строку явно в конструктор | Берёт test-only значение из окружения и проверяет отказ при его отсутствии | Использовать production значение по умолчанию |
Unit-тест: правило без настоящей БД
Ниже минимальный пример на PHPUnit 7. Он не называет объект mock только ради модного слова: репозиторий подставлен, потому что тест проверяет решение RegistrationService до момента записи. Вход и ожидаемый отказ видны прямо в коде. Такой тест быстро падает, если автор случайно удалит проверку существующего адреса, и не требует доступной БД для каждого запуска.
<?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 testRejectsAnExistingEmail(): void
{
$customers = $this->createMock(CustomerLookup::class);
$customers->method('existsByEmail')->with('anna@example.test')->willReturn(true);
$service = new RegistrationService($customers);
$this->expectException(DomainException::class);
$service->register('anna@example.test');
}
}
Этот код сознательно не содержит PDO, getenv() и URL. Если он зелёный, мы знаем ровно одно: при ответе true сервис бросает ожидаемое исключение. Мы не знаем, вернёт ли реальный запрос true, доступна ли нужная таблица и не передал ли bootstrap в репозиторий другой DSN. Чем точнее сформулирован вывод, тем меньше соблазн считать этот тест универсальной страховкой.
Integration-тест: настоящий адаптер вместо предположения
Чтобы проверить репозиторий, unit-тест выше не расширяют ожиданиями на SQL. Создают второй тест для PdoCustomerLookup. Он передаёт адаптеру PDO, подключённый только к тестовой схеме, кладёт известную строку в пределах транзакции и делает настоящий запрос. Ожидаемое значение выводится не из настройки mock-а, а из таблицы через тот же путь, по которому пойдёт приложение.
<?php
final class PdoCustomerLookupIntegrationTest extends TestCase
{
/** @var PDO */
private $pdo;
protected function setUp(): void
{
$this->pdo = TestPdo::fromEnvironment();
$this->pdo->beginTransaction();
$this->pdo->prepare('INSERT INTO customers (email, name) VALUES (?, ?)')
->execute(array('anna@example.test', 'Анна'));
}
protected function tearDown(): void
{
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
}
public function testFindsExistingEmail(): void
{
$lookup = new PdoCustomerLookup($this->pdo);
$this->assertTrue($lookup->existsByEmail('anna@example.test'));
$this->assertFalse($lookup->existsByEmail('missing@example.test'));
}
}
Здесь целевое поведение всё ещё небольшое: два адреса, один настоящий запрос, одна транзакция. Если в таблице вместо email теперь mail, тест покажет реальную ошибку. Если драйвер возвращает строку в неожиданной кодировке или DSN не открывается, это уже не «красный unit-тест», а след того, что договор адаптера или окружения изменился.
Как появляются фальшиво-зелёные проверки
Фальшивая зелень возникает не из-за самого mock-объекта. Она появляется, когда его результат становится единственным доказательством внешней границы. Test double заранее научен вернуть true, поэтому он никогда не увидит отсутствие миграции, ошибочный DNS, пустой TEST_CALLBACK_URL или код ответа 500. Такой объект нужен для unit-вопроса, но его нельзя использовать для ответа на другой вопрос.
| Зелёный тест говорит | Чего он не видел | Минимальная настоящая проверка | Следующее действие |
|---|---|---|---|
Сервис вызвал save() | SQL, транзакцию и индекс | Один INSERT и чтение через PDO в test DB | Добавить integration-тест адаптера |
| Клиент получил URL строкой | DNS, cURL, статус и тело ответа | Локальный HTTP endpoint с ожидаемым статусом | Проверить код и разбор ответа |
| Конструктор получил DSN | Как bootstrap прочёл окружение | Запуск с TEST_ переменными и отказ без них | Зафиксировать test-only конфигурацию |
| Mock вернул массив | Настоящий формат строки БД или JSON | Адаптер читает учебный ответ ресурса | Проверить преобразование на границе |
Выбираю границу по риску, а не по названию папки
Папки tests/Unit и tests/Integration помогают ориентироваться, но не делают код правильным сами. Сначала называем побочный эффект: запись в БД, HTTP-вызов, файловая система, очередь или конфигурация. Затем оставляем реальным только один из них. Если тест одновременно поднимает БД, отправляет письмо и строит HTML, он слишком широкий для поиска причины. Если он заменяет все ресурсы, он не ловит ошибки склейки.
- Выписать один симптом, который прошёл мимо unit-тестов: SQL, HTTP, конфигурация или преобразование данных.
- Назвать класс, который владеет границей, например
PdoCustomerLookupилиCallbackClient. - Оставить настоящий только этот адаптер, а остальные соседние части заменить простыми контролируемыми объектами.
- Подготовить test-only ресурс: отдельную схему, локальный HTTP endpoint или временный каталог; production ресурс не использовать.
- Проверить положительный и один отрицательный путь, который показывает понятную ошибку границы.
- Оставить unit-тест правила рядом с integration-тестом адаптера: они дополняют, а не дублируют друг друга.
Версии и ограничения нельзя прятать
Пример рассчитан на синтаксис PHP 7.2 и PHPUnit 7.5. Эти версии уже не поддерживаются на дату редакционного пересмотра, поэтому в новом проекте их не стоит выбирать по этой статье. Для исторического кода важно закрепить фактическую версию в composer.lock и сверить методы createMock(), setUp() и конфигурацию именно с ней.
Интеграционный тест не заменяет ручную проверку прав production-пользователя и не даёт разрешения обращаться к внешнему партнёру из CI. Его задача скромнее: сделать конкретную техническую границу наблюдаемой в контролируемой среде. Когда эта среда отсутствует, это известное ограничение проекта, которое нужно исправить организационно, а не спрятать под зелёным mock-ом.
Итог: два теста вместо одного громкого названия
Unit-тест быстро подтверждает локальное правило. Integration-тест подтверждает договор с настоящим адаптером. Первый должен быть маленьким и не требовать БД, второй — узким и не уходить в production. Когда в отчёте видны оба пути, зелёный цвет перестаёт означать «мы надеемся» и начинает означать конкретно проверенный переход.