Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove final keyword from classes to allow mocking #159

Closed
manchuck opened this issue Aug 29, 2018 · 8 comments

Comments

Projects
None yet
3 participants
@manchuck
Copy link

commented Aug 29, 2018

Having all the classes marked as finial makes it difficult for creating test mocks in applications.

Example:

UpdateMasterCommand.php:


namespace My\Command;

use GitWrapper\GitWrapper;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class UpdateMasterCommand extends Command
{
    /**
     * @var GitWrapper
     */
    protected $gitWrapper = null;

    public function getGitWrapper()
    {
        if (null === $this->gitWrapper) {
            $this->setGitWrapper(new GitWrapper());
        }

        return $this->gitWrapper;
    }

    public function setGitWrapper($gitWrapper)
    {
        $this->gitWrapper = $gitWrapper;
    }

    public static function getDefaultName()
    {
        return 'update-master';
    }

    protected function configure()
    {
        $this->setName('update-master')
            ->setDescription('Pulls master')
            ->addArgument('source', InputArgument::REQUIRED, 'Path to process');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $gitWrapper = $this->getGitWrapper();
        $git = $gitWrapper->workingCopy($input->getArgument('source'));

        $output->writeln([
            '=============',
            'Pull Master',
            '',
        ]);

        $output->writeln('Checking out master');
        $git->checkout('master');

        $output->writeln('Pulling master');
        $git->pull();

        $output->writeln([
            '',
            'Master pulled',
            '=============',
            '',
        ]);
    }
}

UpdateMasterCommandTest.php:


namespace My\CommandTest;

use GitWrapper\GitWorkingCopy;
use GitWrapper\GitWrapper;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use My\Command\UpdateMasterCommand;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;

class UpdateMasterCommandTest extends TestCase
{
    use MockeryPHPUnitIntegration;

    /**
     * @var \Mockery\MockInterface|GitWrapper
     */
    protected $gitWrapper;

    /**
     * @var \Mockery\MockInterface|GitWorkingCopy
     */
    protected $gitWorkingCopy;

    /**
     * @before
     */
    public function setupGitWrapper()
    {
        $this->gitWrapper = \Mockery::mock(new GitWrapper());
        $this->gitWrapper->shouldReceive(['workingCopy'])
            ->byDefault();

        $this->gitWorkingCopy = \Mockery::mock(new GitWorkingCopy(new GitWrapper(), 'foo/bar'));
        $this->gitWorkingCopy->shouldReceive(['checkout', 'pull'])
            ->byDefault();
    }

    /**
     * @test
     */
    public function testItShouldCheckoutAndPullMaster()
    {
        $input = new ArrayInput(['source' => 'foo/bar']);
        $output = new NullOutput();

        $this->gitWrapper->shouldReceive('workingCopy')
            ->once()
            ->andReturn($this->gitWorkingCopy);


        $this->gitWorkingCopy->shouldReceive('checkout')
            ->once();

        $this->gitWorkingCopy->shouldReceive('pull')
            ->once();

        $command = new UpdateMasterCommand();
        $command->setGitWrapper($this->gitWrapper);
        $command->run($input, $output);
    }
}

When running PHPunit, the following exception is thrown:

$ phpunit
PHPUnit 7.3.2 by Sebastian Bergmann and contributors.


TypeError : Return value of Mockery_0_GitWrapper_GitWrapper_GitWrapper_GitWrapper::workingCopy() must be an instance of GitWrapper\GitWorkingCopy, instance of Mockery_1_GitWrapper_GitWorkingCopy_GitWrapper_GitWorkingCopy returned

This is because the type of UpdateMasterCommandTest::$gitWorkingCopy is a proxied mock and cannot descend GitWrapper\GitWorkingCopy.

The second side effect is that I cannot set UpdateMasterCommand::setGitWrapper to accept only GitWrapper instances due to the fact that UpdateMasterCommandTest::$gitWrapper is also a proxied object

