Skip to content

Commit b38ecb3

Browse files
committed
Define the Functions trait
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.
1 parent e8a9571 commit b38ecb3

File tree

18 files changed

+771
-26
lines changed

18 files changed

+771
-26
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class MyTestClass extends TestCase
3030

3131
### Introduction to Runkit
3232

33-
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").
33+
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").
3434

3535
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.
3636

@@ -46,7 +46,7 @@ var_dump(SOME_CONSTANT)
4646
#=> string(10) "some value"
4747

4848
// Now, re-define the constant.
49-
runkit_constant_redefine('SOME_CONSTANT', 'some other value');
49+
runkit7_constant_redefine('SOME_CONSTANT', 'some other value');
5050
var_dump(SOME_CONSTANT)
5151
#=> string(16) "some other value"
5252
```
@@ -57,11 +57,14 @@ Of course, we might want a constant's original value to be restored after our te
5757

5858
The library offers a number of traits, based on the type of global state that might need to be manipulated.
5959

60-
* [Constants](docs/Constants.md) (requires Runkit7)
60+
* [Constants](docs/Constants.md) (requires [Runkit7])
6161
* [Environment Variables](docs/EnvironmentVariables.md)
62+
* [Functions](docs/Functions.md) (requires [Runkit7])
6263
* [Global Variables](docs/GlobalVariables.md)
6364

6465

6566
## Contributing
6667

6768
If you're interested in contributing to the library, [please review our contributing guidelines](.github/CONTRIBUTING.md).
69+
70+
[Runkit7]: docs/Runkit.md

composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@
4242
"autoload-dev": {
4343
"psr-4": {
4444
"Tests\\": "tests/"
45-
}
45+
},
46+
"files": [
47+
"tests/Support/functions.php"
48+
]
4649
},
4750
"config": {
4851
"preferred-install": "dist",

composer.lock

Lines changed: 30 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/Constants.md

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,8 @@
22

33
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 <abbr title="User Interface">UI</abbr>.
44

5-
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.
5+
Normally, a constant cannot be redefined or removed once defined; however, [the runkit7 extension](Runkit.md) exposes functions to modify normally immutable constructs.
66

7-
If runkit functions are unavailable, the `Constants` trait will automatically skip tests that rely on this functionality.
8-
9-
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):
10-
11-
```sh
12-
$ sudo ./vendor/bin/install-runkit.sh
13-
```
147

158
## Methods
169

docs/Functions.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Managing Functions
2+
3+
When testing software, we often find ourselves making use of "stubs", which are objects that will return known values for given methods.
4+
5+
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.
6+
7+
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.
8+
9+
10+
## Methods
11+
12+
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.
13+
14+
---
15+
16+
### defineFunction()
17+
18+
Define a new function for the duration of the test.
19+
20+
`defineFunction(string $name, \Closure $closure): self`
21+
22+
This is a wrapper around [PHP's `runkit_function_define()` function](https://www.php.net/manual/en/function.runkit-function-define.php).
23+
24+
#### Parameters
25+
26+
<dl>
27+
<dt>$name</dt>
28+
<dd>The function name.</dd>
29+
<dt>$closure</dt>
30+
<dd>The code for the function.</dd>
31+
</dl>
32+
33+
#### Return values
34+
35+
This method will return the calling class, enabling multiple methods to be chained.
36+
37+
An `AssertWell\PHPUnitGlobalState\Exceptions\RunkitException` will be thrown if the given function cannot be defined.
38+
39+
---
40+
41+
### redefineFunction()
42+
43+
Redefine an existing function for the duration of the test. If `$name` does not exist, it will be defined.
44+
45+
`redefineFunction(string $name, \Closure $closure): self`
46+
47+
This is a wrapper around [PHP's `runkit_function_redefine()` function](https://www.php.net/manual/en/function.runkit-function-redefine.php).
48+
49+
#### Parameters
50+
51+
<dl>
52+
<dt>$name</dt>
53+
<dd>The function name.</dd>
54+
<dt>$closure</dt>
55+
<dd>The new code for the function.</dd>
56+
</dl>
57+
58+
#### Return values
59+
60+
This method will return the calling class, enabling multiple methods to be chained.
61+
62+
An `AssertWell\PHPUnitGlobalState\Exceptions\RunkitException` will be thrown if the given function cannot be defined.
63+
64+
---
65+
66+
### deleteFunction()
67+
68+
Delete/undefine a function for the duration of the single test.
69+
70+
`deleteFunction(string $name): self`
71+
72+
#### Parameters
73+
74+
<dl>
75+
<dt>$name</dt>
76+
<dd>The function name.</dd>
77+
</dl>
78+
79+
#### Return values
80+
81+
This method will return the calling class, enabling multiple methods to be chained.
82+
83+
84+
## Examples
85+
86+
### Replacing a function for a single test
87+
88+
Imagine that we have two functions: `get_posts()` and `make_api_request()`, which look something like this:
89+
90+
```php
91+
/**
92+
* Retrieve posts from the API and prepare it for templates.
93+
*
94+
* @return Post[] An array of Post objects.
95+
*/
96+
function get_posts()
97+
{
98+
try {
99+
$posts = make_api_request('/posts');
100+
} catch (ApiUnavailableException $e) {
101+
error_log($e->getMessage(), E_USER_WARNING);
102+
return [];
103+
}
104+
105+
return array_map([Post::class, 'factory'], $posts);
106+
}
107+
108+
/**
109+
* Send a request to the API.
110+
*
111+
* @param string $path The API path.
112+
* @param mixed[] $args Arguments to pass with the request.
113+
*
114+
* @return array[]
115+
*/
116+
function make_api_request($path, $args = [])
117+
{
118+
/*
119+
* A bunch of pre-check conditions, sanitization, merging with default
120+
* values, etc.
121+
*
122+
* Then we'll make the actual request, and finally check the results.
123+
*/
124+
if ($response_code >= 500) {
125+
throw new ApiUnavailableException('Received a 5xx error from the API.');
126+
}
127+
128+
// More logic before finally returning the response.
129+
}
130+
```
131+
132+
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`.
133+
134+
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.
135+
136+
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:
137+
138+
```php
139+
use AssertWell\PHPUnitGlobalState\Functions;
140+
use PHPUnit\Framework\TestCase;
141+
142+
class MyTestClass extends TestCase
143+
{
144+
use Functions;
145+
146+
/**
147+
* @test
148+
*/
149+
public function get_posts_should_return_an_empty_array_if_the_API_request_fails()
150+
{
151+
$this->redefineFunction('make_api_request', function () {
152+
throw new ApiUnavailableException('API is unavailable.');
153+
});
154+
155+
$this->assertEmpty(get_posts());
156+
}
157+
}
158+
```

