Do you prefer composition over inheritance? Yes, that's great. Why aren't your classes final
then? Oh, you have tests and you mock your classes. But why is that a problem?
Since I started using final
first I got rid of many problems. Most programmers I meet already know about the benefits of not having 6 classes extended in a row and that final
remove this issue.
But many of those programmers are skilled and they write tests.
...so it returns 20
on getNumber()
instead:
<?php
final class FinalClass
{
public function getNumber(): int
{
return 10;
}
}
We have few options out in the wild:
or...
<?php
-final class FinalClass
+final class FinalClass implements FinalClassInterface
{
public function getNumber(): int
{
return 10;
}
}
+
+interface FinalClassInterface
+{
+ public function getNumber(): int;
+}
Then use the interface instead of the class in your test:
<?php
use PHPUnit\Framework\TestCase;
final class FinalClassTest extends TestCase
{
public function testSuccess(): void
{
- $finalClassMock = $this->createMock(FinalClass::class);
+ $finalClassMock = $this->createMock(FinalClassInterface::class);
// ... it works! but at what cost...
}
}
This will work, but creates huge debt you'll have to pay later (usually at a time you would rather skip):
public
method in the class, you have to update the interfaceThis is obviously annoying maintenance and it will lead you to one of 2 bad paths:
final
at all❌
Nette packages also missed final
in the code, so people could mock it. Until David came with Bypass Finals package. Some people think it's only for Nette\Tester, but I happily use it in PHPUnit universe as well.
We just install it:
composer require dg/bypass-finals --dev
And enable:
DG\BypassFinals::enable();
✅
T_FINAL
token.
Hm, where should be put it?
bootstrap.php
File?require_once __DIR__ . '/../vendor/autoload.php';
DG\BypassFinals::enable();
Update path in phpunit.xml
:
<phpunit
- bootstrap="vendor/autoload.php"
+ bootstrap="tests/bootstrap.php"
>
Let's run the tests:
vendor/bin/phpunit
...
OK (3 tests, 3 assertions)
Hm, mocks are worked, and let's try another approach.
setUp()
Method?Let's put it into setUp()
method. It seems like a good idea for these operations:
<?php
+use DG\BypassFinals;
use PHPUnit\Framework\TestCase;
final class FinalClassTest extends TestCase
{
+ protected function setUp(): void
+ {
+ BypassFinals::enable();
+ }
public function testFailInside(): void
{
$this->createMock(FinalClass::class);
}
}
And run tests again:
vendor/bin/phpunit
...
OK (3 tests, 3 assertions)
We're getting there, but there are still mocks in the setUp()
method, and we've also added work to our future self - for every new test case, we have to remember to add BypassFinals::enable();
manually.
❌
Why it doesn't work. I was angry and frustrated. Honestly, I wanted to give up now and just pick "interface everything" or "final nothing" quick solution. I think that resolutions in emotions are not a good idea... so I take a deep breath, pause and go to a toilet to get some fresh air.
Suddenly... I remember that... PHPUnit has some Listeners, right? What if we could use that?
Let's try all the methods of TestListener
, enable bypass in each of them by trial-error and see what happens:
<?php declare(strict_types=1);
use DG\BypassFinals;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestListener;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Framework\Warning;
final class BypassFinalListener implements TestListener
{
public function addError(Test $test, \Throwable $t, float $time): void
{
}
public function addWarning(Test $test, Warning $e, float $time): void
{
}
public function addFailure(Test $test, AssertionFailedError $e, float $time): void
{
}
public function addIncompleteTest(Test $test, \Throwable $t, float $time): void
{
}
public function addRiskyTest(Test $test, \Throwable $t, float $time): void
{
}
public function addSkippedTest(Test $test, \Throwable $t, float $time): void
{
}
public function startTestSuite(TestSuite $suite): void
{
}
public function endTestSuite(TestSuite $suite): void
{
}
public function startTest(Test $test): void
{
BypassFinals::enable();
}
public function endTest(Test $test, float $time): void
{
}
}
In the end, it was just one method.
Then register listener it in phpunit.xml
:
<phpunit bootstrap="vendor/autoload.php">
<listeners>
<listener class="Listener\BypassFinalListener"/>
</listeners>
</phpunit>
And run tests again:
vendor/bin/phpunit
...
Success!
Great! All our objects can be final and tests can mock them.
Is it a good enough solution? Yes, it works and it's a single place of origin - use it, close this post and your code will thank you in 2 years later.
✅
Are you a curious hacker that is never satisfied with his or her solution? Let's take it one step further.
What do you think about the Listener class? There is 10+ methods and only one is used. It's very hard to read. To add more fire to the fuel, TestListener
class is deprecated since PHPUnit 8 and will be removed in PHPUnit 9. Don't worry, Rector already covers the migration path.
After bit of Googling on PHPUnit Github and documentation I found something called hooks!
You can read about them in the PHPUnit documentation, but in short: they're the same as the listener, just with 1 event.
<?php declare(strict_types=1);
use DG\BypassFinals;
use PHPUnit\Runner\BeforeTestHook;
final class BypassFinalHook implements BeforeTestHook
{
public function executeBeforeTest(string $test): void
{
BypassFinals::enable();
}
}
And again, register it in phpunit.xml
:
<phpunit bootstrap="vendor/autoload.php">
<extensions>
<extension class="Hook\BypassFinalHook"/>
</extensions>
</phpunit>
The final test, run all tests:
vendor/bin/phpunit
...
Success!
✅ ✅ ✅
final
final
anything
Finally :)
Happy coding!
Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!