Skip to content

Commit

Permalink
Define the Functions trait
Browse files Browse the repository at this point in the history
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
stevegrunwell committed Oct 31, 2020
1 parent e8a9571 commit b38ecb3
Show file tree
Hide file tree
Showing 18 changed files with 771 additions and 26 deletions.
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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"
```
Expand All @@ -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
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"files": [
"tests/Support/functions.php"
]
},
"config": {
"preferred-install": "dist",
Expand Down
39 changes: 30 additions & 9 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 1 addition & 8 deletions docs/Constants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <abbr title="User Interface">UI</abbr>.

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

Expand Down
158 changes: 158 additions & 0 deletions docs/Functions.md
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());
}
}
```
60 changes: 60 additions & 0 deletions docs/Runkit.md
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&hellip; probably shouldn't have been doing anyway&hellip; 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&hellip;

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');
```
Loading

0 comments on commit b38ecb3

Please sign in to comment.