import path from 'node:path'; import { fileURLToPath } from 'node:url'; const escapeHtml = (value) => String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const paragraph = (content) => '

' + content + '

'; const heading = (content) => '

' + content + '

'; const codeBlock = (source) => '
' + escapeHtml(source.trim()) + '
'; function figure(src, alt, caption) { return [ '
', '' + alt + '', '
' + caption + '
', '
', ].join(''); } function dataTable(headers, rows) { const head = headers.map((header) => '' + header + '').join(''); const body = rows.map((row) => ( '' + row.map((cell) => '' + cell + '').join('') + '' )).join(''); return '
' + head + '' + body + '
'; } function orderedList(items) { return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; } function sourceList(items) { return heading('Проверяемые источники') + ''; } const sources = { phpunit7: { label: 'PHPUnit — Getting Started with PHPUnit 7', url: 'https://phpunit.de/getting-started/phpunit-7.html', }, fixtures: { label: 'PHPUnit 7.5 — Fixtures', url: 'https://docs.phpunit.de/en/7.5/fixtures.html', }, doubles: { label: 'PHPUnit 7.5 — Test Doubles', url: 'https://docs.phpunit.de/en/7.5/test-doubles.html', }, pdoTransaction: { label: 'PHP Manual — PDO::beginTransaction', url: 'https://www.php.net/manual/en/pdo.begintransaction.php', }, pdoRollback: { label: 'PHP Manual — PDO::rollBack', url: 'https://www.php.net/manual/en/pdo.rollback.php', }, getenv: { label: 'PHP Manual — getenv', url: 'https://www.php.net/manual/en/function.getenv.php', }, curlExec: { label: 'PHP Manual — curl_exec', url: 'https://www.php.net/manual/en/function.curl-exec.php', }, curlGetinfo: { label: 'PHP Manual — curl_getinfo', url: 'https://www.php.net/manual/en/function.curl-getinfo.php', }, }; const practiceArticle = { slug: 'editorial-2018-11-practice-php-integration-tests', title: 'PHP. Интеграционный тест репозитория: фиксируем контракт с БД', categories: ['PHP', 'Тестирование'], cover: '/assets/editorial/2018/php-integration-contract-2018.svg', excerpt: 'Как написать небольшой интеграционный тест PHP-репозитория: отдельно настроить тестовую БД, проверить запись и чтение через PDO, а затем безопасно убрать учебные данные.', readingMinutes: 12, contentHtml: [ paragraph('Unit-тест сервиса зелёный, а после отправки формы в таблице появляется запись с пустым полем или её нельзя прочитать тем же кодом. Цена ошибки — не один 500-й ответ: команда может неделю менять бизнес-логику, хотя запрос, тип столбца или строка подключения никогда не были проверены вместе.'), paragraph('Разберём один вопрос: какой контракт должен зафиксировать интеграционный тест PHP-репозитория, чтобы он действительно проверял работу с БД? Ниже учебный пример для PHP 7.2 и PHPUnit 7.5. Он не запускает контейнер из статьи и не содержит рабочего пароля: тестовую БД, пользователя и способ её запуска определяет конкретный проект.'), heading('Интеграция начинается там, где PHP перестаёт быть единственным исполнителем'), paragraph('В unit-тесте мы можем передать репозиторию подставной объект и проверить решение внутри класса. Это полезно для правил валидации, расчётов и веток ошибок. Но такой тест не отправляет SQL драйверу, не знает схему таблицы и не читает переменную окружения. Когда важен путь PHP → PDO → тестовая БД → PDO → PHP, его нужно пройти настоящим адаптером.'), paragraph('Контракт здесь короткий: при заданных данных репозиторий записывает ровно те поля, которые нужны сценарию; затем он читает ту же запись и возвращает ожидаемые значения. В него не надо включать весь сайт, почту и внешний API. Чем уже граница, тем понятнее причина падения: конфигурация, соединение, SQL, схема или преобразование результата.'), figure( '/assets/editorial/2018/php-integration-contract-2018.svg', 'Схема интеграционного контракта PHP-репозитория: PHPUnit передаёт учебный объект в PDO-репозиторий, тот пишет и читает тестовую БД в отдельном контейнере или стенде; после проверки транзакция откатывается.', 'Тест доказывает не «работает весь сайт», а связку конфигурации, PDO, SQL и схемы для одного сценария записи и чтения.', ), heading('Сначала называю вход, выход и следы операции'), paragraph('Перед кодом полезно записать контракт словами. Для примера возьмём таблицу customers с полями id, email и name. Вход — валидный адрес и имя. Выход — идентификатор, а после findById() тот же адрес и имя. След операции — одна строка в тестовой БД. Если вместо этого нужен уникальный индекс, нормализация регистра или часовой пояс, это уже отдельный проверяемый случай, а не скрытая деталь первого теста.'), dataTable( ['Часть контракта', 'Что задаём', 'Что проверяем', 'Что не доказывает тест'], [ ['Конфигурация', 'TEST_DATABASE_DSN, пользователь, пароль вне репозитория', 'Подключение создаётся только к тестовой БД', 'Доступность production-БД или права боевого пользователя'], ['Запись', 'Адрес anna@example.test и имя Анна', 'Метод вернул числовой ID и SQL принял значения', 'Работу формы, шаблона и браузера'], ['Чтение', 'ID из той же операции', 'Поля не потерялись и не поменяли тип без причины', 'Все возможные выборки каталога'], ['Очистка', 'Открытая транзакция на соединении теста', 'После теста данные не остаются в этой транзакции', 'Откат DDL или вызова внешнего HTTP-сервиса'], ['Ошибка', 'Отсутствующий DSN или неверная схема', 'Падение объясняет границу, а не маскируется пустым массивом', 'Что ошибка автоматически исправится на стенде'], ], ), heading('Тестовая конфигурация должна быть отдельной'), paragraph('Подключение нельзя прятать в конструкторе репозитория под строкой mysql:host=localhost;dbname=site. В тесте это опасно: читатель не видит, к какой базе обратится команда, а случайно оставленный пароль легко попадёт в Git. Берём DSN и учётные данные из переменных с префиксом TEST_. Сам префикс не является защитой, поэтому ниже есть явная проверка имени базы и понятная остановка при пустом значении.'), paragraph('Тестовая БД может жить в отдельном контейнере, локальном сервисе или выделенном стенде. Контракт от этого не меняется, но окружение должно быть изолировано от рабочих данных. В этой заметке не утверждается, что какой-либо контейнер был запущен: команда запуска и образ зависят от версии MySQL, драйвера PDO и правил проекта. Сначала проверяем адрес, затем разрешаем тесту открыть соединение.'), codeBlock([ ' PDO::ERRMODE_EXCEPTION,', ' PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,', ' ));', ' }', '}', ].join('\n')), paragraph('Проверка слова test — только страховка от очевидной опечатки, а не модель прав доступа. В реальном проекте надёжнее отдельный пользователь без доступа к production-схемам, отдельная сеть и имя БД из закрытой тестовой конфигурации. Если DSN пустой или выглядит сомнительно, лучше остановить запуск с ошибкой, чем заменить его значением по умолчанию.'), heading('Пишу один путь через настоящий PDO-репозиторий'), paragraph('Следующий фрагмент показывает форму теста, а не готовый слой доступа к данным для любого проекта. CustomerRepository здесь использует переданный PDO, поэтому тот же SQL увидят драйвер и тестовая схема. В setUp() создаётся фикстура и открывается транзакция; в tearDown() она откатывается даже после падения проверки. PHPUnit вызывает эти методы вокруг теста, но их конкретные сигнатуры стоит сверить с закреплённой версией фреймворка.'), codeBlock([ 'pdo = TestPdo::fromEnvironment();', ' $this->pdo->beginTransaction();', ' }', '', ' protected function tearDown(): void', ' {', ' if ($this->pdo instanceof PDO && $this->pdo->inTransaction()) {', ' $this->pdo->rollBack();', ' }', ' }', '', ' public function testStoresAndReadsCustomer(): void', ' {', ' $repository = new CustomerRepository($this->pdo);', " $id = $repository->add('anna@example.test', 'Анна');", '', ' $stored = $repository->findById($id);', '', " $this->assertSame('anna@example.test', $stored['email']);", ' $this->assertSame(\'Анна\', $stored[\'name\']);', ' }', '}', ].join('\n')), paragraph('Важно, что тест не проверяет SQL строкой или моковым ожиданием. Он вызывает публичные методы репозитория, а доказательство получает после чтения обратно. Если в add() перепутан столбец, драйвер не принимает тип или findById() меняет имя ключа, зелёный результат невозможен при корректно настроенной тестовой схеме. Если же упало соединение, это тоже полезный сигнал: контракт конфигурации пока не выполнен.'), heading('Транзакция чистит данные, но не отменяет всё'), paragraph('PDO переводит соединение в режим транзакции после beginTransaction(); rollBack() возвращает изменения данных назад и включает autocommit. Это удобно для коротких тестов, которые делают INSERT, UPDATE и DELETE. Но MySQL может сделать неявный commit при DDL, например CREATE TABLE или DROP TABLE. Поэтому миграции и создание схемы не прячем внутрь этого теста: их выполняют отдельным подготовительным шагом.'), paragraph('Ещё одна граница — несколько соединений. Откат одного PDO не очистит запись, сделанную вторым соединением, очередью или HTTP-клиентом. Если код открывает соединение сам, сначала передайте ему тестовую фабрику или выделите адаптер. Только после этого можно честно сказать, что тест контролирует следы операции.'), heading('Порядок запуска без случайного доступа к данным'), orderedList([ 'Создать отдельную схему и пользователя для тестов по правилам проекта; не копировать production DSN в команду PHPUnit.', 'Применить к тестовой схеме заранее подготовленную миграцию и отдельно записать её версию.', 'Перед запуском вывести только имя тестовой базы или иной безопасный идентификатор, не печатая пароль.', 'Запустить один класс через ./vendor/bin/phpunit tests/Integration/CustomerRepositoryIntegrationTest.php после проверки версии PHPUnit.', 'Если тест падает, сначала разделить ошибку подключения, SQL и ожидание результата; не заменять реальный репозиторий моком ради зелёного вывода.', 'После прохождения добавить второй короткий тест лишь для следующего контракта, например уникальности, а не раздувать один сценарий до проверки всего приложения.', ]), heading('Когда этот тест не подходит'), paragraph('Интеграционный тест репозитория не доказывает, что HTML-форма передала нужное поле, cron запущен, письмо доставлено или партнёрский API отвечает. Для каждого такого перехода нужна своя небольшая граница. Не стоит также запускать десятки одинаковых записей против общей БД параллельно без изоляции: тогда тест может падать от чужих данных, а не от кода.'), paragraph('Если проект пока не имеет отдельной схемы, честный статус — «интеграционный тест отложен из-за отсутствия безопасной среды», а не mock, названный интеграцией. Сначала сделайте минимальную тестовую конфигурацию и один путь записи-чтения. После этого остальные адаптеры можно покрывать тем же спокойным правилом: настоящий ресурс, узкий контракт, наблюдаемый результат и явная уборка.'), heading('Итог: проверяем не название теста, а путь данных'), paragraph('Зелёный unit-тест полезен, но он не заменяет путь через PDO и схему. Для репозитория достаточно начать с одного контракта: безопасная тестовая конфигурация, настоящая запись, чтение тем же адаптером и контролируемая очистка. Когда этот маршрут падает, он показывает границу ошибки; когда проходит, он оставляет следующему разработчику воспроизводимый способ проверить ту же связку.'), sourceList([sources.phpunit7, sources.fixtures, sources.pdoTransaction, sources.pdoRollback, sources.getenv]), ].join('\n'), }; const mechanismArticle = { slug: 'editorial-2018-11-mechanism-php-integration-tests', title: 'PHP. Unit и integration: где заканчивается mock и начинается настоящий запрос', categories: ['PHP', 'Тестирование'], cover: '/assets/editorial/2018/php-test-boundary-2018.svg', excerpt: 'Разбираем границу между unit- и integration-тестом PHP: что дают mock-объекты, что они не могут проверить и как выбрать минимальный настоящий переход к БД или HTTP.', readingMinutes: 12, contentHtml: [ paragraph('Все unit-тесты сервиса зелёные, но первая реальная запись падает с ошибкой SQL или читает не то значение из конфигурации. Цена такой зелени — ложная уверенность при рефакторинге: mock подтвердил договор с самим тестом, а не с драйвером БД, схемой и внешним адресом.'), paragraph('Один вопрос этой заметки: как провести границу между unit- и integration-тестом PHP, чтобы не назвать mock настоящей проверкой? В 2018 году достаточно простой модели. Сначала проверяем правило в классе без сети и БД, затем отдельно проводим один реальный переход через адаптер. Не надо заставлять каждый тест поднимать всё приложение.'), heading('Слово «unit» описывает контролируемую границу'), paragraph('Unit-тест оставляет под контролем сам класс и подменяет его соседей. Подстановка нужна не потому, что БД «плохая», а потому, что мы хотим быстро проверить одно правило: например, запрещает ли сервис дублирующий адрес до записи. В таком тесте объект-заглушка возвращает заранее выбранный ответ, а test case проверяет реакцию сервиса. Он не зависит от таблицы, сети и текущего значения переменной окружения.'), paragraph('Integration-тест оставляет настоящий переход там, где важен договор двух частей. Для PHP-репозитория это драйвер PDO, запрос и тестовая схема. Для HTTP-клиента это формирование запроса, cURL и управляемый тестовый endpoint. Оба вида тестов могут вызывать один сервис, но отвечают на разные вопросы. Ошибка начинается, когда они получают одинаковое название и от одного ждут доказательства другого.'), figure( '/assets/editorial/2018/php-test-boundary-2018.svg', 'Схема границы тестов PHP: unit-тест заменяет порт репозитория и проверяет решение сервиса; integration-тест оставляет реальный PDO-адаптер или HTTP-клиент и проверяет договор на границе процесса.', 'Пунктир обозначает место подстановки. Всё, что осталось за ним, unit-тест не способен проверить независимо от числа ожиданий.', ), heading('Один сценарий можно разложить на два точных вопроса'), paragraph('Представим регистрацию пользователя. Правило «не создавать запись для уже занятого email» удобно проверить unit-тестом: он получает подставной репозиторий, который сообщает, что адрес существует. Но сам SQL с WHERE email = ?, тип колонки и сопоставление строки с массивом PHP остаются за границей. Их должен покрыть отдельный integration-тест конкретного PDO-репозитория.'), dataTable( ['Вопрос', 'Unit-тест', 'Integration-тест', 'Признак лишней работы'], [ ['Правило дубликата', 'Подставной репозиторий отвечает true', 'Не обязателен в каждом варианте правила', 'Поднимать БД, чтобы проверить одно условие if'], ['SQL и имена столбцов', 'Не проверяет', 'Выполняет настоящий запрос в тестовой схеме', 'Сравнивать строку SQL с копией этой же строки в тесте'], ['Тип результата PDO', 'Можно задать массив вручную', 'Показывает фактический FETCH_ASSOC и преобразование', 'Считать mock доказательством работы драйвера'], ['HTTP-запрос', 'Проверяет, что клиент был вызван с нужными данными', 'Проверяет URL, код ответа и разбор ответа на локальном endpoint', 'Посылать тест в боевой API'], ['Конфигурация', 'Передаёт строку явно в конструктор', 'Берёт test-only значение из окружения и проверяет отказ при его отсутствии', 'Использовать production значение по умолчанию'], ], ), heading('Unit-тест: правило без настоящей БД'), paragraph('Ниже минимальный пример на PHPUnit 7. Он не называет объект mock только ради модного слова: репозиторий подставлен, потому что тест проверяет решение RegistrationService до момента записи. Вход и ожидаемый отказ видны прямо в коде. Такой тест быстро падает, если автор случайно удалит проверку существующего адреса, и не требует доступной БД для каждого запуска.'), codeBlock([ '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');", ' }', '}', ].join('\n')), paragraph('Этот код сознательно не содержит PDO, getenv() и URL. Если он зелёный, мы знаем ровно одно: при ответе true сервис бросает ожидаемое исключение. Мы не знаем, вернёт ли реальный запрос true, доступна ли нужная таблица и не передал ли bootstrap в репозиторий другой DSN. Чем точнее сформулирован вывод, тем меньше соблазн считать этот тест универсальной страховкой.'), heading('Integration-тест: настоящий адаптер вместо предположения'), paragraph('Чтобы проверить репозиторий, unit-тест выше не расширяют ожиданиями на SQL. Создают второй тест для PdoCustomerLookup. Он передаёт адаптеру PDO, подключённый только к тестовой схеме, кладёт известную строку в пределах транзакции и делает настоящий запрос. Ожидаемое значение выводится не из настройки mock-а, а из таблицы через тот же путь, по которому пойдёт приложение.'), codeBlock([ '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'));", ' }', '}', ].join('\n')), paragraph('Здесь целевое поведение всё ещё небольшое: два адреса, один настоящий запрос, одна транзакция. Если в таблице вместо email теперь mail, тест покажет реальную ошибку. Если драйвер возвращает строку в неожиданной кодировке или DSN не открывается, это уже не «красный unit-тест», а след того, что договор адаптера или окружения изменился.'), heading('Как появляются фальшиво-зелёные проверки'), paragraph('Фальшивая зелень возникает не из-за самого mock-объекта. Она появляется, когда его результат становится единственным доказательством внешней границы. Test double заранее научен вернуть true, поэтому он никогда не увидит отсутствие миграции, ошибочный DNS, пустой TEST_CALLBACK_URL или код ответа 500. Такой объект нужен для unit-вопроса, но его нельзя использовать для ответа на другой вопрос.'), dataTable( ['Зелёный тест говорит', 'Чего он не видел', 'Минимальная настоящая проверка', 'Следующее действие'], [ ['Сервис вызвал save()', 'SQL, транзакцию и индекс', 'Один INSERT и чтение через PDO в test DB', 'Добавить integration-тест адаптера'], ['Клиент получил URL строкой', 'DNS, cURL, статус и тело ответа', 'Локальный HTTP endpoint с ожидаемым статусом', 'Проверить код и разбор ответа'], ['Конструктор получил DSN', 'Как bootstrap прочёл окружение', 'Запуск с TEST_ переменными и отказ без них', 'Зафиксировать test-only конфигурацию'], ['Mock вернул массив', 'Настоящий формат строки БД или JSON', 'Адаптер читает учебный ответ ресурса', 'Проверить преобразование на границе'], ], ), heading('Выбираю границу по риску, а не по названию папки'), paragraph('Папки tests/Unit и tests/Integration помогают ориентироваться, но не делают код правильным сами. Сначала называем побочный эффект: запись в БД, HTTP-вызов, файловая система, очередь или конфигурация. Затем оставляем реальным только один из них. Если тест одновременно поднимает БД, отправляет письмо и строит HTML, он слишком широкий для поиска причины. Если он заменяет все ресурсы, он не ловит ошибки склейки.'), orderedList([ 'Выписать один симптом, который прошёл мимо unit-тестов: SQL, HTTP, конфигурация или преобразование данных.', 'Назвать класс, который владеет границей, например PdoCustomerLookup или CallbackClient.', 'Оставить настоящий только этот адаптер, а остальные соседние части заменить простыми контролируемыми объектами.', 'Подготовить test-only ресурс: отдельную схему, локальный HTTP endpoint или временный каталог; production ресурс не использовать.', 'Проверить положительный и один отрицательный путь, который показывает понятную ошибку границы.', 'Оставить unit-тест правила рядом с integration-тестом адаптера: они дополняют, а не дублируют друг друга.', ]), heading('Версии и ограничения нельзя прятать'), paragraph('Пример рассчитан на синтаксис PHP 7.2 и PHPUnit 7.5. Эти версии уже не поддерживаются на дату редакционного пересмотра, поэтому в новом проекте их не стоит выбирать по этой статье. Для исторического кода важно закрепить фактическую версию в composer.lock и сверить методы createMock(), setUp() и конфигурацию именно с ней.'), paragraph('Интеграционный тест не заменяет ручную проверку прав production-пользователя и не даёт разрешения обращаться к внешнему партнёру из CI. Его задача скромнее: сделать конкретную техническую границу наблюдаемой в контролируемой среде. Когда эта среда отсутствует, это известное ограничение проекта, которое нужно исправить организационно, а не спрятать под зелёным mock-ом.'), heading('Итог: два теста вместо одного громкого названия'), paragraph('Unit-тест быстро подтверждает локальное правило. Integration-тест подтверждает договор с настоящим адаптером. Первый должен быть маленьким и не требовать БД, второй — узким и не уходить в production. Когда в отчёте видны оба пути, зелёный цвет перестаёт означать «мы надеемся» и начинает означать конкретно проверенный переход.'), sourceList([sources.phpunit7, sources.fixtures, sources.doubles, sources.pdoTransaction, sources.getenv]), ].join('\n'), }; const fieldArticle = { slug: 'editorial-2018-11-field-php-integration-tests', title: 'PHP. Зелёный тест, нерабочая форма: разбираем БД, HTTP и конфигурацию', categories: ['PHP', 'Тестирование'], cover: '/assets/editorial/2018/php-false-green-trace-2018.svg', excerpt: 'Полевой разбор ложной зелени: unit-тест подменил репозиторий и HTTP-клиент, а ошибка живёт в DSN, запросе или URL. Собираем короткую трассу без вызова внешнего сервиса.', readingMinutes: 13, contentHtml: [ paragraph('Unit-тест регистрации зелёный, но форма на тестовом стенде отвечает 500 после записи или не отправляет уведомление. Цена ошибки — двойная: можно потерять след между БД и HTTP, а затем «починить» тест подстановкой, которая снова никогда не увидит реальный DSN, cURL и код ответа.'), paragraph('Разберём один вопрос: как поймать фальшиво-зелёный сценарий PHP, если в нём сходятся БД, HTTP и конфигурация? Это учебная трасса, а не отчёт о чужом инциденте. Мы не будем вызывать партнёрский URL и не станем выдавать команды за уже выполненные: вместо этого подготовим test-only БД и локальный HTTP-обработчик, которыми управляет сам проект.'), figure( '/assets/editorial/2018/php-false-green-trace-2018.svg', 'Трасса фальшиво-зелёного PHP-сценария: unit-тест заменяет репозиторий и HTTP-клиент, поэтому не видит DSN и URL; integration-тест проходит через PDO, тестовую БД, локальный callback и фиксирует отдельный request ID.', 'Один ID связывает запись, HTTP-попытку и проверку ответа. Он не нужен для модной наблюдаемости: это короткий способ не спутать три соседние ошибки.', ), heading('Сначала сохраняю порядок фактов, а не объяснение'), paragraph('Полевой разбор начинается с одной исходной команды и одним учебным идентификатором, например registration-test-42. Его передаём в запись и заголовок локального callback. Тогда можно спросить последовательно: создалась ли строка, был ли собран URL, дошёл ли HTTP-запрос до тестового обработчика, какой статус вернулся и какое исключение увидел вызывающий код. Без этого порядка фраза «форма не работает» смешивает три разные границы.'), paragraph('В нормальном тестовом контуре DSN и URL имеют отдельные переменные: TEST_DATABASE_DSN и TEST_CALLBACK_URL. Не подставляем боевой адрес как запасной вариант. Пустая переменная — полезный красный сигнал, потому что она показывает ошибку конфигурации до записи или сетевой попытки. Секреты не печатаем в exception и не кладём в HTML-отчёт.'), dataTable( ['Точка трассы', 'Что записать безопасно', 'Что означает сбой', 'Первое действие'], [ ['Чтение конфигурации', 'Есть ли непустые TEST_ имена, без значений пароля', 'Запуск не получил test-only окружение', 'Остановить тест до соединения'], ['PDO-соединение', 'Имя тестовой схемы и тип исключения', 'DSN, драйвер или права тестового пользователя', 'Проверить отдельную конфигурацию и миграцию'], ['INSERT / SELECT', 'Учебный request ID и факт чтения обратно', 'SQL, схема или преобразование результата', 'Сузить тест до репозитория и повторить'], ['HTTP-вызов', 'URL без query-секретов, статус, текст cURL-ошибки', 'Локальный endpoint недоступен или ответ не соответствует договору', 'Проверить порт, маршрут и ожидаемый статус'], ['Ответ сервиса', 'Тип исключения и request ID', 'Код скрыл ошибку или смешал границы', 'Вернуть понятную ошибку вызывающему уровню'], ], ), heading('Локальный callback вместо внешнего партнёра'), paragraph('Для integration-теста HTTP-граница должна быть настоящей, но управляемой. В отдельном терминале проекта можно запустить встроенный PHP-сервер и направить TEST_CALLBACK_URL на 127.0.0.1. Такой маршрут не доказывает доступность партнёра и не должен это обещать. Зато он показывает, что наш cURL-код собрал URL, отправил тело и корректно обработал статус, не передавая данные за пределы машины.'), paragraph('Обработчик ниже принимает только учебный запрос, сохраняет тело в системную временную папку и возвращает 202. Имя файла включает заранее выбранный ID из заголовка. Перед повторным запуском файл нужно удалить вручную в тестовой директории или в tearDown(); пример не советует чистить широкие каталоги и не требует прав администратора. Команда сервера приведена как способ воспроизведения, а не как выполненный здесь прогон.'), codeBlock([ 'http://127.0.0.1:8088/callback.php. Если проект использует контейнер для тестовой БД, адрес может отличаться внутри контейнера; тогда сначала проверяем, из какого процесса запускается PHPUnit. Не угадываем localhost: у процесса PHP и у браузера это могут быть разные сетевые пространства. Это ещё одна причина хранить URL в test-only переменной и называть его в ошибке без токенов.'), heading('Показываю, почему unit-тест здесь недостаточен'), paragraph('Локальное правило регистрации всё ещё стоит покрыть unit-тестом. Но в следующем фрагменте оба побочных эффекта заменены объектами в памяти. Он подтвердит порядок вызовов и реакцию сервиса, однако всегда останется зелёным при пустом DSN, отсутствующем драйвере PDO или неверном URL. В этом и состоит его ограничение, а не дефект самого теста.'), codeBlock([ '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);', ].join('\n')), paragraph('Такой unit-тест остаётся полезным: он быстро защищает правило, что уведомление отправляется после успешного создания. Но его вывод надо читать буквально. Он не делал INSERT, не открывал cURL и не читал getenv(). Поэтому рядом появляется integration-тест с реальным PdoRegistrationRepository и CurlCallbackClient, направленным только на локальный endpoint.'), heading('Делаю настоящий HTTP-переход проверяемым'), paragraph('cURL-адаптер обязан отличать ошибку транспорта от ответа сервера. curl_exec() возвращает данные или false; статус читаем через curl_getinfo(). Не считаем любой непустой ответ успехом. Для учебного callback договор простой: ожидаем 202 и JSON с признаком accepted. Таймаут и заголовок задаются в коде явно, чтобы тест не зависел от неявных ini-настроек.'), codeBlock([ '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);", ' }', ' }', '}', ].join('\n')), paragraph('Проверка 127.0.0.1 выше намеренно учебная и не подходит как общая политика URL. Её задача — не дать этому конкретному тесту случайно послать данные за пределы локальной машины. В проекте с отдельной тестовой сетью правило будет другим: allowlist test-хоста, отдельные credentials и запрещённый production DNS. Важно, что ограничение находится до вызова cURL, а не в надежде на внимательность запускающего.'), heading('Integration-тест связывает только три нужные части'), paragraph('Тест ниже предполагает, что тестовая схема уже подготовлена, а локальный callback поднят отдельно. Он не создаёт таблицы на лету и не обращается к production. Транзакция очистит запись в БД, но HTTP-вызов не откатится вместе с ней, поэтому обработчик пишет учебное тело в файл с request ID, который можно проверить и удалить после теста. Это явная граница: БД и сеть имеют разный способ уборки.'), codeBlock([ '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\'));', ' }', '}', ].join('\n')), paragraph('Этот пример не доказывает доставку сообщения партнёру и не должен отправляться в общий параллельный контур без уникального request ID. Для параллельных запусков добавьте ID на основе безопасного имени теста и удаляйте только созданный им файл. Если endpoint не запущен, тест должен сообщить о недоступной локальной границе, а не незаметно переключиться на другой URL.'), heading('Порядок разбора, когда тест зеленее реальности'), orderedList([ 'Сохранить текст исходной ошибки и выбрать один учебный request ID; не менять DSN, URL и SQL одновременно.', 'Проверить наличие TEST_DATABASE_DSN и TEST_CALLBACK_URL без вывода паролей и токенов.', 'Запустить отдельно интеграционный тест репозитория: запись и чтение через PDO должны быть видны до HTTP-шага.', 'Поднять или проверить только локальный callback, затем убедиться, что URL теста не совпадает с внешним адресом.', 'Добавить настоящий cURL-адаптер в тест и различить transport error, HTTP status и неверное тело ответа.', 'После причины вернуть unit-тесту его узкую роль, а integration-тест оставить возле адаптеров как защиту от повторной склейки.', ]), heading('Что этот маршрут не обещает'), paragraph('Локальная связка не проверяет реальную сеть партнёра, его авторизацию, лимиты, очередь, браузерную форму или поведение production БД под нагрузкой. Она также не делает распределённую транзакцию: если БД уже записала строку, а callback ответил ошибкой, политика повтора и компенсации должна быть спроектирована отдельно. Не надо прятать эту проблему в catch и объявлять сценарий атомарным.'), paragraph('Версия PHP, драйвер PDO и PHPUnit должны быть закреплены проектом. На дату пересмотра PHP 7 и PHPUnit 7 уже устарели; примеры сохраняют исторический контекст 2018 года, но не заменяют план обновления. Перед применением к существующему коду сверяем актуальные параметры cURL, метод очистки схемы и правила тестовой инфраструктуры именно в этом проекте.'), heading('Итог: зелёный цвет должен иметь границу'), paragraph('Когда один unit-тест заменяет БД и HTTP, он может честно подтвердить порядок вызовов, но не саму склейку. Полевой integration-тест делает эту склейку короткой и управляемой: test-only конфигурация, реальный PDO, локальный callback, один request ID и раздельная уборка следов. Такой путь не лечит все ошибки, зато сразу показывает, какая из трёх границ действительно сломана.'), sourceList([sources.phpunit7, sources.fixtures, sources.pdoTransaction, sources.getenv, sources.curlExec, sources.curlGetinfo]), ].join('\n'), }; export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; const isDirectExecution = Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isDirectExecution && process.argv.includes('--print-revisions')) { process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); }