Software Alchemist

I am constantly seeking answers and ways of transforming desires into reality by coding

Symfony2 Console Commands and DIC

| Comments

I personally feel that conventions should be best practices and not inevitable parts of frameworks. Conventions are good, but they kill testability. So while they can save you some time you would have had to spend on configuration otherwise, they also limit the granularity of your interfaces and break testability.

My recent example of not testable controllers and how it could have been fixed was very well received amongst fellow Symfony2 developers, so that gives me enough confidence to propose something else.

There is another major part of the framework that can hardly be tested as it relies on Symfony’s internals and cannot use DIC for own configuration. Console Commands. They are registered by manual scan of bundles’ Console directory. They therefore cannot be configured through DIC with all dependencies moved to their interface definition and just get the generic Container instance instead.

Or can they? The answer is: “Yes, they can”.

And it wouldn’t be a lot of work to switch that. All we need to do is register each command in DIC as a service, and use tags to specify that this service is a command:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?xml version="1.0" ?>

<container xmlns="http://www.symfony-project.org/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.symfony-project.org/schema/dic/services http://www.symfony-project.org/schema/dic/services/services-1.0.xsd">

    <parameters>
        <parameter key="console.command.assets_install.class">Symfony\Bundle\FrameworkBundle\Command\AssetsInstallCommand</parameter>
        <parameter key="console.command.init_bundle.class">Symfony\Bundle\FrameworkBundle\Command\InitBundleCommand</parameter>
        <parameter key="console.command.router_debug.class">Symfony\Bundle\FrameworkBundle\Command\RouterDebugCommand</parameter>
        <parameter key="console.command.router_apache_dumper.class">Symfony\Bundle\FrameworkBundle\Command\RouterApacheDumperCommand</parameter>
    </parameters>

    <services>
        <service id="console.command.assets_install" class="%console.command.assets_install.class%">
            <tag name="console.command" />
            <call method="setKernel">
                <argument type="service" id="kernel">
            </call>
            <call method="setFilesystem">
                <service class="Symfony\Bundle\FrameworkBundle\Util\Filesystem" shared="false">
            </call>
        </service>

        <service id="console.command.init_bundle" class="%console.command.init_bundle.class%">
            <tag name="console.command" />
        </service>

        <service id="console.command.router_debug" class="%console.command.router_debug.class%">
            <tag name="console.command" />
        </service>

        <service id="console.command.router_apache_dumper" class="%console.command.router_apache_dumper.class%">
            <tag name="console.command" />
        </service>
    </services>
</container>

Let’s look at how we could then test one of the least testable Symfony2 commands - the Symfony\Bundle\FrameworkBundle\Command\AssetsInstallCommand. This command copies public assets like JavaScript and CSS files from the bundles’ Resources/public directories into a publicly accessible web directory, that is passed to it as the only parameter.

Since I’m gonna be testing the already existing class, the test will not be as elegant as it could have been:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
<?php

namespace Symfony\Bundle\FrameworkBundle\Command;

use Symfony\Bundle\FrameworkBundle\Command\AssetsInstallCommand;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;

/*
 * This file is part of the Symfony framework.
 *
 * (c) Fabien Potencier <fabien.potencier@symfony-project.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */

class AssetsInstallCommandTest extends \PHPUnit_Framework_TestCase
{

    /**
     * @covers Symfony\Bundle\FrameworkBundle\AssetsInstallCommand::execute()
     */
    public function testRun()
    {
        $originDir = __DIR__ . '/Resources/public';
        $targetDir = __DIR__ . '/bundles/test';

        $filesystem = $this->getMockFilesystem();
        $filesystem->expects($this->once())
            ->method('remove')
            ->with($targetDir)
        ;
        $filesystem->expects($this->once())
            ->method('mkdirs')
            ->with($targetDir, 0777)
        ;
        $filesystem->expects($this->once())
            ->method('mirror')
            ->with($originDir, $targetDir)
        ;

        $bundle = $this->getMockBundle();
        $bundle->expects($this->any())
            ->method('getName')
            ->will($this->returnValue('TestBundle'))
        ;
        $bundle->expects($this->once())
            ->method('getPath')
            ->will($this->returnValue(__DIR__))
        ;

        $kernel = $this->getMockKernel();
        $kernel->expects($this->once())
            ->method('getBundles')
            ->will($this->returnValue(array($bundle)))
        ;

        $command = new AssetsInstallCommand();
        $command->setKernel($kernel);
        $command->setFilesystem($filesystem);
        $command->run(new ArrayInput(array('target' => __DIR__)), new NullOutput());
    }

    /**
     * Gets Filesystem mock instance
     *
     * @return Symfony\Bundle\FrameworkBundle\Util\Filesystem
     */
    private function getMockFilesystem()
    {
        return $this->getMock('Symfony\Bundle\FrameworkBundle\Util\Filesystem', array(), array(), '', false, false);
    }

