LogoPhly, boy, phly
the weblog and site of Matthew Weier O'Phinney

Saturday, April 8. 2006

Automating PHPUnit2 with SPL

I don't blog much any more. Much of what I work on any more is for my employer, Zend, and I don't feel at liberty to talk about it (and some of it is indeed confidential). However, I can say that I've been programming heavily on PHP5 the past few months, and had a chance to do some pretty fun stuff. Among the new things I've been able to play with are SPL and PHPUnit -- and, recently, together.

I've written before about unit testing, and my preference for the phpt-style tests used in PEAR. However, since Zend Framework uses PHPUnit2, and I work at Zend... I must to as the Romans do.

I've actually come to enjoy the PHPUnit2 style of tests. In the end, I find that my tests are much less verbose than the way I was performing them with phpt, and I tend to test for failure rather than success; failure should be the exception to the rule. The myriad of 'assert' methods make this relatively easy (though some operate in unexpected ways -- try testing assertSame() on two objects that contain PDO handles, for instance).

One thing that was missing for me was an easy way to run all tests in a directory, ala 'pear run-tests'. I read the Pocket Guide, and noted the possibility of creating test suites to automate running tests. (Indeed, the newer versions of PEAR now support running PHPUnit tests via pear run-tests as long as there is a file named AllTests.php containing the test suite in the test directory.)

However, I was initially disappointed. The demonstrated way to do this is to manually require each test file and add the class contained therein to the test suite. Basically, I was going to need to touch the file every time I added a test class to the suite. Bleh!

So, I started thinking about it, and realized I could just go through the directory tree, grabbing files matching the pattern '/(.*?Test)\.php$/', load them up, and add their respective class (by substituting '_' for '/' in the path, and trimming the Test.php from the end) to the suite.

Initially, I was going to do this with the combination of opendir(), readdir(), and closedir(), and then thought, "I'm doing something new with PHPUnit, why not keep learning and do this with SPL?"

The problem with SPL is that it's not documented very well. It has extensive API documentation, but that's mainly of the sort, 'such-and-such class exists, with such-and-such properties and methods.' If any use cases exist, they're typically in the user-contributed comments. I know, if it's a problem, get off my duff and fix it -- and maybe I will, when I have a spare week or so.

Fortunately, there's a nice use case of RecursiveDirectoryIterator in the comments to the DirectoryIterator::construct() entry. One thing to note: you can't use foreach() with the RecursiveDirectoryIterator, as you need access to not just the 'array' elements, but the iterator itself; a for() loop thus becomes necessary.

With RecursiveDirectoryIterator in hand, I was then able to whip up a very nice quick routine for creating a test suite:


<?php
if (!defined('PHPUnit2_MAIN_METHOD')) {
    define('PHPUnit2_MAIN_METHOD', 'AllTests::main');
}

require_once 'PHPUnit2/Framework/TestSuite.php';
require_once 'PHPUnit2/TextUI/TestRunner.php';

class AllTests
{
    /**
     * Root directory of tests
     */

    public static $root;

    /**
     * Pattern against which to test files to see if they contain tests
     */

    public static $filePattern;

    /**
     * Pattern against which to test directories to see if they are for source
     * code control metadata
     */

    public static $sscsPattern = '/(CVS|\.svn)$/';

    /**
     * Associative array of test class => file
     */

    public static $list = array();

    /**
     * Main method
     *
     * @static
     * @access public
     * @return void
     */

    public static function main()
    {
        PHPUnit2_TextUI_TestRunner::run(self::suite());
    }

    /**
     * Create test suite by recursively iterating through tests directory
     *
     * @static
     * @access public
     * @return PHPUnit2_Framework_TestSuite
     */

    public static function suite()
    {
        $suite = new PHPUnit2_Framework_TestSuite('MyTestSuite');

        self::$root = realpath(dirname(__FILE__));
        self::$filePattern = '|^' . self::$root . '/(.*?Test)\.php$|';

        self::createTestList(new RecursiveDirectoryIterator(self::$root));

        foreach (self::$list as $class => $file) {
            require_once $file;
            $suite->addTestSuite($class);
        }

        return $suite;
    }

    /**
     * Recursively iterate through a directory looking for test classes
     *
     * @static
     * @access public
     * @param RecursiveDirectoryIterator $dir
     * @return void
     */

