Skip to content

Commit

Permalink
Merge branch 'main' into collection
Browse files Browse the repository at this point in the history
  • Loading branch information
xepozz committed Mar 1, 2024
2 parents 2a60234 + ac35931 commit 2e3f88d
Show file tree
Hide file tree
Showing 16 changed files with 23,369 additions and 96 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/phpunit.yaml
Expand Up @@ -32,7 +32,7 @@ jobs:
run: composer install

- name: Run tests with code coverage.
run: php -ddisable_functions=time vendor/bin/phpunit --coverage-clover=coverage.xml --colors=always
run: composer test

- name: Upload coverage to Codecov.
if: matrix.os == 'ubuntu-latest'
Expand Down
2 changes: 2 additions & 0 deletions Makefile
@@ -0,0 +1,2 @@
generate-stubs:
cd generator/stub; make generate
43 changes: 35 additions & 8 deletions README.md
Expand Up @@ -20,7 +20,6 @@ functions as: `time()`, `str_contains()`, `rand`, etc.
- [Tracking calls](#tracking-calls)
- [Global namespaced functions](#global-namespaced-functions)
- [Internal functions](#internal-functions)
- [Workaround](#workaround)
- [Internal function implementation](#internal-function-implementation)
- [Restrictions](#restrictions)
- [Data Providers](#data-providers)
Expand Down Expand Up @@ -76,6 +75,14 @@ The main idea is pretty simple: register a Listener for PHPUnit and call the Moc

Here you have registered extension that will be called every time when you run `./vendor/bin/phpunit`.

By default, all functions will be generated and saved into `/vendor/bin/xepozz/internal-mocker/data/mocks.php` file.

Override the first argument of the `Mocker` constructor to change the path:

```php
$mocker = new Mocker('/path/to/your/mocks.php');
```

### Register mocks

The package supports a few ways to mock functions:
Expand Down Expand Up @@ -112,6 +119,17 @@ MockerState::addCondition(
);
```

You may also use a callback to set the result of the function:

```php
MockerState::addCondition(
'', // namespace
'headers_sent', // function name
[null, null], // both arguments are references and they are not initialized yet on the function call
fn (&$file, &$line) => $file = $line = 123, // callback result
);
```

So your test case will look like the following:

```php
Expand Down Expand Up @@ -205,16 +223,22 @@ $traces = MockerState::getTraces('App\Service', 'time');
]
```

## Global namespaced functions
### Function signature stubs

### Internal functions
All internal functions are stubbed to be compatible with the original ones.
It makes the functions use referenced arguments (`&$file`) as the originals do.

They are located in the [`src/stubs.php`](src/stubs.php) file.

If you need to add a new function signature, override the second argument of the `Mocker` constructor:

```php
$mocker = new Mocker(stubPath: '/path/to/your/stubs.php');
```

Without any additional configuration you can mock only functions that are defined under any not global
namespaces: `App\`, `App\Service\`, etc.
But you cannot mock functions that are defined under global namespace or defined in a `use` statement, e.g. `use time;`
or `\time();`.
## Global namespaced functions

#### Workaround
### Internal functions

The way you can mock global functions is to disable them
in `php.ini`: https://www.php.net/manual/en/ini.core.php#ini.disable-functions
Expand Down Expand Up @@ -258,6 +282,9 @@ $mocks[] = [
];
```

> Keep in mind that leaving a global function without implementation will cause a recourse call of the function,
> that will lead to a fatal error.
## Restrictions

### Data Providers
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Expand Up @@ -24,6 +24,6 @@
}
},
"scripts": {
"test": "php -ddisable_functions=time vendor/bin/phpunit"
"test": "php -ddisable_functions=time,header,headers_sent vendor/bin/phpunit"
}
}
1 change: 1 addition & 0 deletions generator/stub/.gitignore
@@ -0,0 +1 @@
stubs/
8 changes: 8 additions & 0 deletions generator/stub/Makefile
@@ -0,0 +1,8 @@
clone:
git clone https://github.com/JetBrains/phpstorm-stubs stubs
clean:
rm -rf stubs
generator:
php generator.php

generate: clean clone generator
55 changes: 55 additions & 0 deletions generator/stub/generator.php
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

$folder = './stubs';
$destination = dirname(__DIR__, 2) . '/src/stubs.php';

$phpFiles = new RegexIterator(
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($folder)
),
'/.*\.php$/',
RegexIterator::GET_MATCH
);

$removeAttributesList = implode('|', [
'LanguageLevelTypeAware',
'LanguageAware',
'ElementAvailable',
'PhpStormStubsElementAvailable',
'PhpVersionAware',
'TypeContract',
'ExpectedValues',
'\\\\SensitiveParameter',
]);

$stubs = [];
foreach ($phpFiles as $file) {
$path = $file[0];
$contents = file_get_contents($path);
if (!preg_match_all('/^function (\w+)\(.*\)/m', $contents, $matches, PREG_SET_ORDER)) {
continue;
}

foreach ($matches as $match) {
$functionLine = preg_replace('/#\[(?:' . $removeAttributesList . ')(?:\(.+\)|)] /', '', $match[0]);
//$functionLine = 'function headers_sent(string &$filename, int &$line): bool';
preg_match_all('/(\$\w+)/', $functionLine, $arguments,);
preg_match('/\((.+)\)/', $functionLine, $signatureArguments);

$stubs[$match[1]] = [
'signatureArguments' => $signatureArguments[1] ?? '',
'arguments' => implode(', ', $arguments[0] ?? []),
];
}
}

$patches = require './patches.php';

$result = array_merge($stubs, $patches);

file_put_contents(
$destination,
'<?php' . PHP_EOL . PHP_EOL . 'return ' . var_export($result, true) . ';'
);
13 changes: 13 additions & 0 deletions generator/stub/patches.php
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

/**
* Example:
* 'header_remove' => [
* 'signatureArguments' => '?string $name = null',
* 'arguments' => '$name',
* ],
*/
return [
];
68 changes: 41 additions & 27 deletions src/Mocker.php
Expand Up @@ -8,8 +8,10 @@

final class Mocker
{
public function __construct(private string $path = __DIR__ . '/../data/mocks.php')
{
public function __construct(
private string $path = __DIR__ . '/../data/mocks.php',
private string $stubPath = __DIR__ . '/stubs.php',
) {
}

public function load(array $mocks): void
Expand Down Expand Up @@ -41,25 +43,28 @@ public function generate(array $mocks): string
$resultString = VarDumper::create($imock['result'])->export(false);
$defaultString = $imock['default'] ? 'true' : 'false';
$mockerConfig[] = <<<PHP
MockerState::addCondition(
"$namespace",
"$functionName",
$argumentsString,
$resultString,
$defaultString,
);
MockerState::addCondition(
"$namespace",
"$functionName",
$argumentsString,
$resultString,
$defaultString,
);
PHP;
}
}
}

$stubs = require $this->stubPath;

$outputs = [];
$mockerConfigClassName = MockerState::class;
foreach ($mocks as $namespace => $functions) {
$innerOutputsString = $this->generateFunction($functions);
$innerOutputsString = $this->generateFunction($functions, $stubs);

$outputs[] = <<<PHP
namespace {$namespace} {
use {$mockerConfigClassName};
use {$mockerConfigClassName};
$innerOutputsString
}
Expand All @@ -72,7 +77,7 @@ public function generate(array $mocks): string
$runtimeMocks = implode("\n", $mockerConfig);
$pre = <<<PHP
namespace {
use {$mockerConfigClassName};
use {$mockerConfigClassName};
{$runtimeMocks}
}
Expand Down Expand Up @@ -101,29 +106,38 @@ private function normalizeMocks(array $mocks): array
return $result;
}

private function generateFunction(mixed $groupedMocks): string
private function generateFunction(array $groupedMocks, array $stubs): string
{
$innerOutputs = [];
foreach ($groupedMocks as $functionName => $_) {
$function = "fn() => \\$functionName(...\$arguments)";
$signatureArguments = $stubs[$functionName]['signatureArguments'] ?? '...$arguments';
if (isset($stubs[$functionName]['arguments'])) {
$arrayArguments = sprintf('[%s]', $stubs[$functionName]['arguments']);
$unpackedArguments = $stubs[$functionName]['arguments'];
} else {
$arrayArguments = '$arguments';
$unpackedArguments = '...$arguments';
}

$function = "fn($signatureArguments) => \\$functionName($unpackedArguments)";
if ($_[0]['function'] !== false) {
$function = is_string($_[0]['function']) ? $_[0]['function'] : VarDumper::create(
$_[0]['function']
)->export(false);
$function = is_string($_[0]['function'])
? $_[0]['function']
: VarDumper::create($_[0]['function'])->export(false);
}

$string = <<<PHP
function $functionName(...\$arguments)
{
\$position = MockerState::saveTrace(__NAMESPACE__, "$functionName", \$arguments);
if (MockerState::checkCondition(__NAMESPACE__, "$functionName", \$arguments)) {
\$result = MockerState::getResult(__NAMESPACE__, "$functionName", \$arguments);
} else {
\$result = MockerState::getDefaultResult(__NAMESPACE__, "$functionName", $function);
function $functionName($signatureArguments)
{
\$position = MockerState::saveTrace(__NAMESPACE__, "$functionName", $unpackedArguments);
if (MockerState::checkCondition(__NAMESPACE__, "$functionName", $arrayArguments)) {
\$result = MockerState::getResult(__NAMESPACE__, "$functionName", $unpackedArguments);
} else {
\$result = MockerState::getDefaultResult(__NAMESPACE__, "$functionName", $function, $unpackedArguments);
}
return MockerState::saveTraceResult(__NAMESPACE__, "$functionName", \$position, \$result);
}
return MockerState::saveTraceResult(__NAMESPACE__, "$functionName", \$position, \$result);
}
PHP;
$innerOutputs[] = $string;
}
Expand Down
19 changes: 10 additions & 9 deletions src/MockerState.php
Expand Up @@ -14,7 +14,7 @@ final class MockerState
public static function addCondition(
string $namespace,
string $functionName,
array $arguments,
array|string $arguments,
mixed $result,
bool $default = false
): void {
Expand All @@ -39,7 +39,7 @@ public static function addCondition(
public static function checkCondition(
string $namespace,
string $functionName,
array $expectedArguments,
array|string $expectedArguments,
): bool {
$mocks = self::$state[$namespace][$functionName] ?? [];

Expand All @@ -51,10 +51,10 @@ public static function checkCondition(
return false;
}

private static function compareArguments(array $arguments, array $expectedArguments): bool
private static function compareArguments(array $arguments, array|string $expectedArguments): bool
{
return $arguments['arguments'] === $expectedArguments
|| array_values($arguments['arguments']) === $expectedArguments;
|| (is_array($arguments['arguments']) && array_values($arguments['arguments']) === $expectedArguments);
}

private static function replaceResult(
Expand All @@ -75,13 +75,13 @@ private static function replaceResult(
public static function getResult(
string $namespace,
string $functionName,
array $expectedArguments,
&...$expectedArguments,
): mixed {
$mocks = self::$state[$namespace][$functionName] ?? [];

foreach ($mocks as $mock) {
if (self::compareArguments($mock, $expectedArguments)) {
return $mock['result'];
return is_callable($mock['result']) ? $mock['result'](...$expectedArguments) : $mock['result'];
}
}
return false;
Expand All @@ -91,12 +91,13 @@ public static function getDefaultResult(
string $namespace,
string $functionName,
callable $fallback,
&...$arguments,
): mixed {
if (isset(self::$defaults[$namespace][$functionName])) {
return self::$defaults[$namespace][$functionName];
}

return $fallback();
return $fallback(...$arguments);
}

public static function saveState(): void
Expand All @@ -113,11 +114,11 @@ public static function resetState(): void
public static function saveTrace(
string $namespace,
string $functionName,
array $arguments
&...$arguments
): int {
$position = count(self::$traces[$namespace][$functionName] ?? []);
self::$traces[$namespace][$functionName][$position] = [
'arguments' => $arguments,
'arguments' => &$arguments,
'trace' => debug_backtrace(),
];

Expand Down

0 comments on commit 2e3f88d

Please sign in to comment.