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:
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:
<?phpnamespaceSymfony\Bundle\FrameworkBundle\Command;useSymfony\Bundle\FrameworkBundle\Command\AssetsInstallCommand;useSymfony\Component\Console\Input\ArrayInput;useSymfony\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. */classAssetsInstallCommandTestextends\PHPUnit_Framework_TestCase{/** * @covers Symfony\Bundle\FrameworkBundle\AssetsInstallCommand::execute() */publicfunctiontestRun(){$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=newAssetsInstallCommand();$command->setKernel($kernel);$command->setFilesystem($filesystem);$command->run(newArrayInput(array('target'=>__DIR__)),newNullOutput());}/** * Gets Filesystem mock instance * * @return Symfony\Bundle\FrameworkBundle\Util\Filesystem */privatefunctiongetMockFilesystem(){return$this->getMock('Symfony\Bundle\FrameworkBundle\Util\Filesystem',array(),array(),'',false,false);}/** * Gets Bundle mock instance * * @return Symfony\Component\HttpKernel\Bundle\Bundle */privatefunctiongetMockBundle(){return$this->getMock('Symfony\Component\HttpKernel\Bundle\Bundle',array(),array(),'',false,false);}/** * Gets Kernel mock instance * * @return Symfony\Component\HttpKernel\Kernel */privatefunctiongetMockKernel(){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:
<?phpnamespaceSymfony\Bundle\FrameworkBundle\Command;useSymfony\Component\Console\Input\InputArgument;useSymfony\Component\Console\Input\InputOption;useSymfony\Component\Console\Input\InputInterface;useSymfony\Component\Console\Output\OutputInterface;useSymfony\Component\Console\Output\Output;useSymfony\Bundle\FrameworkBundle\Util\Filesystem;useSymfony\Component\HttpKernel\Kernel;useSymfony\Component\Console\Command\CommandasBaseCommand;/* * 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> */classAssetsInstallCommandextendsBaseCommand{/** * 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 */publicfunctionsetKernel(Kernel$kernel){$this->kernel=$kernel;}/** * Sets Filesystem instance * * @param Symfony\Bundle\FrameworkBundle\Util\Filesystem $fs */publicfunctionsetFilesystem(Filesystem$fs){$this->filesystem=$fs;}/** * @see Command */protectedfunctionconfigure(){$this->setDefinition(array(newInputArgument('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 */protectedfunctionexecute(InputInterface$input,OutputInterface$output){if(!is_dir($input->getArgument('target'))){thrownew\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:
1234567
$ 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 (1test, 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.