@TomasVotruba

This comment has been minimized.

Copy link
Collaborator

commented Aug 30, 2018

Hi, thanks for the clear description of your issue.

When final is removed, the architecture code will suffer a big problem. Read about why final is so important here. Amazing post.

But to your problem:

  1. To make mocking work right now use this: https://phpfashion.com/how-to-mock-final-classes

  2. Instead of removing final, the better call is to decouple an interface - you can send PR for that, since it might be useful in more cases.

  3. Instead of using mocks, use anonymous classes - https://www.tomasvotruba.cz/blog/2018/06/11/how-to-turn-mocks-from-nightmare-to-solid-kiss-tests/#solid-code-as-a-side-effect, they're clear, easy to refactor and everyone understand them - it's plain PHP

  4. Why not pass the GitWrapper by constructor instead of set/get magic? It would make design more clear and you'd get rid of manual new GitWrapper inside the code - you should never do that, there is DI.

 final class UpdateMasterCommand extends Command
 {
     /**
      * @var GitWrapper
      */
     private $gitWrapper;

-    public function getGitWrapper()
-    {
-        if (null === $this->gitWrapper) {
-            $this->setGitWrapper(new GitWrapper());
-        }
-
-        return $this->gitWrapper;
-    }
-
-    public function setGitWrapper($gitWrapper)
-    {
-        $this->gitWrapper = $gitWrapper;
-    }
+
+    public function __construct(GitWrapper $gitWrapper)
+    {
+        $this->gitWrapper = $gitWrapper;
+    }
}
@lolli42

This comment has been minimized.

Copy link

commented Nov 25, 2018

Hey @TomasVotruba, the final keyword bites me when testing, too. The git wrapper lib is a low level library, when it comes to functional tests, one really wants to mock some things away, for example $workingCopy->push().

I'm using constructor DI for the GitWrapper class in my service. This does not help here since I can't inject a mocked wrapper (and workingCopty) as long as they have final keyword and no interface.

The project 'dg/bypass-finals' is only a hack for devs who are in desperate need to get rid of the final keyword from a library class without interfaces. Tokenizing the php stream and rewriting on the fly really is not a cool thing. Not even for tests. I don't consider that a solution.

I ask to do one of the following:

  • Revert the patch that introduced the final keywords.
  • Add interfaces to all classes that have the final keyword.
  • Add a warning to README.md this library is an unmockable dependency.
@TomasVotruba

This comment has been minimized.

Copy link
Collaborator

commented Nov 25, 2018

Hi, reverting final is a no-go. See my comment above.

I'm open to solution 2.

@lolli42

This comment has been minimized.

Copy link

commented Nov 25, 2018

Adding final without adding interfaces was breaking and violated semver.

With the rather long main classes, you would end up with longish interfaces that simply contain the exact same method signatures, forcing you to keep those forever. This does not make much sense either, I'll not create pull requests for that.

At the moment I rather tend to fork and revert the final patch or to switch away and use a shell script.

@TomasVotruba

This comment has been minimized.

Copy link
Collaborator

commented Nov 25, 2018

Adding final without adding interfaces was breaking and violated semver.

That's not true. It was released as 2.0: #137

At the moment I rather tend to fork and revert the final patch or to switch away and use a shell script.

That's sad that you prefer that over adding a PR with interface. But if that's what you prefer, give it a go.

@lolli42

This comment has been minimized.

Copy link

commented Nov 25, 2018

You really prefer interfaces for things like GitWrapper that contain 50 method signatures?

@TomasVotruba

This comment has been minimized.

Copy link
Collaborator

commented Nov 25, 2018

I'd use the bypass final, since it's another package that forces bad design.

Imagine that framework you use would require static methods only. Would you send PRs to all packages that you use to use such design?

@TomasVotruba

This comment has been minimized.

Copy link
Collaborator

commented Nov 25, 2018

Closing as options are answered and it's 3rd party responsibility

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.