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 @@ +