Skip to content

Commit 39959e1

Browse files
committed
Define a Methods trait
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.
1 parent fd0476c commit 39959e1

File tree

9 files changed

+881
-1
lines changed

9 files changed

+881
-1
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ The library offers a number of traits, based on the type of global state that mi
6161
* [Environment Variables](docs/EnvironmentVariables.md)
6262
* [Functions](docs/Functions.md) (requires [Runkit7])
6363
* [Global Variables](docs/GlobalVariables.md)
64+
* [Methods](docs/Methods.md) (requires[Runkit7])
6465

6566

6667
## Contributing

docs/Methods.md

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Managing Methods
2+
3+
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.
4+
5+
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.
6+
7+
What happens when the software we're working with isn't coded so nicely, though?
8+
9+
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.
10+
11+
12+
## Methods
13+
14+
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.
15+
16+
---
17+
18+
### defineMethod()
19+
20+
Define a new method for the duration of the test.
21+
22+
`defineMethod(string $class, string $name, \Closure $closure, string $visibility = 'public', bool $static = false): self`
23+
24+
This is a wrapper around [PHP's `runkit7_method_define()` function](https://www.php.net/manual/en/function.runkit7-method-define.php).
25+
26+
#### Parameters
27+
28+
<dl>
29+
<dt>$class</dt>
30+
<dd>The class name.</dd>
31+
<dt>$name</dt>
32+
<dd>The method name.</dd>
33+
<dt>$closure</dt>
34+
<dd>The code for the method.</dd>
35+
<dt>$visibility</dt>
36+
<dd>Optional. The method visibility, one of "public", "protected", or "private".</dd>
37+
<dt>$static</dt>
38+
<dd>Optional. Whether or not the method should be static. Default is false.</dd>
39+
</dl>
40+
41+
#### Return values
42+
43+
This method will return the calling class, enabling multiple methods to be chained.
44+
45+
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.
46+
47+
---
48+
49+
### redefineMethod()
50+
51+
Redefine an existing method for the duration of the test. If `$name` does not exist, it will be defined.
52+
53+
`redefineMethod(string $class, string $name, ?\Closure $closure, ?string $visibility = null, ?bool $static = null): self`
54+
55+
This is a wrapper around [PHP's `runkit7_method_redefine()` function](https://www.php.net/manual/en/function.runkit7-method-redefine.php).
56+
57+
#### Parameters
58+
59+
<dl>
60+
<dt>$class</dt>
61+
<dd>The class name.</dd>
62+
<dt>$name</dt>
63+
<dd>The method name.</dd>
64+
<dt>$closure</dt>
65+
<dd>The new code for the method.</dd>
66+
<dd>If <code>null</code> is passed, the existing method body will be copied.</dd>
67+
<dt>$visibility</dt>
68+
<dd>Optional. The method visibility, one of "public", "protected", or "private".</dd>
69+
<dd>If <code>null</code> is passed, the existing visibility will be preserved.</dd>
70+
<dt>$static</dt>
71+
<dd>Optional. Whether or not the method should be static. Default is false.</dd>
72+
<dd>If <code>null</code> is passed, the existing state will be used.</dd>
73+
</dl>
74+
75+
#### Return values
76+
77+
This method will return the calling class, enabling multiple methods to be chained.
78+
79+
An `AssertWell\PHPUnitGlobalState\Exceptions\RunkitException` will be thrown if the given method cannot be (re)defined.
80+
81+
---
82+
83+
### deleteMethod()
84+
85+
Delete/undefine a method for the duration of the single test.
86+
87+
`deleteMethod(string $class, string $name): self`
88+
89+
#### Parameters
90+
91+
<dl>
92+
<dt>$class</dt>
93+
<dd>The class name.</dd>
94+
<dt>$name</dt>
95+
<dd>The method name.</dd>
96+
</dl>
97+
98+
#### Return values
99+
100+
This method will return the calling class, enabling multiple methods to be chained.
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace AssertWell\PHPUnitGlobalState\Exceptions;
4+
5+
class MethodExistsException extends FunctionExistsException
6+
{
7+
8+
}

src/Methods.php

+213
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
<?php
2+
3+
namespace AssertWell\PHPUnitGlobalState;
4+
5+
use AssertWell\PHPUnitGlobalState\Exceptions\MethodExistsException;
6+
use AssertWell\PHPUnitGlobalState\Exceptions\RunkitException;
7+
use AssertWell\PHPUnitGlobalState\Support\Runkit;
8+
9+
trait Methods
10+
{
11+
/**
12+
* All methods being handled by this trait.
13+
*
14+
* @var array[]
15+
*/
16+
private $methods = [
17+
'defined' => [],
18+
'redefined' => [],
19+
];
20+
21+
/**
22+
* @after
23+
*
24+
* @return void
25+
*/
26+
protected function restoreMethods()
27+
{
28+
// Reset anything that was modified.
29+
array_walk($this->methods['redefined'], function ($methods, $class) {
30+
foreach ($methods as $modified => $original) {
31+
if (method_exists($class, $modified)) {
32+
Runkit::method_remove($class, $modified);
33+
}
34+
35+
// Put the original back into place.
36+
Runkit::method_rename($class, $original, $modified);
37+
}
38+
39+
unset($this->methods['redefined'][$class]);
40+
});
41+
42+
array_walk($this->methods['defined'], function ($methods, $class) {
43+
foreach ($methods as $method) {
44+
Runkit::method_remove($class, $method);
45+
}
46+
unset($this->methods['defined'][$class]);
47+
});
48+
49+
Runkit::reset();
50+
}
51+
52+
/**
53+
* Define a new method.
54+
*
55+
* @throws \AssertWell\PHPUnitGlobalState\Exceptions\MethodExistsException
56+
* @throws \AssertWell\PHPUnitGlobalState\Exceptions\RunkitException
57+
*
58+
* @param string $class The class name.
59+
* @param string $name The method name.
60+
* @param \Closure $closure The method body.
61+
* @param string $visibility Optional. The method visibility, one of "public", "protected",
62+
* or "private". Default is "public".
63+
* @param bool $static Optional. Whether or not the method should be defined as static.
64+
* Default is false.
65+
*
66+
* @return self
67+
*/
68+
protected function defineMethod($class, $name, \Closure $closure, $visibility = 'public', $static = false)
69+
{
70+
if (method_exists($class, $name)) {
71+
throw new MethodExistsException(sprintf(
72+
'Method %1$s::%2$s() already exists. You may redefine it using %3$s::redefineMethod() instead.',
73+
$class,
74+
$name,
75+
get_class($this)
76+
));
77+
}
78+
79+
if (! Runkit::isAvailable()) {
80+
$this->markTestSkipped('defineMethod() requires Runkit be available, skipping.');
81+
}
82+
83+
$flags = Runkit::getVisibilityFlags($visibility, $static);
84+
85+
if (! Runkit::method_add($class, $name, $closure, $flags)) {
86+
throw new RunkitException(sprintf('Unable to define method %1$s::%2$s().', $class, $name));
87+
}
88+
89+
if (! isset($this->methods['defined'][$class])) {
90+
$this->methods['defined'][$class] = [];
91+
}
92+
$this->methods['defined'][$class][] = $name;
93+
94+
return $this;
95+
}
96+
97+
/**
98+
* Redefine an existing method.
99+
*
100+
* If the method doesn't yet exist, it will be defined.
101+
*
102+
* @param string $class The class name.
103+
* @param string $name The method name.
104+
* @param \Closure|null $closure Optional. A closure representing the method body. If null,
105+
* the method body will not be replaced. Default is null.
106+
* @param string $visibility Optional. The method visibility, one of "public",
107+
* "protected", or "private". Default is the same as the
108+
* current value.
109+
* @param bool $static Optional. Whether or not the method should be defined as
110+
* static. Default is the same is as the current value.
111+
*
112+
* @return self
113+
*/
114+
protected function redefineMethod($class, $name, $closure = null, $visibility = null, $static = null)
115+
{
116+
if (! method_exists($class, $name)) {
117+
if (! $closure instanceof \Closure) {
118+
throw new RunkitException(
119+
sprintf('New method %1$s::$2$s() cannot have an empty body.', $class, $name)
120+
);
121+
}
122+
123+
return $this->defineMethod($class, $name, $closure, $visibility, $static);
124+
}
125+
126+
if (! Runkit::isAvailable()) {
127+
$this->markTestSkipped('redefineMethod() requires Runkit be available, skipping.');
128+
}
129+
130+
$method = new \ReflectionMethod($class, $name);
131+
132+
if (null === $visibility) {
133+
if ($method->isPrivate()) {
134+
$visibility = 'private';
135+
} elseif ($method->isProtected()) {
136+
$visibility = 'protected';
137+
} else {
138+
$visibility = 'public';
139+
}
140+
}
141+
142+
if (null === $static) {
143+
$static = $method->isStatic();
144+
}
145+
146+
$flags = Runkit::getVisibilityFlags($visibility, $static);
147+
148+
// If $closure is null, copy the existing method body.
149+
if (null === $closure) {
150+
$closure = $method->isStatic()
151+
? $method->getClosure()
152+
: $method->getClosure($this->getMockBuilder($class)
153+
->disableOriginalConstructor()
154+
->getMock());
155+
}
156+
157+
// Back up the original version of the method.
158+
if (! isset($this->methods['redefined'][$class][$name])) {
159+
$prefixed = Runkit::makePrefixed($name);
160+
161+
if (! Runkit::method_rename($class, $name, $prefixed)) {
162+
throw new RunkitException(
163+
sprintf('Unable to back up %1$s::%2$s(), aborting.', $class, $name)
164+
);
165+
}
166+
167+
if (! isset($this->methods['redefined'][$class])) {
168+
$this->methods['redefined'][$class] = [];
169+
}
170+
$this->methods['redefined'][$class][$name] = $prefixed;
171+
172+
if (! Runkit::method_add($class, $name, $closure, $flags)) {
173+
throw new RunkitException(
174+
sprintf('Unable to redefine function %1$s::%2$s().', $method, $name)
175+
);
176+
}
177+
} else {
178+
Runkit::method_redefine($class, $name, $closure, $flags);
179+
}
180+
181+
return $this;
182+
}
183+
184+
/**
185+
* Delete an existing method.
186+
*
187+
* @param string $class The class name.
188+
* @param string $name The method to be deleted.
189+
*
190+
* @return self
191+
*/
192+
protected function deleteMethod($class, $name)
193+
{
194+
if (! method_exists($class, $name)) {
195+
return $this;
196+
}
197+
198+
$prefixed = Runkit::makePrefixed($name);
199+
200+
if (! Runkit::method_rename($class, $name, $prefixed)) {
201+
throw new RunkitException(
202+
sprintf('Unable to back up %1$s::%2$s(), aborting.', $class, $name)
203+
);
204+
}
205+
206+
if (! isset($this->methods['redefined'][$class])) {
207+
$this->methods['redefined'][$class] = [];
208+
}
209+
$this->methods['redefined'][$class][$name] = $prefixed;
210+
211+
return $this;
212+
}
213+
}

0 commit comments

Comments
 (0)