Unit-тест регистрации зелёный, но форма на тестовом стенде отвечает 500 после записи или не отправляет уведомление. Цена ошибки — двойная: можно потерять след между БД и HTTP, а затем «починить» тест подстановкой, которая снова никогда не увидит реальный DSN, cURL и код ответа.
Разберём один вопрос: как поймать фальшиво-зелёный сценарий PHP, если в нём сходятся БД, HTTP и конфигурация? Это учебная трасса, а не отчёт о чужом инциденте. Мы не будем вызывать партнёрский URL и не станем выдавать команды за уже выполненные: вместо этого подготовим test-only БД и локальный HTTP-обработчик, которыми управляет сам проект.
Сначала сохраняю порядок фактов, а не объяснение
Полевой разбор начинается с одной исходной команды и одним учебным идентификатором, например registration-test-42. Его передаём в запись и заголовок локального callback. Тогда можно спросить последовательно: создалась ли строка, был ли собран URL, дошёл ли HTTP-запрос до тестового обработчика, какой статус вернулся и какое исключение увидел вызывающий код. Без этого порядка фраза «форма не работает» смешивает три разные границы.
В нормальном тестовом контуре DSN и URL имеют отдельные переменные: TEST_DATABASE_DSN и TEST_CALLBACK_URL. Не подставляем боевой адрес как запасной вариант. Пустая переменная — полезный красный сигнал, потому что она показывает ошибку конфигурации до записи или сетевой попытки. Секреты не печатаем в exception и не кладём в HTML-отчёт.
| Точка трассы | Что записать безопасно | Что означает сбой | Первое действие |
|---|---|---|---|
| Чтение конфигурации | Есть ли непустые TEST_ имена, без значений пароля | Запуск не получил test-only окружение | Остановить тест до соединения |
| PDO-соединение | Имя тестовой схемы и тип исключения | DSN, драйвер или права тестового пользователя | Проверить отдельную конфигурацию и миграцию |
| INSERT / SELECT | Учебный request ID и факт чтения обратно | SQL, схема или преобразование результата | Сузить тест до репозитория и повторить |
| HTTP-вызов | URL без query-секретов, статус, текст cURL-ошибки | Локальный endpoint недоступен или ответ не соответствует договору | Проверить порт, маршрут и ожидаемый статус |
| Ответ сервиса | Тип исключения и request ID | Код скрыл ошибку или смешал границы | Вернуть понятную ошибку вызывающему уровню |
Локальный callback вместо внешнего партнёра
Для integration-теста HTTP-граница должна быть настоящей, но управляемой. В отдельном терминале проекта можно запустить встроенный PHP-сервер и направить TEST_CALLBACK_URL на 127.0.0.1. Такой маршрут не доказывает доступность партнёра и не должен это обещать. Зато он показывает, что наш cURL-код собрал URL, отправил тело и корректно обработал статус, не передавая данные за пределы машины.
Обработчик ниже принимает только учебный запрос, сохраняет тело в системную временную папку и возвращает 202. Имя файла включает заранее выбранный ID из заголовка. Перед повторным запуском файл нужно удалить вручную в тестовой директории или в tearDown(); пример не советует чистить широкие каталоги и не требует прав администратора. Команда сервера приведена как способ воспроизведения, а не как выполненный здесь прогон.
<?php
// tests/fixtures/callback.php
$requestId = isset($_SERVER['HTTP_X_TEST_REQUEST_ID']) ? $_SERVER['HTTP_X_TEST_REQUEST_ID'] : 'missing';
if (!preg_match('/^[a-z0-9-]{1,40}$/', $requestId)) {
http_response_code(400);
echo 'bad request id';
return;
}
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'callback-' . $requestId . '.json';
file_put_contents($path, file_get_contents('php://input'));
header('Content-Type: application/json');
http_response_code(202);
echo '{"accepted":true}';
# отдельный терминал, если порт 8088 свободен:
# php -S 127.0.0.1:8088 -t tests/fixtures
В тестовой конфигурации URL должен быть явным: http://127.0.0.1:8088/callback.php. Если проект использует контейнер для тестовой БД, адрес может отличаться внутри контейнера; тогда сначала проверяем, из какого процесса запускается PHPUnit. Не угадываем localhost: у процесса PHP и у браузера это могут быть разные сетевые пространства. Это ещё одна причина хранить URL в test-only переменной и называть его в ошибке без токенов.
Показываю, почему unit-тест здесь недостаточен
Локальное правило регистрации всё ещё стоит покрыть unit-тестом. Но в следующем фрагменте оба побочных эффекта заменены объектами в памяти. Он подтвердит порядок вызовов и реакцию сервиса, однако всегда останется зелёным при пустом DSN, отсутствующем драйвере PDO или неверном URL. В этом и состоит его ограничение, а не дефект самого теста.
<?php
final class MemoryRegistrationRepository implements RegistrationRepository
{
public function create(string $requestId, string $email): int
{
return 42;
}
}
final class SpyCallbackClient implements CallbackClient
{
public $messages = array();
public function send(string $requestId, int $registrationId): void
{
$this->messages[] = array($requestId, $registrationId);
}
}
$repository = new MemoryRegistrationRepository();
$callback = new SpyCallbackClient();
$service = new RegistrationService($repository, $callback);
$service->register('registration-test-42', 'anna@example.test');
$this->assertSame(array(array('registration-test-42', 42)), $callback->messages);
Такой unit-тест остаётся полезным: он быстро защищает правило, что уведомление отправляется после успешного создания. Но его вывод надо читать буквально. Он не делал INSERT, не открывал cURL и не читал getenv(). Поэтому рядом появляется integration-тест с реальным PdoRegistrationRepository и CurlCallbackClient, направленным только на локальный endpoint.
Делаю настоящий HTTP-переход проверяемым
cURL-адаптер обязан отличать ошибку транспорта от ответа сервера. curl_exec() возвращает данные или false; статус читаем через curl_getinfo(). Не считаем любой непустой ответ успехом. Для учебного callback договор простой: ожидаем 202 и JSON с признаком accepted. Таймаут и заголовок задаются в коде явно, чтобы тест не зависел от неявных ini-настроек.
<?php
final class CurlCallbackClient implements CallbackClient
{
private $url;
public function __construct(string $url)
{
$host = parse_url($url, PHP_URL_HOST);
if ($host !== '127.0.0.1') {
throw new InvalidArgumentException('TEST_CALLBACK_URL must point to the local test endpoint');
}
$this->url = $url;
}
public function send(string $requestId, int $registrationId): void
{
$handle = curl_init($this->url);
if ($handle === false) {
throw new RuntimeException('Cannot create test callback handle');
}
curl_setopt_array($handle, array(
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => array('Content-Type: application/json', 'X-Test-Request-Id: ' . $requestId),
CURLOPT_POSTFIELDS => json_encode(array('registrationId' => $registrationId)),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
));
$body = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
$error = curl_error($handle);
curl_close($handle);
if ($body === false || $status !== 202 || $body !== '{"accepted":true}') {
throw new RuntimeException('Test callback failed: status=' . $status . ' error=' . $error);
}
}
}
Проверка 127.0.0.1 выше намеренно учебная и не подходит как общая политика URL. Её задача — не дать этому конкретному тесту случайно послать данные за пределы локальной машины. В проекте с отдельной тестовой сетью правило будет другим: allowlist test-хоста, отдельные credentials и запрещённый production DNS. Важно, что ограничение находится до вызова cURL, а не в надежде на внимательность запускающего.
Integration-тест связывает только три нужные части
Тест ниже предполагает, что тестовая схема уже подготовлена, а локальный callback поднят отдельно. Он не создаёт таблицы на лету и не обращается к production. Транзакция очистит запись в БД, но HTTP-вызов не откатится вместе с ней, поэтому обработчик пишет учебное тело в файл с request ID, который можно проверить и удалить после теста. Это явная граница: БД и сеть имеют разный способ уборки.
<?php
final class RegistrationIntegrationTest extends TestCase
{
/** @var PDO */
private $pdo;
protected function setUp(): void
{
$this->pdo = TestPdo::fromEnvironment();
$this->pdo->beginTransaction();
}
protected function tearDown(): void
{
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
}
public function testWritesAndNotifiesLocalCallback(): void
{
$url = (string) getenv('TEST_CALLBACK_URL');
$service = new RegistrationService(
new PdoRegistrationRepository($this->pdo),
new CurlCallbackClient($url)
);
$id = $service->register('registration-test-42', 'anna@example.test');
$this->assertInternalType('int', $id);
$this->assertTrue(is_file(sys_get_temp_dir() . '/callback-registration-test-42.json'));
}
}
Этот пример не доказывает доставку сообщения партнёру и не должен отправляться в общий параллельный контур без уникального request ID. Для параллельных запусков добавьте ID на основе безопасного имени теста и удаляйте только созданный им файл. Если endpoint не запущен, тест должен сообщить о недоступной локальной границе, а не незаметно переключиться на другой URL.
Порядок разбора, когда тест зеленее реальности
- Сохранить текст исходной ошибки и выбрать один учебный request ID; не менять DSN, URL и SQL одновременно.
- Проверить наличие
TEST_DATABASE_DSNиTEST_CALLBACK_URLбез вывода паролей и токенов. - Запустить отдельно интеграционный тест репозитория: запись и чтение через PDO должны быть видны до HTTP-шага.
- Поднять или проверить только локальный callback, затем убедиться, что URL теста не совпадает с внешним адресом.
- Добавить настоящий cURL-адаптер в тест и различить transport error, HTTP status и неверное тело ответа.
- После причины вернуть unit-тесту его узкую роль, а integration-тест оставить возле адаптеров как защиту от повторной склейки.
Что этот маршрут не обещает
Локальная связка не проверяет реальную сеть партнёра, его авторизацию, лимиты, очередь, браузерную форму или поведение production БД под нагрузкой. Она также не делает распределённую транзакцию: если БД уже записала строку, а callback ответил ошибкой, политика повтора и компенсации должна быть спроектирована отдельно. Не надо прятать эту проблему в catch и объявлять сценарий атомарным.
Версия PHP, драйвер PDO и PHPUnit должны быть закреплены проектом. На дату пересмотра PHP 7 и PHPUnit 7 уже устарели; примеры сохраняют исторический контекст 2018 года, но не заменяют план обновления. Перед применением к существующему коду сверяем актуальные параметры cURL, метод очистки схемы и правила тестовой инфраструктуры именно в этом проекте.
Итог: зелёный цвет должен иметь границу
Когда один unit-тест заменяет БД и HTTP, он может честно подтвердить порядок вызовов, но не саму склейку. Полевой integration-тест делает эту склейку короткой и управляемой: test-only конфигурация, реальный PDO, локальный callback, один request ID и раздельная уборка следов. Такой путь не лечит все ошибки, зато сразу показывает, какая из трёх границ действительно сломана.