    /**
     * Gets Bundle mock instance
     *
     * @return Symfony\Component\HttpKernel\Bundle\Bundle
     */
    private function getMockBundle()
    {
        return $this->getMock('Symfony\Component\HttpKernel\Bundle\Bundle', array(), array(), '', false, false);
    }

    /**
     * Gets Kernel mock instance
     *
     * @return Symfony\Component\HttpKernel\Kernel
     */
    private function getMockKernel()
    {
        return $this->getMock('Symfony\Component\HttpKernel\Kernel', array(), array(), '', false, false);
    }

}

While writing this test, I found out the command wasn’t testable because of a hard-coded mkdir function call that I couldn’t mock out. In order to fix it, I found the already existent Symfony\Bundle\FrameworkBundle\Util\Filesystem::mkdirs() method that wraps it, and makes it mockable, which I then proceeded to use. The only other changes I had to introduce were - get rid of Container dependency, and add Symfony\Bundle\FrameworkBundle\Command\AssetsInstallCommand::setKernel() and Symfony\Bundle\FrameworkBundle\Command\AssetsInstallCommand::setFilesystem() methods for direct injection of primary dependencies.

So here it is - the modified AssetsInstallCommand, that is fully unit-tested:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
<?php

namespace Symfony\Bundle\FrameworkBundle\Command;

use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\Output;
use Symfony\Bundle\FrameworkBundle\Util\Filesystem;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Console\Command\Command as BaseCommand;

/*
 * This file is part of the Symfony framework.
 *
 * (c) Fabien Potencier <fabien.potencier@symfony-project.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */

/**
 * AssetsInstallCommand.
 *
 * @author     Fabien Potencier <fabien.potencier@symfony-project.com>
 */
class AssetsInstallCommand extends BaseCommand
{

    /**
     * Holds Kernel instance
     *
     * @var Symfony\Component\HttpKernel\Kernel
     */
    private $kernel;

    /**
     * Holds Filesystem instance
     *
     * @var Symfony\Bundle\FrameworkBundle\Util\Filesystem
     */
    private $filesystem;

    /**
     * Sets Kernel instance
     *
     * @param Symfony\Component\HttpKernel\Kernel $kernel
     */
    public function setKernel(Kernel $kernel)
    {
        $this->kernel = $kernel;
    }

    /**
     * Sets Filesystem instance
     *
     * @param Symfony\Bundle\FrameworkBundle\Util\Filesystem $fs
     */
    public function setFilesystem(Filesystem $fs)
    {
        $this->filesystem = $fs;
    }

    /**
     * @see Command
     */
    protected function configure()
    {
        $this
            ->setDefinition(array(
                new InputArgument('target', InputArgument::REQUIRED, 'The target directory'),
            ))
            ->addOption('symlink', null, InputOption::PARAMETER_NONE, 'Symlinks the assets instead of copying it')
            ->setName('assets:install')
        ;
    }

    /**
     * @see Command
     *
     * @throws \InvalidArgumentException When the target directory does not exist
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        if (!is_dir($input->getArgument('target'))) {
            throw new \InvalidArgumentException(sprintf('The target directory "%s" does not exist.', $input->getArgument('target')));
        }

        foreach ($this->kernel->getBundles() as $bundle) {
            if (is_dir($originDir = $bundle->getPath().'/Resources/public')) {
                $output->writeln(sprintf('Installing assets for <comment>%s\\%s</comment>', $bundle->getNamespacePrefix(), $bundle->getName()));

                $targetDir = $input->getArgument('target').'/bundles/'.preg_replace('/bundle$/', '', strtolower($bundle->getName()));

                $this->filesystem->remove($targetDir);

                if ($input->getOption('symlink')) {
                    $this->filesystem->symlink($originDir, $targetDir);
                } else {
                    $this->filesystem->mkdirs($targetDir, 0777);
                    $this->filesystem->mirror($originDir, $targetDir);
                }
            }
        }
    }
}

And here is the result of running it in PHPUnit:

1
2
3
4
5
6
7
$ phpunit.bat --bootstrap tests/bootstrap.php src/Symfony/Bundle/FrameworkBundle/Tests/Command/AssetInstallCommandTest.php
PHPUnit 3.5.0RC1 by Sebastian Bergmann.

.
Time: 0 seconds, Memory: 4.75MB

OK (1 test, 3 assertions)

Happy Coding!

P.S. While I was posting this, and embedding my thoughts in public gists, Kris Wallsmith suggested to use the tags to specify command names as well, which is a very interesting suggestion.

P.P.S. Henrik Bjørnskov was very happy when I shared this idea with him and contributed most of the initial implementation of this feature here

P.P.P.S Code that I provided in the post is available on my GitHub repository, and is built on top of Henrik’s efforts.

Comments