    public static function createTestList(RecursiveDirectoryIterator $dir)
    {
        for ($dir->rewind(); $dir->valid(); $dir->next()) {
            if ($dir->isDot()) {
                continue;
            }

            $file = $dir->current()->getPathname();

            if ($dir->isDir()) {
                if (!preg_match(self::$sscsPattern, $file)
                    && $dir->hasChildren())
                {
                    self::createTestList($dir->getChildren());
                }
            } elseif ($dir->isFile()) {
                if (preg_match(self::$filePattern, $file, $matches)) {
                    self::$list[str_replace('/', '_', $matches[1])] = $file;
                }
            }
        }
    }
}

/**
 * Run tests
 */

if (PHPUnit2_MAIN_METHOD == 'AllTests::main') {
    AllTests::main();
}
 

The crux of the class is the createTestList() method:


    public static function createTestList(RecursiveDirectoryIterator $dir)
    {
        for ($dir->rewind(); $dir->valid(); $dir->next()) {
            if ($dir->isDot()) {
                continue;
            }

            $file = $dir->current()->getPathname();

            if ($dir->isDir()) {
                if (!preg_match(self::$sscsPattern, $file)
                    && $dir->hasChildren())
                {
                    self::createTestList($dir->getChildren());
                }
            } elseif ($dir->isFile()) {
                if (preg_match(self::$filePattern, $file, $matches)) {
                    self::$list[str_replace('/', '_', $matches[1])] = $file->__toString();
                }
            }
        }
    }
 

Basically, you step through each element of the directory. the isDot() method of RDI allows you to quickly identify the . and .. entries and skip over them. isDir() and isFile() let you quickly identify directories and files with nice, OOP syntax. hasChildren() lets you decide whether or not you need to descend into a directory; getChildren() returns a new RDI object for the subdirectory.

What's more fun is the usage of objects as strings. $dir->current() actually returns an SplFileObject. However, because it has a defined __toString() method, you can use it in situations that require strings -- such as the preg_match()s I perform here. In the case of SplFileObject, the __toString() method returns the full path to the file -- which is much handier than when using readdir(), which gives only the basename, as you can much more portably and easily perform operations on the file provided (such as require, file_get_contents(), etc). Update: Turns out there are some differences in how DirectoryIterator is implemented in PHP 5.0.x vs 5.1.x. As a result, I modified this to pull the pathName() using an agile interface instead.

The effort of using RDI is actually roughly equivalent to using readdir(), with the exception that I don't have to keep track of the path to the file -- which is actually a pretty substantial benefit. What will be even easier is when RegexFindFile makes it into a core release -- this will allow you to do something like:


    $files = new RegexFindFile(realpath(dirname(__FILE__)), '/Test\.php$/');
    $files = iterator_to_array($files);
    foreach ($files as $file) {
        // We're just working on filenames now... and we have the full list!
        //...
    }
 

So, in the end, you get an AllTests.php file that you can write once and never have to touch again, assuming you name your tests consistently.

Posted by Matthew Weier O'Phinney in PHP at 00:57 | Comments (8) | Trackbacks (0)

Trackbacks
Trackback specific URI for this entry

No Trackbacks

Comments
Display comments as (Linear | Threaded)

Interesting approach and thanks for the hints regarding the SPL dev branch for the 5.1.x releases.

You could provide a back compatibility method for those who run PHP 5.0.x at their box. Try the following code, its just a quick hack from my response at Harry's blog entry http://www.sitepoint.com/blogs/2004/09/21/lies-darn-lies/


if( !class_exists( 'RegexFindFile', false ) ) {
class RegexFindFile extends FilterIterator {
protected $it;
function __construct($path, $pattern) {
$this->it = new RecursiveIteratorIterator (
new RecursiveDirectoryIterator($path)
);
$this->pattern = $pattern;
parent::__construct( $this->it );
}
function accept() {
return ($this->it->getSubIterator()->isFile()
&&
preg_match($this->pattern, $this->it->getSubIterator()->getFilename())
);
}
}
}
$path = dirname(__FILE__);
$pattern = '/Test\.php$/';
foreach(new RegexFindFile($path, $pattern) as $file) {
echo $file->getPath(), ' --> ', $file->getFilename(), "\n";
}


