Цель
Подмена (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 ?