Skip to content

Commit

Permalink
Define a Methods trait
Browse files Browse the repository at this point in the history
This trait exposes three methods:

1. `defineMethod(string $class, string $name, \Closure $closure, string $visibility = 'public', bool $static = false): self`
2. `redefineMethod(string $class, string $name, ?\Closure $closure, ?string $visibility = null, ?bool $static = null): self`
3. `deleteMethod(string $class, string $name): self`

Refs #12.
  • Loading branch information
stevegrunwell committed Nov 24, 2020
1 parent fd0476c commit 39959e1
Show file tree
Hide file tree
Showing 9 changed files with 881 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ The library offers a number of traits, based on the type of global state that mi
* [Environment Variables](docs/EnvironmentVariables.md)
* [Functions](docs/Functions.md) (requires [Runkit7])
* [Global Variables](docs/GlobalVariables.md)
* [Methods](docs/Methods.md) (requires[Runkit7])


## Contributing
Expand Down
100 changes: 100 additions & 0 deletions docs/Methods.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Managing Methods

When writing tests, we often make use of [test doubles](https://en.wikipedia.org/wiki/Test_double) to better control how our code will behave. For instance, we don't want to make calls to remote APIs every time we run our tests, as these dependencies can make our tests fragile and slow.

If your software is written using proper [Dependency Injection](https://phptherightway.com/#dependency_injection), it's usually pretty easy to [create test doubles with PHPUnit](https://jmauerhan.wordpress.com/2018/10/04/the-5-types-of-test-doubles-and-how-to-create-them-in-phpunit/) and inject them into the objects we create in our tests.

What happens when the software we're working with isn't coded so nicely, though?

Most of the time, we can get around this using [Reflection](https://www.php.net/intro.reflection), but _sometimes_ we need a sledgehammer to break through. That's where the `AssertWell\PHPUnitGlobalState\Methods` trait (powered by [PHP's runkit7 extension](Runkit.md)) comes in handy.


## 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.

---

### defineMethod()

Define a new method for the duration of the test.

`defineMethod(string $class, string $name, \Closure $closure, string $visibility = 'public', bool $static = false): self`

This is a wrapper around [PHP's `runkit7_method_define()` function](https://www.php.net/manual/en/function.runkit7-method-define.php).

#### Parameters

<dl>
<dt>$class</dt>
<dd>The class name.</dd>
<dt>$name</dt>
<dd>The method name.</dd>
<dt>$closure</dt>
<dd>The code for the method.</dd>
<dt>$visibility</dt>
<dd>Optional. The method visibility, one of "public", "protected", or "private".</dd>
<dt>$static</dt>
<dd>Optional. Whether or not the method should be static. Default is false.</dd>
</dl>

#### Return values

This method will return the calling class, enabling multiple methods to be chained.

An `AssertWell\PHPUnitGlobalState\Exceptions\MethodExistsException` exception will be thrown if the given `$method` already exists. An `AssertWell\PHPUnitGlobalState\Exceptions\RunkitException` will be thrown if the given method cannot be defined.

---

### redefineMethod()

Redefine an existing method for the duration of the test. If `$name` does not exist, it will be defined.

`redefineMethod(string $class, string $name, ?\Closure $closure, ?string $visibility = null, ?bool $static = null): self`

This is a wrapper around [PHP's `runkit7_method_redefine()` function](https://www.php.net/manual/en/function.runkit7-method-redefine.php).

#### Parameters

<dl>
<dt>$class</dt>
<dd>The class name.</dd>
<dt>$name</dt>
<dd>The method name.</dd>
<dt>$closure</dt>
<dd>The new code for the method.</dd>
<dd>If <code>null</code> is passed, the existing method body will be copied.</dd>
<dt>$visibility</dt>
<dd>Optional. The method visibility, one of "public", "protected", or "private".</dd>
<dd>If <code>null</code> is passed, the existing visibility will be preserved.</dd>
<dt>$static</dt>
<dd>Optional. Whether or not the method should be static. Default is false.</dd>
<dd>If <code>null</code> is passed, the existing state will be used.</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 method cannot be (re)defined.

---

### deleteMethod()

Delete/undefine a method for the duration of the single test.

`deleteMethod(string $class, string $name): self`

#### Parameters

<dl>
<dt>$class</dt>
<dd>The class name.</dd>
<dt>$name</dt>
<dd>The method name.</dd>
</dl>

#### Return values

This method will return the calling class, enabling multiple methods to be chained.
8 changes: 8 additions & 0 deletions src/Exceptions/MethodExistsException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace AssertWell\PHPUnitGlobalState\Exceptions;

class MethodExistsException extends FunctionExistsException
{

}
213 changes: 213 additions & 0 deletions src/Methods.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<?php

namespace AssertWell\PHPUnitGlobalState;

use AssertWell\PHPUnitGlobalState\Exceptions\MethodExistsException;
use AssertWell\PHPUnitGlobalState\Exceptions\RunkitException;
use AssertWell\PHPUnitGlobalState\Support\Runkit;

trait Methods
{
/**
* All methods being handled by this trait.
*
* @var array[]
*/
private $methods = [
'defined' => [],
'redefined' => [],
];

/**
* @after
*
* @return void
*/
protected function restoreMethods()
{
// Reset anything that was modified.
array_walk($this->methods['redefined'], function ($methods, $class) {
foreach ($methods as $modified => $original) {
if (method_exists($class, $modified)) {
Runkit::method_remove($class, $modified);
}

// Put the original back into place.
Runkit::method_rename($class, $original, $modified);
}

unset($this->methods['redefined'][$class]);
});

array_walk($this->methods['defined'], function ($methods, $class) {
foreach ($methods as $method) {
Runkit::method_remove($class, $method);
}
unset($this->methods['defined'][$class]);
});

Runkit::reset();
}

/**
* Define a new method.
*
* @throws \AssertWell\PHPUnitGlobalState\Exceptions\MethodExistsException
* @throws \AssertWell\PHPUnitGlobalState\Exceptions\RunkitException
*
* @param string $class The class name.
* @param string $name The method name.
* @param \Closure $closure The method body.
* @param string $visibility Optional. The method visibility, one of "public", "protected",
* or "private". Default is "public".
* @param bool $static Optional. Whether or not the method should be defined as static.
* Default is false.
*
* @return self
*/
protected function defineMethod($class, $name, \Closure $closure, $visibility = 'public', $static = false)
{
if (method_exists($class, $name)) {
throw new MethodExistsException(sprintf(
'Method %1$s::%2$s() already exists. You may redefine it using %3$s::redefineMethod() instead.',
$class,
$name,
get_class($this)
));
}

if (! Runkit::isAvailable()) {
$this->markTestSkipped('defineMethod() requires Runkit be available, skipping.');
}

$flags = Runkit::getVisibilityFlags($visibility, $static);

if (! Runkit::method_add($class, $name, $closure, $flags)) {
throw new RunkitException(sprintf('Unable to define method %1$s::%2$s().', $class, $name));
}

if (! isset($this->methods['defined'][$class])) {
$this->methods['defined'][$class] = [];
}
$this->methods['defined'][$class][] = $name;

return $this;
}

/**
* Redefine an existing method.
*
* If the method doesn't yet exist, it will be defined.
*
* @param string $class The class name.
* @param string $name The method name.
* @param \Closure|null $closure Optional. A closure representing the method body. If null,
* the method body will not be replaced. Default is null.
* @param string $visibility Optional. The method visibility, one of "public",
* "protected", or "private". Default is the same as the
* current value.
* @param bool $static Optional. Whether or not the method should be defined as
* static. Default is the same is as the current value.
*
* @return self
*/
protected function redefineMethod($class, $name, $closure = null, $visibility = null, $static = null)
{
if (! method_exists($class, $name)) {
if (! $closure instanceof \Closure) {
throw new RunkitException(
sprintf('New method %1$s::$2$s() cannot have an empty body.', $class, $name)
);
}

return $this->defineMethod($class, $name, $closure, $visibility, $static);
}

if (! Runkit::isAvailable()) {
$this->markTestSkipped('redefineMethod() requires Runkit be available, skipping.');
}

$method = new \ReflectionMethod($class, $name);

if (null === $visibility) {
if ($method->isPrivate()) {
$visibility = 'private';
} elseif ($method->isProtected()) {
$visibility = 'protected';
} else {
$visibility = 'public';
}
}

if (null === $static) {
$static = $method->isStatic();
}

$flags = Runkit::getVisibilityFlags($visibility, $static);

// If $closure is null, copy the existing method body.
if (null === $closure) {
$closure = $method->isStatic()
? $method->getClosure()
: $method->getClosure($this->getMockBuilder($class)
->disableOriginalConstructor()
->getMock());
}

// Back up the original version of the method.
if (! isset($this->methods['redefined'][$class][$name])) {
$prefixed = Runkit::makePrefixed($name);

if (! Runkit::method_rename($class, $name, $prefixed)) {
throw new RunkitException(
sprintf('Unable to back up %1$s::%2$s(), aborting.', $class, $name)
);
}

if (! isset($this->methods['redefined'][$class])) {
$this->methods['redefined'][$class] = [];
}
$this->methods['redefined'][$class][$name] = $prefixed;

if (! Runkit::method_add($class, $name, $closure, $flags)) {
throw new RunkitException(
sprintf('Unable to redefine function %1$s::%2$s().', $method, $name)
);
}
} else {
Runkit::method_redefine($class, $name, $closure, $flags);
}

return $this;
}

/**
* Delete an existing method.
*
* @param string $class The class name.
* @param string $name The method to be deleted.
*
* @return self
*/
protected function deleteMethod($class, $name)
{
if (! method_exists($class, $name)) {
return $this;
}

$prefixed = Runkit::makePrefixed($name);

if (! Runkit::method_rename($class, $name, $prefixed)) {
throw new RunkitException(
sprintf('Unable to back up %1$s::%2$s(), aborting.', $class, $name)
);
}

if (! isset($this->methods['redefined'][$class])) {
$this->methods['redefined'][$class] = [];
}
$this->methods['redefined'][$class][$name] = $prefixed;

return $this;
}
}
Loading

0 comments on commit 39959e1

Please sign in to comment.