In the N98-magerun: Creating Hello World article, we covered creating a new n98-magerun
command. However, this command was the ubiquitous but ultimately useless “Hello World”. Also, because of its trivialness we failed to perform an important part of modern software development: Writing automated tests.
In this last n98-magerun
article, we’re going to review the implementation of the config:search
command, including the all important phpunit
tests.
Test Driven Development
As mentioned, we’re going to take a test driven development (TDD) approach. This means the first thing we’ll do is write a test. One benifit of TDD is you’ll always have a test suite since you always start by writing tests.
To create our initial test, we’ll create the following file with the following contents
#File: tests/N98/Magento/Command/Config/SearchTest.php
<?php
namespace N98\Magento\Command\Config;
use Symfony\Component\Console\Tester\CommandTester;
use N98\Magento\Command\PHPUnit\TestCase;
class SearchTest extends TestCase
{
public function testSetup()
{
$this->assertEquals('yes','yes');
}
}
Here we’ve setup our test case with a single “let’s make sure everything is working” test. When we run the test through phpunit
we’ll see the following.
$ vendor/bin/phpunit tests/N98/Magento/Command/Config/SearchTest.php
PHPUnit 3.7.21 by Sebastian Bergmann.
Configuration read from /path/to/n98-magerun/phpunit.xml
.
Time: 0 seconds, Memory: 7.00Mb
OK (1 test, 1 assertion)
No failures! We’re ready to go. If you’re a little shaky on what we’ve done here, you may want to review the N98-magerun: Writing Tests article.
The First Test
With our setup test out of the way, we’re ready to create our first real test. Since we’re creating a configuration search, we’ll want to test running the search for some text we know is a stock part of Magento, and then ensure it returns the correct node.
Except … how can we run the search if we haven’t written the command?
This is a less understood benefit of TDD. It forces you to think about how the code you’re writing will be used before you write it. That is, your implementation code is driven by the tests you write.
Imagining our command, we know it will start something like this
$ n98-magerun config:search
Next, we need some text to search for. Something we know shows up in Magento’s system configuration section — maybe “Credit Card Types”. This means our command will look like the following
$ n98-magerun config:search "Credit Card Types"
Here we’re searching for the text Credit Card Types
. We now have the most simple use case for our command and can stop thinking about syntax. It’s time to translate our imaginary syntax into test code. Add the following testExecute
method to your test
#File: tests/N98/Magento/Command/Config/SearchTest.php
public function testExecute()
{
$application = $this->getApplication();
$application->add(new DumpCommand());
$command = $this->getApplication()->find('config:search');
$commandTester = new CommandTester($command);
$commandTester->execute(
array(
'command' => $command->getName(),
'text' => 'Credit Card Types',
)
);
$this->assertContains('[WHAT TO ASSERT FOR?]', $commandTester->getDisplay());
}
If you’re not familiar with the CommandTester
, checkout the N98-magerun: Writing Tests article. We’re almost done with our first test. The above will run the $ n98-magerun config:search "Credit Card Types"
command — but what text should we look for in the output ([WHAT TO ASSERT FOR?]
)?
Again, TDD is forcing us to think about our code before we write any. If our config search finds a match, we’ll want it to look something like this
Found a field with a match
Mage::getStoreConfig('payment/ccsave/cctypes')
Payment Methods -> Saved CC -> Credit Card Types
That is, a message confirming a match has been found, a code snippet for grabbing the configuration value, and a hierarchical display showing us where the command is in the admin. With that output as our goal, we can change
$this->assertContains('[WHAT TO ASSERT FOR?]', $commandTester->getDisplay());
to
$this->assertContains('payment/ccsave/cctypes', $commandTester->getDisplay());
Now our test is searching for the string payment/ccsave/cctypes
. With our test complete, lets run it again. Assuming you’ve set your N98_MAGERUN_TEST_MAGENTO_ROOT
constant, you should see the following
$ vendor/bin/phpunit tests/N98/Magento/Command/Config/SearchTest.php
PHPUnit 3.7.21 by Sebastian Bergmann.
Configuration read from /path/to/n98-magerun/phpunit.xml
.E
Time: 10 seconds, Memory: 32.25Mb
There was 1 error:
1) N98\Magento\Command\Config\SearchTest::testExecute
InvalidArgumentException: Command "config:search" is not defined.
Did you mean one of these?
config:set
config:get
Oh no! Our test has come back with an error (E
) — except that’s exactly what we want it to do. Right now this test returns an error — but once we correctly implement our command, it won’t. In other words: Once this test passes, we’re done.
Test in place, we can now start to code.
Implement the Command
Thanks to our test, we now know the command we want to write will look like this
$ n98-magerun config:search "Search String Here"
Just as we did with our Hello World command, step 1 is creating the class that will contain our command logic.
#File: src/N98/Magento/Command/Config/SearchCommand.php
namespace N98\Magento\Command\Config;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class SearchCommand extends AbstractConfigCommand
{
protected function configure()
{
$this
->setName('config:search')
->setDescription('Search system configuration descriptions.')
->setHelp(
<<<EOT
Searches the merged system.xml configuration tree <labels/> and <comments/> for the indicated text.
EOT
)
->addArgument('text', InputArgument::REQUIRED, 'The text to search for');
}
/**
* @param \Symfony\Component\Console\Input\InputInterface $input
* @param \Symfony\Component\Console\Output\OutputInterface $output
* @return int|void
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$this->detectMagento($output, true);
if ($this->initMagento()) {
$output->writeln('<info>Test</info>');
}
}
The important bits are in the configure
method — this is where we set the command name we’ll want to use (config:search
), a short description for built in list
functionality, a longer description for the built in help
command, and any arguments we want our command to have.
With the above in place, you should be able to navigate to an existing Magento folder and run the command using the built in source application stub file. This stub will run n98-magerun
from source, meaning you don’t need to compile everything into a phar
$ /path/to/github_netz98/n98-magerun/bin/n98-magerun config:search "Credit Card Types"
Test
Success! Of course, our command isn’t done yet. Since we haven’t implemented anything, the command doesn’t contain a search result.
Before we move on to implementation, let’s try running out tests again
$ vendor/bin/phpunit tests/N98/Magento/Command/Config/SearchTest.php
.F
Time: 9 seconds, Memory: 33.00Mb
There was 1 failure:
1) N98\Magento\Command\Config\SearchTest::testExecute
Failed asserting that 'Test
' contains "payment/ccsave/cctypes".
Our test case still didn’t run cleanly, but notice this time instead of an E
indicating there was a PHP error, phpunit
reports an F
, with the message
Failed asserting that 'Test
' contains "payment/ccsave/cctypes".
That’s because on this run the config:search
command was actually implemented. This let our test complete without an error. However, our command’s output (“Test”), still didn’t contain the expected text payment/ccsave/cctypes
.
This is the core operating principle of automated tests. Instead of manually running our code and testing output, we’ve handed that responsibility over to the computer (in the form of our tests). In addition to removing this cognitive load from ourselves during development, we’ve also given ourselves the ability to run this test over and over again.
To implement the full config:search
command, replace the simple SearchCommand
class with the following
#File: src/N98/Magento/Command/Config/SearchCommand.php
class SearchCommand extends AbstractConfigCommand
{
protected function configure()
{
$this
->setName('config:search')
->setDescription('Search system configuration descriptions.')
->setHelp(
<<<EOT
Searches the merged system.xml configuration tree <labels/> and <comments/> for the indicated text.
EOT
)
->addArgument('text', InputArgument::REQUIRED, 'The text to search for');
}
/**
* @param \Symfony\Component\Console\Input\InputInterface $input
* @param \Symfony\Component\Console\Output\OutputInterface $output
* @return int|void
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$this->detectMagento($output, true);
if ($this->initMagento()) {
$this->writeSection($output, 'Config Search');
$searchString = $input->getArgument('text');
$system = \Mage::getConfig()->loadModulesConfiguration('system.xml');
$matches = $this->_searchConfiguration($searchString, $system);
if (count($matches) > 0) {
foreach ($matches as $match) {
$output->writeln('Found a <comment>' . $match->type . '</comment> with a match');
$output->writeln(' ' . $this->_getPhpMageStoreConfigPathFromMatch($match));
$output->writeln(' ' . $this->_getPathFromMatch($match));
if ($match->match_type == 'comment') {
$output->writeln(
' ' .
str_ireplace(
$searchString,
'<info>' . $searchString . '</info>',
(string)$match->node->comment
)
);
}
$output->writeln('');
}
} else {
$output->writeln('<info>No matches for <comment>' . $searchString . '</comment></info>');
}
}
}
/**
* @param string $searchString
* @param string $system
*
* @return array
*/
protected function _searchConfiguration($searchString, $system)
{
$xpathSections = array(
'sections/*',
'sections/*/groups/*',
'sections/*/groups/*/fields/*'
);
$matches = array();
foreach ($xpathSections as $xpath) {
$tmp = $this->_searchConfigurationNodes(
$searchString,
$system->getNode()->xpath($xpath)
);
$matches = array_merge($matches, $tmp);
}
return $matches;
}
/**
* @param string $searchString
* @param array $nodes
*
* @return array
*/
protected function _searchConfigurationNodes($searchString, $nodes)
{
$matches = array();
foreach ($nodes as $node) {
$match = $this->_searchNode($searchString, $node);
if ($match) {
$matches[] = $match;
}
}
return $matches;
}
/**
* @param string $searchString
* @param object $node
*
* @return bool|\stdClass
*/
protected function _searchNode($searchString, $node)
{
$match = new \stdClass;
$match->type = $this->_getNodeType($node);
if (stristr((string)$node->label, $searchString)) {
$match->match_type = 'label';
$match->node = $node;
return $match;
}
if (stristr((string)$node->comment, $searchString)) {
$match->match_type = 'comment';
$match->node = $node;
return $match;
}
return false;
}
/**
* @param object $node
*
* @return string
*/
protected function _getNodeType($node)
{
$parent = current($node->xpath('parent::*'));
$grandParent = current($parent->xpath('parent::*'));
if ($grandParent->getName() == 'config') {
return 'section';
}
switch ($parent->getName()) {
case 'groups':
return 'group';
case 'fields':
return 'field';
default:
return 'unknown';
}
}
/**
* @param object $match
*
* @return string
* @throws \RuntimeException
*/
protected function _getPhpMageStoreConfigPathFromMatch($match)
{
switch ($match->type) {
case 'section':
$path = $match->node->getName();
break;
case 'field':
$parent = current($match->node->xpath('parent::*'));
$parent = current($parent->xpath('parent::*'));
$grand = current($parent->xpath('parent::*'));
$grand = current($grand->xpath('parent::*'));
$path = $grand->getName() . '/' . $parent->getName() . '/' . $match->node->getName();
break;
case 'group':
$parent = current($match->node->xpath('parent::*'));
$parent = current($parent->xpath('parent::*'));
$path = $parent->getName() . '/' . $match->node->getName();
break;
default:
// @TODO Why?
throw new \RuntimeException(__METHOD__);
}
return "Mage::getStoreConfig('" . $path . "')";
}
/**
* @param object $match
*
* @return string
* @throws \RuntimeException
*/
protected function _getPathFromMatch($match)
{
switch ($match->type) {
case 'section':
return (string)$match->node->label . ' -> ... -> ...';
case 'field':
$parent = current($match->node->xpath('parent::*'));
$parent = current($parent->xpath('parent::*'));
$grand = current($parent->xpath('parent::*'));
$grand = current($grand->xpath('parent::*'));
return $grand->label . ' -> ' . $parent->label . ' -> <info>' . $match->node->label . '</info>';
case 'group':
$parent = current($match->node->xpath('parent::*'));
$parent = current($parent->xpath('parent::*'));
return $parent->label . ' -> <info>' . $match->node->label . '</info> -> ...';
default:
// @TODO Why?
throw new \RuntimeException(__METHOD__);
}
}
}
The general idea is we’re loading-and-merging Magento’s system.xml
configuration files, (via the Magento environment bootstrapped by the n98-magerun
initMagento
method), and then searching each section
, group
and field
nodes for the search text. While covering the specific implementation details are beyond the scope of this article, three are two things worth covering.
First is the retrieval of argument text. Up in the configure
method, we told n98-magrun
our command should have a single argument
#File: src/N98/Magento/Command/Config/SearchCommand.php
->addArgument('text', InputArgument::REQUIRED, 'The text to search for');
You may have wondered why the first argument to the addArgument
method, ('text'
), is required. The argument name text
is used when we want to retrieve the value of an argument down in execute
#File: src/N98/Magento/Command/Config/SearchCommand.php
$searchString = $input->getArgument('text');
Second, take a look at the call Mage::getConfig()
#File: src/N98/Magento/Command/Config/SearchCommand.php
$system = \Mage::getConfig()->loadModulesConfiguration('system.xml');
$matches = $this->_searchConfiguration($searchString, $system);
You’ll notice this is actually a call to \Mage::getConfig
. Without this leading backlash, we’d have received the following error
PHP Fatal error: Class 'N98\Magento\Command\Config\Mage' not found in
That’s because every n98-magerun
command is in its own namespace. Ours is in the N98\Magento\Command\Config
namespace.
#File: src/N98/Magento/Command/Config/SearchCommand.php
namespace N98\Magento\Command\Config;
Since Magento doesn’t use namespaces, the Mage
class is automatically put in the global namespace. In order to let PHP know it should look for this class in the global namespace (as opposed to the namespace local to this file), the leading backslash \Mage
syntax is required.
With our command completed, let’s run our test case one more time.
$ vendor/bin/phpunit tests/N98/Magento/Command/Config/SearchTest.php
PHPUnit 3.7.21 by Sebastian Bergmann.
Configuration read from /path/to/n98-magerun/phpunit.xml
..
Time: 12 seconds, Memory: 32.75Mb
OK (2 tests, 3 assertions)
This time we had a completely error and fail free run. In other words, we passed our initial tests. At this point our command is done. With a working test case in place we’re ready to merge into the development branch, create a new set of use cases and tests to implement, or refactor our existing code.
Wrap UP
Test driven development is a tricky topic in programming circles. The effort to setup and integrate a testing environment into development workflows, as well as the will to enforce the writing of tests (especially when someone needs just one little thing) can create tension in organizations where engineering, product, and sales are all different departments with different institutional goals.
Fortunately, the creators of n98-magerun
know the importance of testing. Not only is there coverage for all their commands — but the creation and setup of new tests is a breeze. This makes TDD a no brainer when working on new n98-magerun
commands.