Цель
Подмена (mock) функций, которые уже “загружены” в PHP еще до подгрузки Composer Autoloader, каких-либо include или других объвлений function name() {}
Подмена не только из под не пустого namespace, например App\Service\name , но и из корневого namespace: проще всего это сделать через use function name;
Проблема
Если объявить функцию с именем, которая уже существует в стандартной библиотеке PHP, то получим ошибку, что такая функция уже существует и переопределить её нельзя.
Можно было бы “выгрузить” её из памяти, но увы, выгружать из памяти функции в PHP нельзя.
Можно лишь переопределить функцию до её непосредственного объявления. Но такой способ не подходит, потому что функция уже объявлена при любом вызове php .
Ресерч
В php.ini можно найти флаг disable_functions , которая принимает список имен функций, которые нужно “не объявлять” в недрах PHP.
Если использовать этот флаг, то php -ddisable_functions=time -r "echo time();" выкинет ошибку:
❯ php -ddisable_functions=time -r "echo time();" PHP Fatal error: Uncaught Error: Call to undefined function time() in Command line code:1 Stack trace: #0 {main} thrown in Command line code on line 1 Fatal error: Uncaught Error: Call to undefined function time() in Command line code on line 1 Error: Call to undefined function time() in Command line code on line 1 Call Stack: 0.0000 389568 1. {main}() Command line code:0
Это и логично. Функции time больше нет. Но теперь ведь можно создать её самостоятельно?
Если объявить функцию самостоятельно, то ошибки больше не будет:
❯ php -ddisable_functions=time -r "function time() { return 123; } echo time();" 123%
Бинго!
Помещаем объявление функции в библиотеку, создаем State manager, через которого сможем управлять возвращаемым значение “123” и делаем пользователю интерфейс взаимодействия с этим менеджером.
Теперь, если пользователь захочет протестировать вызов time , то сможем самостоятельно указать требуемые значения. Время в будущем, в прошлом, 0, false, что угодно.
Но как быть, если нужно протестировать измененную функцию time лишь в одном тесте, а в других местах оставить всё как есть?
Можно так и сделать: State manager создает для всех тестов функцию, которая эмулирует стандартную time , а в нужном тесте наложить на общую эмуляцию частную.
Вроде всё логично и понятно. Можно накодить и наслаждаться тестированием.
Однако, а как эмулировать системное время? Если с различными полифилами от symfony всё понятно: можно создать какую-то функцию, которая будет базировать на другой функции, преобразовывать результат под новый формат и отдавать его.
Но на какой функции нужно базировать время?
DateTime* классы? date() ? mktime ? hrtime ? А если их тоже отключить нужно?
Bash! ?
PHP имеет возможность в любое время обратиться к своему старшему брату-башу простыми обратными кавычками: command . Результат будет строкой, но всегда можно "кастануть".
Для аналога time() команда date +%s .
Значит для State manager осталось написать только возможность использовать не статичное значение, а функцию, которая каждый раз будет выполняться.
Всё это и не только сделано в библиотеке xepozz/internal-mocker
Читаем доку по установке и первичной настройке, добавляем нужные файлы, вписываем следующую конфигурацию:
<?php $mocker = new Mocker(); $mocker->load([ [ 'namespace' => '', 'name' => 'time', 'function' => fn () => `date +%s`, ], ]); MockerState::saveState();
И получаем сгенерированный mock для функции time , который просто будет работать всегда как обычный time в самом PHP
<?php namespace { use Xepozz\InternalMocker\MockerState; function time(...$arguments) { if (MockerState::checkCondition(__NAMESPACE__, "time", $arguments)) { return MockerState::getResult(__NAMESPACE__, "time", $arguments); } return MockerState::getDefaultResult(__NAMESPACE__, "time", fn () => `date +%s`); } }
А протестить это очень легко:
<?php namespace Xepozz\InternalMocker\Tests\Integration; use PHPUnit\Framework\TestCase; use Xepozz\InternalMocker\MockerState; use function time; final class TimeTest extends TestCase { public function testRun() { $this->assertEquals(`date +%s`, time()); } public function testRun2() { MockerState::addCondition( '', 'time', [], 100 ); $this->assertEquals(100, time()); } public function testRun3() { $this->assertEquals(`date +%s`, time()); } public function testRun4() { $now = time(); sleep(1); $next = time(); $this->assertEquals(1, $next - $now); } }
Если кто-то писал свои костыли или специально убирал use function из файлов, чтобы подменять функции в нужном namespace, теперь можете избавиться от них и заменить это на подключение библиотеки и небольшой конфиг.
Полезные ссылки
Описание disable-functions: https://www.php.net/manual/en/ini.core.php#ini.disable-functions
Internal mocker: https://github.com/xepozz/internal-mocker/
А этот пост был написан еще неделю назад в моём телеграмме: https://t.me/handle_topic ?