Cheers
Alex
#1 Alexios Fakos (Link) on 2006-04-10 08:40 (Reply)
Very nice solution -- I'll be adding that one to my toolbox.
#1.1 Matthew Weier O'Phinney (Link) on 2006-04-10 10:08 (Reply)
Nice work. Is this code released under any given license?

On Windows I had to change:

self::$root = realpath(dirname(__FILE__));

to

self::$root = str_replace('\\', '/', realpath(dirname(__FILE__)));

as the regexp parser got confused otherwise :-)
#2 Rob... (Link) on 2006-04-11 17:46 (Reply)
Code is free to use -- enjoy!

I don't use PHP on Windows much (I actually have PHP running under coLinux under Windows...), so it's nice to see what needs to be done to have it work there.

You could even test for the OS and use that to determine the path... :-)
#2.1 Matthew Weier O'Phinney (Link) on 2006-04-11 17:52 (Reply)
Thanks!

It's not the path that's the problem, it's the path separator :-) regexp wants \ to be escaped.

Of course there are two solutions: convert \ to / or convert \ to \\. I went with conversion to / initially, but have now changed to addslashes() as it feels more "correct" :-)
#3 Rob... (Link) on 2006-04-11 17:57 (Reply)
Oh, with PHPUnit 3.0.0.alpha2 I had to comment out

require_once 'PHPUnit2/TextUI/TestRunner.php';

to remove this error:

Fatal error: Cannot redeclare class phpunit2_textui_testrunner in c:\programs\php51\PEAR\PHPUnit2\TextUI\TestRunner.php on line 688

I haven't bothered working out why it caused problems though, so it might be required again by the time 3 goes stable...
#4 Rob... (Link) on 2006-04-11 18:01 (Reply)
Quick couple of suggestions. I would avoid putting arbitrary strings (such as file paths) into regex patterns because the script will break under certain edge cases (e.g., the file path contains regex special characters). You could do this instead:
$file = $dir->current()->getPathname();
$basename = basename($file);
if (preg_match('/.*Test\.php/', $basename)) {
// etc...
}

An even better solution is to use PHP's glob() function to collect the test files for each directory.

$paths = glob('tests/*Test.php');
foreach ($paths) {
// etc...
}

This avoid regex's and it avoids much of the file-iteration logic.

Happy coding!
#5 Moxley Stratton (Link) on 2006-06-01 13:03 (Reply)
This is great, it was just what I needed!

Now, I need to figure out a way to sort the tests before running them.
#6 Ian on 2006-11-16 10:47 (Reply)

Add Comment

Standard emoticons like :-) and ;-) are converted to images.
E-Mail addresses will not be displayed and will only be used for E-Mail notifications

To prevent automated Bots from commentspamming, please enter the string you see in the image below in the appropriate input box. Your comment will only be submitted if the strings match. Please ensure that your browser supports and accepts cookies, or your comment cannot be verified correctly.
CAPTCHA

 
 
  • Home
  • Resume
  • Blog
  • Phly PEAR Channel
  • Contact Me
  • About this site

ZCE

Zend Education Advisory Board Member

Add to Technorati Favorites

Calendar

Back September '08 Forward
Mon Tue Wed Thu Fri Sat Sun
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          

Quicksearch

Links

  • PHLY - PHp LibrarY
  • Paul M. Jones
  • Mike Naberezny
  • Shahar Evron
  • Planet PHP
  • Zend Where I now work
  • Garden.org Where I once worked

Archives

September 2008
August 2008
July 2008
Recent...
Older...

Categories

XML Linux
XML Personal
XML Aikido
XML Family
XML Programming
XML Dojo
XML Perl
XML PHP

All categories

Syndicate This Blog

XML RSS 0.91 feed
XML RSS 1.0 feed
XML RSS 2.0 feed
ATOM/XML ATOM 0.3 feed
ATOM/XML ATOM 1.0 feed
XML RSS 2.0 Comments

Show tagged entries

xml best practices
xml books
xml conferences
xml dojo
xml dpc08
xml file_fortune
xml linux
xml mvc
xml pear
xml personal
xml php
xml programming
xml ubuntu
xml webinar
xml zendcon
xml zend framework
© 2004 - present, Matthew Weier O'Phinney
matthew-web <at> weierophinney.net