docs/Runkit.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# PHP's runkit and runkit7 extensions
2+
3+
> For all those things you&hellip; probably shouldn't have been doing anyway&hellip; but surely do!
4+
5+
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.
6+
7+
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.
8+
9+
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.
10+
11+
12+
## You really shouldn't be using runkit&hellip;
13+
14+
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.
15+
16+
Generally speaking, **if you're using runkit in production code, you're probably approaching the problem in the wrong way.**
17+
18+
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.
19+
20+
Using runkit should probably never be your first approach, but in certain situations it's by-far the cleanest.
21+
22+
Remember: **with great power comes great responsibility!!**
23+
24+
25+
## Installation
26+
27+
Both runkit and runkit7 can be installed in your environment via [PECL](https://pecl.php.net/):
28+
29+
```sh
30+
# For PHP 5.x
31+
pecl install runkit
32+
33+
# For PHP 7.x
34+
pecl install runkit7
35+
```
36+
37+
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`.
38+
39+
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.
40+
41+
42+
## Using runkit and runkit7 in the same test suite
43+
44+
More recent versions of runkit7 have introduced `runkit7_`-prefixed functions, and their `runkit_` counterparts are aliased to the newer versions.
45+
46+
For example, `runkit_function_redefine()` is an alias for `runkit7_function_redefine()`.
47+
48+
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).
49+
50+
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:
51+
52+
```php
53+
use AssertWell\PHPUnitGlobalState\Support\Runkit;
54+
55+
/*
56+
* Use runkit7_constant_redefine() on PHP 7.x,
57+
* runkit_constant_redefine() for PHP 5.x.
58+
*/
59+
Runkit::constant_redefine('SOME_CONSTANT', 'some value');
60+
```

0 commit comments

Comments
 (0)