diff --git a/README.md b/README.md
index 2a57891..31f70c7 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ class MyTestClass extends TestCase
### Introduction to Runkit
-Some of the traits will rely on [Runkit7](https://www.php.net/runkit7), a port of PHP's runkit designed to work in PHP 7.x, to rewrite code at runtime (a.k.a. "monkey-patching").
+Some of the traits will rely on [Runkit7], a port of PHP's runkit designed to work in PHP 7.x, to rewrite code at runtime (a.k.a. "monkey-patching").
For example, once a PHP constant is defined, it will normally have that value until the PHP process ends. Under normal circumstances, that's great: it prevents the value from being accidentally overwritten and/or tampered with.
@@ -46,7 +46,7 @@ var_dump(SOME_CONSTANT)
#=> string(10) "some value"
// Now, re-define the constant.
-runkit_constant_redefine('SOME_CONSTANT', 'some other value');
+runkit7_constant_redefine('SOME_CONSTANT', 'some other value');
var_dump(SOME_CONSTANT)
#=> string(16) "some other value"
```
@@ -57,11 +57,14 @@ Of course, we might want a constant's original value to be restored after our te
The library offers a number of traits, based on the type of global state that might need to be manipulated.
-* [Constants](docs/Constants.md) (requires Runkit7)
+* [Constants](docs/Constants.md) (requires [Runkit7])
* [Environment Variables](docs/EnvironmentVariables.md)
+* [Functions](docs/Functions.md) (requires [Runkit7])
* [Global Variables](docs/GlobalVariables.md)
## Contributing
If you're interested in contributing to the library, [please review our contributing guidelines](.github/CONTRIBUTING.md).
+
+[Runkit7]: docs/Runkit.md
diff --git a/composer.json b/composer.json
index bff8337..73428b3 100644
--- a/composer.json
+++ b/composer.json
@@ -28,7 +28,7 @@
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^0.12",
"squizlabs/php_codesniffer": "^3.5",
- "stevegrunwell/runkit7-installer": "^1.1",
+ "stevegrunwell/runkit7-installer": "^1.2",
"symfony/phpunit-bridge": "^5.1"
},
"suggest": {
@@ -42,7 +42,10 @@
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
- }
+ },
+ "files": [
+ "tests/stubs/functions.php"
+ ]
},
"config": {
"preferred-install": "dist",
diff --git a/composer.lock b/composer.lock
index d306ee0..03ac9ee 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "efac7541b4b02889b60a3582c4bfa3eb",
+ "content-hash": "79ef5654d4d0319b3e09740bea89b2f4",
"packages": [],
"packages-dev": [
{
@@ -71,6 +71,10 @@
"stylecheck",
"tests"
],
+ "support": {
+ "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues",
+ "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer"
+ },
"time": "2020-06-25T14:57:39+00:00"
},
{
@@ -129,20 +133,24 @@
"phpcs",
"standards"
],
+ "support": {
+ "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues",
+ "source": "https://github.com/PHPCompatibility/PHPCompatibility"
+ },
"time": "2019-12-27T09:44:58+00:00"
},
{
"name": "phpstan/phpstan",
- "version": "0.12.52",
+ "version": "0.12.57",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
- "reference": "e96dd5e7ae9aefed663bc7e285ad96792b67eadc"
+ "reference": "f9909d1d0c44b4cbaf72babcf80e8f14d6fdd55b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e96dd5e7ae9aefed663bc7e285ad96792b67eadc",
- "reference": "e96dd5e7ae9aefed663bc7e285ad96792b67eadc",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f9909d1d0c44b4cbaf72babcf80e8f14d6fdd55b",
+ "reference": "f9909d1d0c44b4cbaf72babcf80e8f14d6fdd55b",
"shasum": ""
},
"require": {
@@ -171,6 +179,10 @@
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
+ "support": {
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "source": "https://github.com/phpstan/phpstan/tree/0.12.57"
+ },
"funding": [
{
"url": "https://github.com/ondrejmirtes",
@@ -185,7 +197,7 @@
"type": "tidelift"
}
],
- "time": "2020-10-25T07:23:44+00:00"
+ "time": "2020-11-21T12:53:28+00:00"
},
{
"name": "squizlabs/php_codesniffer",
@@ -236,26 +248,27 @@
"phpcs",
"standards"
],
+ "support": {
+ "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues",
+ "source": "https://github.com/squizlabs/PHP_CodeSniffer",
+ "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
+ },
"time": "2020-10-23T02:01:07+00:00"
},
{
"name": "stevegrunwell/runkit7-installer",
- "version": "v1.1.1",
+ "version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/stevegrunwell/runkit7-installer.git",
- "reference": "76c4cdaf6a03298a545012f1b8d2588c34a10d0e"
+ "reference": "7a2ed7adc0a0e1e904b94e40c2224101aaf28a16"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/stevegrunwell/runkit7-installer/zipball/76c4cdaf6a03298a545012f1b8d2588c34a10d0e",
- "reference": "76c4cdaf6a03298a545012f1b8d2588c34a10d0e",
+ "url": "https://api.github.com/repos/stevegrunwell/runkit7-installer/zipball/7a2ed7adc0a0e1e904b94e40c2224101aaf28a16",
+ "reference": "7a2ed7adc0a0e1e904b94e40c2224101aaf28a16",
"shasum": ""
},
- "require-dev": {
- "php": "^7.1",
- "phpunit/phpunit": ">=6.0"
- },
"bin": [
"bin/install-runkit.sh"
],
@@ -267,29 +280,32 @@
"authors": [
{
"name": "Steve Grunwell",
- "email": "steve@stevegrunwell.com",
"homepage": "https://stevegrunwell.com"
}
],
- "description": "Installer for PHP Runkit7",
+ "description": "Installer for PHP's runkit and runkit7 extensions",
"keywords": [
"runkit",
"testing"
],
- "time": "2018-12-05T19:16:14+00:00"
+ "support": {
+ "issues": "https://github.com/stevegrunwell/runkit7-installer/issues",
+ "source": "https://github.com/stevegrunwell/runkit7-installer"
+ },
+ "time": "2020-11-22T22:09:52+00:00"
},
{
"name": "symfony/phpunit-bridge",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/phpunit-bridge.git",
- "reference": "150aeb91dd9dafe13ec8416abd62e435330ca12d"
+ "reference": "61744927348cd391ac12f7c6b70544991275845c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/150aeb91dd9dafe13ec8416abd62e435330ca12d",
- "reference": "150aeb91dd9dafe13ec8416abd62e435330ca12d",
+ "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/61744927348cd391ac12f7c6b70544991275845c",
+ "reference": "61744927348cd391ac12f7c6b70544991275845c",
"shasum": ""
},
"require": {
@@ -309,9 +325,6 @@
],
"type": "symfony-bridge",
"extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- },
"thanks": {
"name": "phpunit/phpunit",
"url": "https://github.com/sebastianbergmann/phpunit"
@@ -344,6 +357,9 @@
],
"description": "Symfony PHPUnit Bridge",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/phpunit-bridge/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -358,7 +374,7 @@
"type": "tidelift"
}
],
- "time": "2020-10-02T12:57:56+00:00"
+ "time": "2020-10-24T15:53:55+00:00"
}
],
"aliases": [],
@@ -370,5 +386,5 @@
"php": ">=5.6"
},
"platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
diff --git a/docs/Constants.md b/docs/Constants.md
index 0714092..bc3ebd3 100644
--- a/docs/Constants.md
+++ b/docs/Constants.md
@@ -2,15 +2,8 @@
Some applications — especially WordPress — will use [PHP constants](https://www.php.net/manual/en/language.constants.php) for configuration that should not be edited directly through the UI.
-Normally, a constant cannot be redefined or removed once defined; however, [the runkit7 extension](https://www.php.net/manual/en/book.runkit7) exposes functions to modify normally immutable constructs.
+Normally, a constant cannot be redefined or removed once defined; however, [the runkit7 extension](Runkit.md) exposes functions to modify normally immutable constructs.
-If runkit functions are unavailable, the `Constants` trait will automatically skip tests that rely on this functionality.
-
-In order to install runkit7 in your development and CI environments, you may use [the installer bundled with this repo](https://github.com/stevegrunwell/runkit7-installer):
-
-```sh
-$ sudo ./vendor/bin/install-runkit.sh
-```
## Methods
diff --git a/docs/Functions.md b/docs/Functions.md
new file mode 100644
index 0000000..a3eda3b
--- /dev/null
+++ b/docs/Functions.md
@@ -0,0 +1,158 @@
+# Managing Functions
+
+When testing software, we often find ourselves making use of "stubs", which are objects that will return known values for given methods.
+
+For example, assume we're writing an integration test around how a feature behaves when an external API is unavailable, it's certainly easier to replace the HTTP response than to actually take down the API every time the test is run.
+
+Unfortunately, [PHPUnit's test double tools](https://phpunit.readthedocs.io/en/9.3/test-doubles.html) don't extend to functions, so we have to get creative. Fortunately, [PHP's runkit7 extension](Runkit.md), allows us to dynamically redefine functions at runtime.
+
+
+## Methods
+
+As all of these methods require [runkit7](Runkit.md), tests that use these methods will automatically be marked as skipped if the extension is unavailable.
+
+---
+
+### defineFunction()
+
+Define a new function for the duration of the test.
+
+`defineFunction(string $name, \Closure $closure): self`
+
+This is a wrapper around [PHP's `runkit_function_define()` function](https://www.php.net/manual/en/function.runkit-function-define.php).
+
+#### Parameters
+
+
+ - $name
+ - The function name.
+ - $closure
+ - The code for the function.
+
+
+#### Return values
+
+This method will return the calling class, enabling multiple methods to be chained.
+
+An `AssertWell\PHPUnitGlobalState\Exceptions\RunkitException` will be thrown if the given function cannot be defined.
+
+---
+
+### redefineFunction()
+
+Redefine an existing function for the duration of the test. If `$name` does not exist, it will be defined.
+
+`redefineFunction(string $name, \Closure $closure): self`
+
+This is a wrapper around [PHP's `runkit_function_redefine()` function](https://www.php.net/manual/en/function.runkit-function-redefine.php).
+
+#### Parameters
+
+
+ - $name
+ - The function name.
+ - $closure
+ - The new code for the function.
+
+
+#### Return values
+
+This method will return the calling class, enabling multiple methods to be chained.
+
+An `AssertWell\PHPUnitGlobalState\Exceptions\RunkitException` will be thrown if the given function cannot be defined.
+
+---
+
+### deleteFunction()
+
+Delete/undefine a function for the duration of the single test.
+
+`deleteFunction(string $name): self`
+
+#### Parameters
+
+
+ - $name
+ - The function name.
+
+
+#### Return values
+
+This method will return the calling class, enabling multiple methods to be chained.
+
+
+## Examples
+
+### Replacing a function for a single test
+
+Imagine that we have two functions: `get_posts()` and `make_api_request()`, which look something like this:
+
+```php
+/**
+ * Retrieve posts from the API and prepare it for templates.
+ *
+ * @return Post[] An array of Post objects.
+ */
+function get_posts()
+{
+ try {
+ $posts = make_api_request('/posts');
+ } catch (ApiUnavailableException $e) {
+ error_log($e->getMessage(), E_USER_WARNING);
+ return [];
+ }
+
+ return array_map([Post::class, 'factory'], $posts);
+}
+
+/**
+ * Send a request to the API.
+ *
+ * @param string $path The API path.
+ * @param mixed[] $args Arguments to pass with the request.
+ *
+ * @return array[]
+ */
+function make_api_request($path, $args = [])
+{
+ /*
+ * A bunch of pre-check conditions, sanitization, merging with default
+ * values, etc.
+ *
+ * Then we'll make the actual request, and finally check the results.
+ */
+ if ($response_code >= 500) {
+ throw new ApiUnavailableException('Received a 5xx error from the API.');
+ }
+
+ // More logic before finally returning the response.
+}
+```
+
+We're trying to write unit tests for `get_posts()`, but the path we want to test is what happens when `make_api_request()` returns throws an `ApiUnavailableException`.
+
+Now, assume that we don't have an easy way to emulate a 5xx status code from the API to cause `make_api_request()` to throw an `ApiUnavailableException`. Furthermore, we don't actually _want_ our tests making external requests, as that would add latency, external dependencies, and potentially cost money if it's a pay-per-usage service.
+
+Instead of weighing down our tests with a ton of code to make `make_api_request()` throw the desired exception, we can simply replace the function:
+
+```php
+use AssertWell\PHPUnitGlobalState\Functions;
+use PHPUnit\Framework\TestCase;
+
+class MyTestClass extends TestCase
+{
+ use Functions;
+
+ /**
+ * @test
+ */
+ public function get_posts_should_return_an_empty_array_if_the_API_request_fails()
+ {
+ $this->redefineFunction('make_api_request', function () {
+ throw new ApiUnavailableException('API is unavailable.');
+ });
+
+ $this->assertEmpty(get_posts());
+ }
+}
+```
diff --git a/docs/Runkit.md b/docs/Runkit.md
new file mode 100644
index 0000000..0fad499
--- /dev/null
+++ b/docs/Runkit.md
@@ -0,0 +1,60 @@
+# PHP's runkit and runkit7 extensions
+
+> For all those things you… probably shouldn't have been doing anyway… but surely do!
+
+In the PHP 5.x days, we had [the runkit extension](http://pecl.php.net/package/runkit) for dynamically redefining things that _shouldn't_ normally be redefined within the PHP runtime.
+
+For example, if you needed to change the value of a constant, your options were slim-to-nil before runkit came along. With the extension installed, however, you could now redefine that which was never meant to be redefined.
+
+With the release of PHP 7.x, [Tyson Andre](https://github.com/TysonAndre) forked runkit to create [runkit7](https://github.com/runkit7/runkit7), a PHP 7.x-compatible version of the extension.
+
+
+## You really shouldn't be using runkit…
+
+The runkit(7) extension is an immensely-powerful tool, but if you're not careful it can be the source of a lot of pain within your codebase.
+
+Generally speaking, **if you're using runkit in production code, you're probably approaching the problem in the wrong way.**
+
+That being said, runkit can be _amazing_ for automated tests, as we can dynamically change configurations and behaviors to emulate certain situations. If you're testing older or poorly-architected applications, runkit can mean the difference between a comprehensive test suite and one that leaves a lot of paths uncovered.
+
+Using runkit should probably never be your first approach, but in certain situations it's by-far the cleanest.
+
+Remember: **with great power comes great responsibility!!**
+
+
+## Installation
+
+Both runkit and runkit7 can be installed in your environment via [PECL](https://pecl.php.net/):
+
+```sh
+# For PHP 5.x
+pecl install runkit
+
+# For PHP 7.x
+pecl install runkit7
+```
+
+Depending on your environment, you may also need to take additional steps to load the extension after installation, which will be detailed in the shell output from `pecl install`.
+
+If you'd like to automate this process further, you may also consider [installing stevegrunwell/runkit7-installer](https://github.com/stevegrunwell/runkit7-installer#installation) as a Composer dependency in your project.
+
+
+## Using runkit and runkit7 in the same test suite
+
+More recent versions of runkit7 have introduced `runkit7_`-prefixed functions, and their `runkit_` counterparts are aliased to the newer versions.
+
+For example, `runkit_function_redefine()` is an alias for `runkit7_function_redefine()`.
+
+However, static code analysis tools like [PHPStan](https://phpstan.org/) will often throw warnings about the `runkit_` versions of the functions being undefined, and the corresponding pages are being removed from [php.net](https://php.net).
+
+To get around these issues, this library includes the `AssertWell\PHPUnitGlobalState\Support\Runkit` class, which proxies static method calls to runkit based what's available:
+
+```php
+use AssertWell\PHPUnitGlobalState\Support\Runkit;
+
+/*
+ * Use runkit7_constant_redefine() on PHP 7.x,
+ * runkit_constant_redefine() for PHP 5.x.
+ */
+Runkit::constant_redefine('SOME_CONSTANT', 'some value');
+```
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 9a51eff..670eeba 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -17,3 +17,14 @@ parameters:
-
message: '#Parameter \#1 \$function of function call_user_func_array expects callable\(\): mixed, string given\.#'
path: src/Support/Runkit.php
+
+ # Dynamically-defined functions.
+ -
+ message: '#Function \S+ not found\.$#'
+ paths:
+ - tests/FixtureTest.php
+ - tests/FunctionsTest.php
+
+ -
+ message: '#Call to an undefined static method \S+#'
+ path: tests/Support/RunkitTest.php
diff --git a/src/Concerns/Runkit.php b/src/Concerns/Runkit.php
deleted file mode 100644
index 126e0e2..0000000
--- a/src/Concerns/Runkit.php
+++ /dev/null
@@ -1,38 +0,0 @@
-isRunkitAvailable()) {
- return;
- }
-
- throw new SkippedTestError($message ?: 'This test requires Runkit, skipping.');
- }
-
- /**
- * Determine whether or not Runkit is available in the current environment.
- *
- * @return bool
- */
- protected function isRunkitAvailable()
- {
- return function_exists('runkit7_constant_redefine')
- || function_exists('runkit_constant_redefine');
- }
-}
diff --git a/src/Constants.php b/src/Constants.php
index 2e697f3..20a099e 100644
--- a/src/Constants.php
+++ b/src/Constants.php
@@ -7,8 +7,6 @@
trait Constants
{
- use Concerns\Runkit;
-
/**
* All constants being handled by this trait.
*
@@ -32,13 +30,19 @@ protected function restoreConstants()
} else {
define($name, $value);
}
+
+ unset($this->constants['updated'][$name]);
}
- foreach ($this->constants['created'] as $name) {
+ foreach ($this->constants['created'] as $key => $name) {
if (defined($name)) {
Runkit::constant_remove($name);
}
+
+ unset($this->constants['created'][$key]);
}
+
+ Runkit::reset();
}
/**
@@ -55,7 +59,9 @@ protected function restoreConstants()
*/
protected function setConstant($name, $value = null)
{
- $this->requiresRunkit('setConstant() requires Runkit be available, skipping.');
+ if (! Runkit::isAvailable()) {
+ $this->markTestSkipped('setConstant() requires Runkit be available, skipping.');
+ }
if (defined($name)) {
if (! isset($this->constants['updated'][$name])) {
@@ -92,7 +98,9 @@ protected function deleteConstant($name)
return $this;
}
- $this->requiresRunkit('deleteConstant() requires Runkit be available, skipping.');
+ if (! Runkit::isAvailable()) {
+ $this->markTestSkipped('deleteConstant() requires Runkit be available, skipping.');
+ }
if (! isset($this->constants[$name])) {
$this->constants['updated'][$name] = constant($name);
diff --git a/src/Exceptions/FunctionExistsException.php b/src/Exceptions/FunctionExistsException.php
new file mode 100644
index 0000000..6d000a5
--- /dev/null
+++ b/src/Exceptions/FunctionExistsException.php
@@ -0,0 +1,8 @@
+ [],
+ 'redefined' => [],
+ ];
+
+ /**
+ * @after
+ *
+ * @return void
+ */
+ protected function restoreFunctions()
+ {
+ // Reset anything that was modified.
+ array_walk($this->functions['redefined'], function ($original, $name) {
+ if (function_exists($name)) {
+ Runkit::function_remove($name);
+ }
+
+ // Put the original back into place.
+ Runkit::function_rename($original, $name);
+
+ unset($this->functions['redefined'][$name]);
+ });
+
+ array_map([Runkit::class, 'function_remove'], $this->functions['defined']);
+ $this->functions['defined'] = [];
+
+ Runkit::reset();
+ }
+
+ /**
+ * Define a new function.
+ *
+ * @throws \AssertWell\PHPUnitGlobalState\Exceptions\FunctionExistsException
+ * @throws \AssertWell\PHPUnitGlobalState\Exceptions\RunkitException
+ *
+ * @param string $name The function name.
+ * @param \Closure $closure The function body.
+ *
+ * @return self
+ */
+ protected function defineFunction($name, \Closure $closure)
+ {
+ if (function_exists($name)) {
+ throw new FunctionExistsException(sprintf(
+ 'Function %1$s() already exists. You may redefine it using %2$s::redefineFunction() instead.',
+ $name,
+ get_class($this)
+ ));
+ }
+
+ if (! Runkit::isAvailable()) {
+ $this->markTestSkipped('defineFunction() requires Runkit be available, skipping.');
+ }
+
+ if (! Runkit::function_add($name, $closure)) {
+ throw new RunkitException(sprintf('Unable to define function %1$s().', $name));
+ }
+
+ $this->functions['defined'][] = $name;
+
+ return $this;
+ }
+
+ /**
+ * Redefine an existing function.
+ *
+ * If the function doesn't yet exist, it will be defined.
+ *
+ * @param string $name The function name to be redefined.
+ * @param \Closure $closure The new function body.
+ *
+ * @return self
+ */
+ protected function redefineFunction($name, \Closure $closure)
+ {
+ if (! function_exists($name)) {
+ return $this->defineFunction($name, $closure);
+ }
+
+ if (! Runkit::isAvailable()) {
+ $this->markTestSkipped('redefineFunction() requires Runkit be available, skipping.');
+ }
+
+ // Back up the original version of the function.
+ if (! isset($this->functions['redefined'][$name])) {
+ $namespaced = Runkit::makeNamespaced($name);
+
+ if (! Runkit::function_rename($name, $namespaced)) {
+ throw new RunkitException(sprintf('Unable to back up %1$s(), aborting.', $name));
+ }
+
+ $this->functions['redefined'][$name] = $namespaced;
+
+ if (! Runkit::function_add($name, $closure)) {
+ throw new RunkitException(sprintf('Unable to redefine function %1$s().', $name));
+ }
+ } else {
+ Runkit::function_redefine($name, $closure);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Delete an existing function.
+ *
+ * @param string $name The function to be deleted.
+ *
+ * @return self
+ */
+ protected function deleteFunction($name)
+ {
+ if (! function_exists($name)) {
+ return $this;
+ }
+
+ $namespaced = Runkit::makeNamespaced($name);
+
+ if (! Runkit::function_rename($name, $namespaced)) {
+ throw new RunkitException(sprintf('Unable to back up %1$s(), aborting.', $name));
+ }
+
+ $this->functions['redefined'][$name] = $namespaced;
+
+ return $this;
+ }
+}
diff --git a/src/Support/Runkit.php b/src/Support/Runkit.php
index 5fbf103..eb3fa69 100644
--- a/src/Support/Runkit.php
+++ b/src/Support/Runkit.php
@@ -13,8 +13,10 @@
* @method static bool constant_redefine(string $constname, mixed $value, int $newVisibility = NULL)
* @method static bool constant_remove(string $constname)
* @method static bool function_add(string $funcname, string $arglist, string $code, bool $return_by_reference = NULL, string $doc_comment = NULL, string $return_type, bool $is_strict = NULL)
+ * @method static bool function_add(string $funcname, \Closure $closure, string $doc_comment = NULL, string $return_type = NULL, bool $is_strict = NULL)
* @method static bool function_copy(string $funcname, string $targetname)
* @method static bool function_redefine(string $funcname, string $arglist, string $code, bool $return_by_reference = NULL, string $doc_comment = NULL, string $return_type = NULL, bool $is_strict)
+ * @method static bool function_redefine(string $funcname, \Closure $closure, string $doc_comment = NULL, string $return_type = NULL, string $is_strict = NULL)
* @method static bool function_remove(string $funcname)
* @method static bool function_rename(string $funcname, string $newname)
* @method static bool import(string $filename, int $flags = NULL)
@@ -30,6 +32,13 @@
*/
class Runkit
{
+ /**
+ * A namespace used to move things out of the way for the duration of a test.
+ *
+ * @var string
+ */
+ private static $namespace;
+
/**
* Dynamically alias methods to the underlying Runkit functions.
*
@@ -51,8 +60,65 @@ public static function __callStatic($name, array $args = [])
}
throw new \BadFunctionCallException(sprintf(
- 'Runkit7 does not include a runkit7_%1$s() function.',
+ 'Neither runkit7_%1$s() nor runkit_%1$s() are defined.',
$name
));
}
+
+ /**
+ * Determine whether or not Runkit is available in the current environment.
+ *
+ * @return bool
+ */
+ public static function isAvailable()
+ {
+ return function_exists('runkit7_constant_redefine')
+ || function_exists('runkit_constant_redefine');
+ }
+
+ /**
+ * Get the current runkit namespace.
+ *
+ * If the property is currently empty, one will be created.
+ *
+ * @return string The namespace (with trailing backslash) where we're moving functions,
+ * constants, etc. during tests.
+ */
+ public static function getNamespace()
+ {
+ if (empty(self::$namespace)) {
+ self::$namespace = uniqid(__NAMESPACE__ . '\\runkit_') . '\\';
+ }
+
+ return self::$namespace;
+ }
+
+ /**
+ * Namespace the given reference.
+ *
+ * @param string $var The item to be moved into the temporary test namespace.
+ *
+ * @return string The newly-namespaced item.
+ */
+ public static function makeNamespaced($var)
+ {
+ // Strip leading backslashes.
+ if (0 === mb_strpos($var, '\\')) {
+ $var = mb_substr($var, 1);
+ }
+
+ return self::getNamespace() . $var;
+ }
+
+ /**
+ * Reset static properties.
+ *
+ * This is helpful to run before tests in case self::$namespace gets polluted.
+ *
+ * @return void
+ */
+ public static function reset()
+ {
+ self::$namespace = '';
+ }
}
diff --git a/tests/Concerns/RunkitTest.php b/tests/Concerns/RunkitTest.php
deleted file mode 100644
index 1620e58..0000000
--- a/tests/Concerns/RunkitTest.php
+++ /dev/null
@@ -1,69 +0,0 @@
-instance = $this->getMockForTrait(Runkit::class, [], '', true, true, true, [
- 'isRunkitAvailable',
- ]);
- }
-
- /**
- * @test
- */
- public function it_should_permit_tests_to_run_if_runkit_is_available()
- {
- $this->instance->expects($this->once())
- ->method('isRunkitAvailable')
- ->willReturn(true);
-
- $method = new \ReflectionMethod($this->instance, 'requiresRunkit');
- $method->setAccessible(true);
-
- $this->assertNull($method->invoke($this->instance));
- }
-
- /**
- * @test
- */
- public function it_should_skip_tests_that_require_runkit_if_it_is_unavailable()
- {
- $this->instance->expects($this->once())
- ->method('isRunkitAvailable')
- ->willReturn(false);
-
- $method = new \ReflectionMethod($this->instance, 'requiresRunkit');
- $method->setAccessible(true);
-
- // Older versions of PHPUnit will actually try to mark this as skipped.
- try {
- $method->invoke($this->instance);
- } catch (SkippedTestError $e) {
- $this->assertInstanceOf(SkippedTestError::class, $e);
- return;
- }
-
- $this->fail('Did not catch the expected SkippedTestError.');
- }
-}
diff --git a/tests/ConstantsTest.php b/tests/ConstantsTest.php
index 11d02ab..e3599d2 100644
--- a/tests/ConstantsTest.php
+++ b/tests/ConstantsTest.php
@@ -3,10 +3,10 @@
namespace Tests;
use AssertWell\PHPUnitGlobalState\Exceptions\RedefineException;
+use AssertWell\PHPUnitGlobalState\Support\Runkit;
use PHPUnit\Framework\SkippedTestError;
/**
- * @covers AssertWell\PHPUnitGlobalState\Concerns\Runkit
* @covers AssertWell\PHPUnitGlobalState\Constants
*
* @group Constants
@@ -31,7 +31,9 @@ public static function defineConstants()
*/
public function setConstant_should_be_able_to_handle_newly_defined_constants()
{
- $this->requiresRunkit('This test depends on runkit being available.');
+ if (! Runkit::isAvailable()) {
+ $this->markTestSkipped('This test depends on runkit being available.');
+ }
$this->assertFalse(defined('SOME_CONSTANT'));
@@ -48,7 +50,9 @@ public function setConstant_should_be_able_to_handle_newly_defined_constants()
*/
public function setConstant_should_be_able_to_redefine_existing_constants()
{
- $this->requiresRunkit('This test depends on runkit being available.');
+ if (! Runkit::isAvailable()) {
+ $this->markTestSkipped('This test depends on runkit being available.');
+ }
$this->setConstant('EXISTING_CONSTANT', 'some other value');
$this->assertSame('some other value', constant('EXISTING_CONSTANT'));
@@ -67,7 +71,9 @@ public function setConstant_should_be_able_to_redefine_existing_constants()
*/
public function setConstant_should_throw_an_exception_if_it_cannot_redefine_a_constant()
{
- $this->requiresRunkit('This test depends on runkit being available.');
+ if (! Runkit::isAvailable()) {
+ $this->markTestSkipped('This test depends on runkit being available.');
+ }
$this->expectException(RedefineException::class);
$this->setConstant('EXISTING_CONSTANT', (object) ['some' => 'object']);
@@ -85,7 +91,9 @@ public function setConstant_should_throw_an_exception_if_it_cannot_redefine_a_co
*/
public function deleteConstant_should_remove_an_existing_constant()
{
- $this->requiresRunkit('This test depends on runkit being available.');
+ if (! Runkit::isAvailable()) {
+ $this->markTestSkipped('This test depends on runkit being available.');
+ }
$this->deleteConstant('DELETE_THIS_CONSTANT');
$this->assertFalse(defined('DELETE_THIS_CONSTANT'));
diff --git a/tests/FixtureTest.php b/tests/FixtureTest.php
index 5e153b2..534c121 100644
--- a/tests/FixtureTest.php
+++ b/tests/FixtureTest.php
@@ -27,6 +27,10 @@ public function doSetUp()
$this->setConstant('FIXTURE_SETUP_CONSTANT', true);
$this->setEnvironmentVariable('FIXTURE_SETUP_ENV', 'abc');
$this->setGlobalVariable('FIXTURE_SETUP_GLOBAL', true);
+
+ $this->defineFunction('fixture_setup_function', function () {
+ return 'abc';
+ });
}
/**
@@ -37,13 +41,17 @@ protected function defineInitialValues()
$this->setConstant('FIXTURE_BEFORE_CONSTANT', true);
$this->setEnvironmentVariable('FIXTURE_BEFORE_ENV', 'xyz');
$this->setGlobalVariable('FIXTURE_BEFORE_GLOBAL', true);
+
+ $this->defineFunction('fixture_before_function', function () {
+ return 'xyz';
+ });
}
/**
* @test
* @group Constants
*/
- public function it_should_permit_constants_to_be_set_in_fixtures_method()
+ public function it_should_permit_constants_to_be_set_in_fixtures()
{
$this->assertTrue(
defined('FIXTURE_SETUP_CONSTANT'),
@@ -69,7 +77,7 @@ public function it_should_permit_constants_to_be_set_in_fixtures_method()
* @test
* @group EnvironmentVariables
*/
- public function it_should_permit_environment_variables_to_be_set_in_fixtures_method()
+ public function it_should_permit_environment_variables_to_be_set_in_fixtures()
{
$this->assertSame(
'abc',
@@ -93,11 +101,39 @@ public function it_should_permit_environment_variables_to_be_set_in_fixtures_met
);
}
+ /**
+ * @test
+ * @group Functions
+ */
+ public function it_should_permit_functions_to_be_set_in_fixtures()
+ {
+ $this->assertSame(
+ 'abc',
+ fixture_setup_function(),
+ 'The function should have been defined in the setUp() method.'
+ );
+ $this->assertSame(
+ 'xyz',
+ fixture_before_function(),
+ 'The function should have been defined in the @before method.'
+ );
+
+ $this->restoreFunctions();
+ $this->assertFalse(
+ function_exists('fixture_setup_function'),
+ 'The function should have been undefined by restoreFunctions().'
+ );
+ $this->assertFalse(
+ function_exists('fixture_before_function'),
+ 'The function should have been undefined by restoreFunctions().'
+ );
+ }
+
/**
* @test
* @group GlobalVariables
*/
- public function it_should_permit_global_variables_to_be_set_in_fixtures_method()
+ public function it_should_permit_global_variables_to_be_set_in_fixtures()
{
$this->assertTrue(
isset($GLOBALS['FIXTURE_SETUP_GLOBAL']),
diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php
new file mode 100644
index 0000000..3aac327
--- /dev/null
+++ b/tests/FunctionsTest.php
@@ -0,0 +1,212 @@
+markTestSkipped('This test depends on runkit being available.');
+ }
+ }
+
+ /**
+ * @test
+ * @testdox defineFunction() should be able to define a new function
+ */
+ public function defineFunction_should_be_able_to_define_a_new_function()
+ {
+ $this->assertFalse(function_exists('my_custom_function'));
+
+ $this->defineFunction('my_custom_function', function ($return) {
+ return $return;
+ });
+
+ $this->assertSame(123, my_custom_function(123));
+
+ $this->restoreFunctions();
+ $this->assertFalse(function_exists('my_custom_function'), 'The new function should have been undefined.');
+ }
+
+ /**
+ * @test
+ * @testdox defineFunction() should throw a warning if the function already exists
+ */
+ public function defineFunction_should_throw_a_warning_if_the_function_already_exists()
+ {
+ $this->assertTrue(function_exists('Tests\\Stubs\\sum_three_numbers'));
+ $signature = (string) (new \ReflectionFunction('Tests\\Stubs\\sum_three_numbers'));
+
+ $this->expectException(FunctionExistsException::class);
+ $this->defineFunction('Tests\\Stubs\\sum_three_numbers', function ($return) {
+ return $return;
+ });
+
+ $this->assertSame(
+ $signature,
+ (string) (new \ReflectionFunction('Tests\\Stubs\\sum_three_numbers')),
+ 'The original function should have been left untouched.'
+ );
+ }
+
+ /**
+ * @test
+ * @testdox redefineFunction() should be able to redefine an existing function
+ */
+ public function redefineFunction_should_be_able_to_redefine_existing_functions()
+ {
+ $this->assertTrue(function_exists('Tests\\Stubs\\sum_three_numbers'));
+ $signature = (string) (new \ReflectionFunction('Tests\\Stubs\\sum_three_numbers'));
+
+ $this->redefineFunction('Tests\\Stubs\\sum_three_numbers', function () {
+ return 123;
+ });
+
+ $this->assertSame(123, sum_three_numbers(1, 2, 3));
+
+ $this->restoreFunctions();
+ $this->assertTrue(function_exists('Tests\\Stubs\\sum_three_numbers'));
+ $this->assertSame(
+ $signature,
+ (string) (new \ReflectionFunction('Tests\\Stubs\\sum_three_numbers')),
+ 'The original function definition should have been restored.'
+ );
+ }
+
+ /**
+ * @test
+ * @testdox redefineFunction() should be able to redefine an existing function
+ */
+ public function redefineFunction_should_be_able_to_redefine_newly_defined_functions()
+ {
+ $this->defineFunction('my_test_function', function () {
+ return 'abc';
+ });
+ $this->redefineFunction('my_test_function', function () {
+ return 'xyz';
+ });
+
+ $this->assertSame('xyz', my_test_function());
+
+ $this->restoreFunctions();
+ $this->assertFalse(
+ function_exists('my_test_function'),
+ 'The newly-created function should still be removed.'
+ );
+ }
+
+ /**
+ * @test
+ * @testdox redefineFunction() should be able to redefine an existing function multiple times
+ * @depends redefineFunction_should_be_able_to_redefine_existing_functions
+ */
+ public function redefineFunction_should_be_able_to_redefine_existing_functions_multiple_times()
+ {
+ $this->assertTrue(function_exists('Tests\\Stubs\\sum_three_numbers'));
+ $signature = (string) (new \ReflectionFunction('Tests\\Stubs\\sum_three_numbers'));
+
+ $this->redefineFunction('Tests\\Stubs\\sum_three_numbers', function () {
+ return 'first';
+ });
+ $this->redefineFunction('Tests\\Stubs\\sum_three_numbers', function () {
+ return 'second';
+ });
+ $this->redefineFunction('Tests\\Stubs\\sum_three_numbers', function () {
+ return 'third';
+ });
+
+ $this->assertSame(
+ 'third',
+ sum_three_numbers(1, 2, 3),
+ 'Expected the latest re-definition to be used.'
+ );
+
+ $this->restoreFunctions();
+ $this->assertSame(
+ $signature,
+ (string) (new \ReflectionFunction('Tests\\Stubs\\sum_three_numbers')),
+ 'The original function definition should have been restored.'
+ );
+ }
+
+ /**
+ * @test
+ * @testdox redefineFunction() should define functions if they do not exist
+ * @depends defineFunction_should_be_able_to_define_a_new_function
+ */
+ public function redefineFunction_should_define_functions_if_they_do_not_exist()
+ {
+ $this->assertFalse(function_exists('my_custom_function'));
+
+ $this->redefineFunction('my_custom_function', function ($return) {
+ return $return;
+ });
+
+ $this->assertSame(123, my_custom_function(123));
+
+ $this->restoreFunctions();
+ $this->assertFalse(function_exists('my_custom_function'), 'The new function should have been undefined.');
+ }
+
+ /**
+ * @test
+ * @testdox deleteFunction() should be able to delete functions
+ */
+ public function deleteFunction_should_be_able_to_delete_functions()
+ {
+ $this->assertTrue(
+ function_exists('Tests\\Stubs\\sum_three_numbers'),
+ 'Test is predicated on this function existing.'
+ );
+
+ $this->deleteFunction('Tests\\Stubs\\sum_three_numbers');
+ $this->assertFalse(
+ function_exists('Tests\\Stubs\\sum_three_numbers'),
+ 'The function should have been deleted.'
+ );
+
+ $this->restoreFunctions();
+ $this->assertTrue(
+ function_exists('Tests\\Stubs\\sum_three_numbers'),
+ 'The function should have been restored.'
+ );
+ }
+
+ /**
+ * @test
+ * @testdox deleteFunction() should do nothing if the function does not exist
+ */
+ public function deleteFunction_should_do_nothing_if_the_function_does_not_exist()
+ {
+ $this->assertFalse(
+ function_exists('Tests\\Stubs\\sum_three_numbers_again'),
+ 'Test is predicated on this function NOT existing.'
+ );
+
+ $this->deleteFunction('Tests\\Stubs\\sum_three_numbers_again');
+ $this->assertFalse(
+ function_exists('Tests\\Stubs\\sum_three_numbers_again'),
+ 'Deleting a non-existent function should not do anything.'
+ );
+
+ $this->restoreFunctions();
+ $this->assertFalse(
+ function_exists('Tests\\Stubs\\sum_three_numbers_again'),
+ 'Nothing should be restored as there was nothing to begin with.'
+ );
+ }
+}
diff --git a/tests/Support/RunkitTest.php b/tests/Support/RunkitTest.php
new file mode 100644
index 0000000..b9474cf
--- /dev/null
+++ b/tests/Support/RunkitTest.php
@@ -0,0 +1,59 @@
+expectException(\BadFunctionCallException::class);
+
+ Runkit::a_function_that_does_not_exist();
+ }
+
+ /**
+ * @test
+ */
+ public function getNamespace_should_return_the_same_value_on_subsequent_calls()
+ {
+ $namespace = Runkit::getNamespace();
+
+ $this->assertSame(Runkit::getNamespace(), $namespace);
+ }
+
+ /**
+ * @test
+ */
+ public function makeNamespaced_should_return_the_given_reference_with_a_prefixed_namespace()
+ {
+ $namespace = Runkit::getNamespace();
+
+ $this->assertSame(
+ $namespace . 'some_global_function',
+ Runkit::makeNamespaced('some_global_function'),
+ 'The global namespace should be eligible.'
+ );
+ $this->assertSame(
+ $namespace . 'Some\\Namespaced\\function_to_move',
+ Runkit::makeNamespaced('Some\\Namespaced\\function_to_move'),
+ 'Namespaces should be preserved.'
+ );
+ $this->assertSame(
+ $namespace . 'Some\\Namespaced\\function_with_leading_slashes',
+ Runkit::makeNamespaced('\\Some\\Namespaced\\function_with_leading_slashes'),
+ 'Leading slashes should be stripped.'
+ );
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 17e0e30..be7fcb5 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -5,6 +5,7 @@
use PHPUnit\Framework\TestCase as BaseTestCase;
use AssertWell\PHPUnitGlobalState\Constants;
use AssertWell\PHPUnitGlobalState\EnvironmentVariables;
+use AssertWell\PHPUnitGlobalState\Functions;
use AssertWell\PHPUnitGlobalState\GlobalVariables;
/**
@@ -18,5 +19,6 @@ abstract class TestCase extends BaseTestCase
{
use Constants;
use EnvironmentVariables;
+ use Functions;
use GlobalVariables;
}
diff --git a/tests/stubs/functions.php b/tests/stubs/functions.php
new file mode 100644
index 0000000..a735ce6
--- /dev/null
+++ b/tests/stubs/functions.php
@@ -0,0 +1,21 @@
+