-
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
The `AssertWell\PHPUnitGlobalState\Functions` trait exposes three methods for dealing with functions: 1. `defineFunction(string $name, \Closure $func): self` 2. `redefineFunction(string $name, \Closure $func): self` 3. `deleteFunction(string $name): self` This commit also adds additional documentation around runkit(7), as it's now used by both the `Constants` trait and `Functions`. Fixes #11.
- Loading branch information
1 parent
e8a9571
commit b38ecb3
Showing
18 changed files
with
771 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
|
||
<dl> | ||
<dt>$name</dt> | ||
<dd>The function name.</dd> | ||
<dt>$closure</dt> | ||
<dd>The code for the function.</dd> | ||
</dl> | ||
|
||
#### 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 | ||
|
||
<dl> | ||
<dt>$name</dt> | ||
<dd>The function name.</dd> | ||
<dt>$closure</dt> | ||
<dd>The new code for the function.</dd> | ||
</dl> | ||
|
||
#### 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 | ||
|
||
<dl> | ||
<dt>$name</dt> | ||
<dd>The function name.</dd> | ||
</dl> | ||
|
||
#### 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()); | ||
} | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'); | ||
``` |
Oops, something went wrong.