Categories


Archives


Recent Posts


Categories


Developing Commands for N98-magerun

astorm

Frustrated by Magento? Then you’ll love Commerce Bug, the must have debugging extension for anyone using Magento. Whether you’re just starting out or you’re a seasoned pro, Commerce Bug will save you and your team hours everyday. Grab a copy and start working with Magento instead of against it.

No Frills Magento Layout is the only Magento front end book you'll ever need. Get your copy today!

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.

Originally published August 3, 2013

Copyright © Alan Storm 1975 – 2017 All Rights Reserved

Originally Posted: 3rd August 2013