Sunday 10 May 2009

Testing Phing buildfiles with PHPUnit

While transforming some of the Ant buildfile refactorings described in Julian Simpson's seminal essay into a Phing context, it felt plainly wrong that I didn't have any tests for the buildfile to back me up on obtaining the pristine behaviour throughout the process. While Ant users can rely on an Apache project called AntUnit there are currently no tailor-made tools available for testing or verifying Phing buildfiles. Therefor I took a weekend off, locked myself in the stuffy lab, and explored the abilities to test Phing buildfiles respectively their included properties, targets and tasks with the PHPUnit testing framework. In case you'd like to take a peek at the emerged lab jottings, keep on scanning.

Introducing the buildfile under test

The buildfile that will be used as an example is kept simple, and contains several targets ranging from common ones like initializing the build environment by creating the necessary directories to more specific ones like pulling an external artifact from GitHub. To get an overview of the buildfile under test have a look at the following listing.
<?xml version="1.0" encoding="UTF-8"?>
<project name="test-example" default="build" basedir=".">

  <property name="project.basedir" value="." override="true" />
  <property name="github.repos.dir" value="${project.basedir}/build/github-repos" override="true" />

  <target name="clean" depends="clean-github-repos" description="Removes runtime build artifacts">
    <delete dir="${project.basedir}/build" includeemptydirs="true" verbose="false" failonerror="true" />
    <delete dir="${project.basedir}/build/reports" includeemptydirs="true" verbose="false" failonerror="true" />
  </target>

  <target name="clean-github-repos" description="Removes runtime build artifacts">
    <delete dir="${github.repos.dir}" includeemptydirs="true" failonerror="true" />
  </target>

  <target name="-log-build" description="A private target which should only be invoked internally">
    <!-- omitted -->
  </target>

  <target name="build" depends="clean" description="Builds the distributable product">
    <!-- omitted -->
  </target>

  <target name="database-setup" description="Sets up the database structure">
    <!-- omitted -->
  </target>

  <target name="init" description="Initalizes the build by creating directories etc">
    <mkdir dir="${project.basedir}/build/logs/performance/" />
    <mkdir dir="${project.basedir}/build/doc" />
    <mkdir dir="${project.basedir}/build/reports/phploc" />
  </target>

  <target name="init-ad-hoc-tasks" 
          description="Initalizes the ad hoc tasks for reusability in multiple targets">      
    <adhoc-task name="github-clone"><![CDATA[
  class Github_Clone extends Task {
    private $repository = null;
    private $destDirectory = null;

    function setRepos($repository) {
      $this->repository = $repository;
    }
    function setDest($destDirectory) {
      $this->destDirectory = $destDirectory;
    }
    function main() {
      // Get project name from repos Uri
      $projectName = str_replace('.git', '',
        substr(strrchr($this->repository, '/'), 1));

      $gitCommand = 'git clone ' . $this->repository . ' ' .
      $this->destDirectory . '/' . $projectName;

      exec(escapeshellcmd($gitCommand), $output, $return);

      if ($return !== 0) {
        throw new BuildException('Git clone failed');
      }
      $logMessage = 'Cloned Git repository ' . $this->repository .
        ' into ' . $this->destDirectory . '/' . $projectName;
      $this->log($logMessage);
    }
  }
]]></adhoc-task>

    <echo message="Intialized github-clone ad hoc task." />
  </target>

  <target name="github" depends="init-ad-hoc-tasks, clean-github-repos" 
          description="Clones given repositories from GitHub">
    <github-clone repos="git://github.com/raphaelstolt/phploc-phing.git" dest="${github.repos.dir}" />
  </target>
</project>

Testing the buildfile

All tests for the buildfile under test will be bundled, like 'normal' tests, in a class i.e. BuildfileTest extending the PHPUnit_Framework_TestCase class. When testing buildfiles it's possible to build some tests around the actual buildfile XML structure, by utilizing the xpath method of PHP's SimpleXMLElement class and asserting against the XPath query results, or around the dispatching of specific targets and asserting against the expected build artifacts. Furthermore these two identified groups, structure and artifact, can be used to organize the accumulating tests via PHPUnit's @group annotation.

To be able to dispatch specific build targets and feed them with properties if necessary I additionally developed a very basic build runner shown in the next code listing.
<?php
class Phing_Buildfile_Runner {

    private $_buildfilePath = null;

    public function __construct($buildfilePath) {
        if (!file_exists($buildfilePath)) {
            throw new Exception("Buildfile '{$buildfilePath}' doesn't exist");
        }
        $this->buildfilePath = realpath($buildfilePath); 
    }
    public function runTarget($targets = array(), $properties = array()) {
        $runTargetCommand = "phing " . "-f {$this->buildfilePath} ";
        if (count($targets) > 0) {
            foreach ($targets as $target) {
                $runTargetCommand.= $target . " ";
            }
        }
        if (count($properties) > 0) {
            foreach ($properties as $property => $value) {
                $runTargetCommand.= "-D{$property}={$value} ";
            }       
        }
        exec(escapeshellcmd($runTargetCommand), $output, $return);
        return array('output' => $output, 'return' => $return);
    }
}
Out of the box PHPUnit's assertion pool provides all the utilities to test buildfiles; although it would be cleaner to create domain specfic assertions for this testing domain this technique will be ignored for the sake of brevity.

After an initial 1000ft view on how to test buildfiles let's jump into the actual testing of a structural aspect of the buildfile under test. The test to come shows how to verify that a clean target is defined for playing along in the build orchestra by querying a XPath expression against the buildfile XML and asserting that a result is available.
/**
 * @test
 * @group structure
 */
public function buildfileShouldContainACleanTarget() {
    $xml = new SimpleXMLElement($this->_buildfileXml);
    $cleanElement = $xml->xpath("//target[@name='clean']");
    $this->assertTrue(count($cleanElement) > 0, "Buildfile doesn't contain a clean target");
}
The next artifactual test raises the bar an inch, by verifying that the defined init target of the build does initialize the build environment correctly, or to pick up the orchestra metaphor again that the specific instrument plays along and holds the directed tone. Therefor the build runner executes the target and afterwards asserts a list of expected artifacts against the current state of the build process.
/**
 * @test
 * @group artifact
 */
public function initTargetShouldCreateInitialBuildArtifacts() {
    $this->_isTearDownNecessary = true;
    $this->_buildfileRunner->runTarget(array('init'));
    $expectedInitArtifacts = array(
        "{$this->_buildfileBasedir}/build",
        "{$this->_buildfileBasedir}/build/logs/performance/",
        "{$this->_buildfileBasedir}/build/doc",
        "{$this->_buildfileBasedir}/build/reports"
    );

    foreach ($expectedInitArtifacts as $artifact) {
        $this->assertFileExists($artifact, "Expected file '{$artifact}' doesn't exist");
    }
}
The next code listing shows the whole picture of the BuildfileTest class containing additional test methods verifying different aspects of the buildfile under test and also the innards of the setup and teardown method.

The main assignment of the setup method is to load the XML of the buildfile under test and to intialize the build runner so an instance is available for an use in artifactual tests. The teardown method its sole responsibility is to reset the build state by running the clean target of the buildfile.
<?php
require_once 'PHPUnit/Framework.php';
require_once 'Phing/Buildfile/Runner.php';

class ExampleBuildfileTest extends PHPUnit_Framework_TestCase {

    protected $_buildfileXml = null;
    protected $_buildfileName = null;
    protected $_buildfileBasedir = null;
    protected $_buildfileRunner = null;
    protected $_isTearDownNecessary = false;

    protected function setUp() {
        $this->_buildfileName = realpath('../../build.xml');        
        $this->_buildfileBasedir = dirname($this->_buildfileName);
        $this->_buildfileXml = file_get_contents($this->_buildfileName);
        $this->_buildfileRunner = new Phing_Buildfile_Runner(
        $this->_buildfileName);
    }

    protected function tearDown() {
        if ($this->_isTearDownNecessary) {
            $this->_buildfileRunner->runTarget(array('clean'));
        }
    }

   /**
    * @test
    * @group structure
    */
    public function targetBuildShouldBeTheDefaultTarget() {
        $xml = new SimpleXMLElement($this->_buildfileXml);
        $xpath = "//@default";
        $defaultElement = $xml->xpath($xpath);
        $this->assertSame('build', trim($defaultElement[0]->default), 
            "Buildfile doesn't have a default target named 'build'"
        );
    }
   /**
    * @test
    * @group structure
    */
    public function propertyGithubReposDirShouldBeSet() {
        $xml = new SimpleXMLElement($this->_buildfileXml);
        $xpath = "//property[@name='github.repos.dir']/@value";
        $valueElement = $xml->xpath($xpath);
        $this->assertTrue($valueElement[0] instanceof SimpleXMLElement, 
            "Buildfile doesn't contain a 'github.repos.dir' property"
        );
        $this->assertGreaterThan(1, strlen($valueElement[0]->value));
    }
   /**
    * @test
    * @group structure
    */
    public function buildfileShouldContainACleanTarget() {
        $xml = new SimpleXMLElement($this->_buildfileXml);
        $cleanElement = $xml->xpath("//target[@name='clean']");
        $this->assertTrue(count($cleanElement) > 0, 
            "Buildfile doesn't contain a clean target"
        );
    }
   /**
    * @test
    * @group structure
    */
    public function targetLogBuildShouldBeAPrivateOne() {
        $xml = new SimpleXMLElement($this->_buildfileXml);
        $nameElement = $xml->xpath("//target[@name='-log-build']");
        $this->assertTrue(count($nameElement) > 0, 
            'Log build target is not a private target'
        );
    }
    /**
     * @test
     * @group structure
     */
    public function targetBuildShouldDependOnCleanTarget() {
        $xml = new SimpleXMLElement($this->_buildfileXml);
        $xpath = "//target[@name='build']/@depends";
        $dependElement = $xml->xpath($xpath);
        $this->assertTrue(count($dependElement) > 0, 
            'Target build contains no depends attribute'
        );
        $dependantTasks = array_filter(explode(' ',
            trim($dependElement[0]->depends))
        );
        $this->assertContains('clean', $dependantTasks, "Target build doesn't 
            depend on the clean target"
        );
    }
    /**
     * @test
     * @group structure
     */
    public function allDefinedTargetsShouldHaveADescriptionAttribute() {
        $xml = new SimpleXMLElement($this->_buildfileXml);
        $xpath = "//target";
        $targetElements = $xml->xpath($xpath);
        $describedTargetElements = array();
        foreach ($targetElements as $index => $targetElement) {
            $targetDescription = trim($targetElement->attributes()->description); 
            if ($targetDescription !== '') {
                $describedTargetElements[] = $targetDescription;
            }
        }
        $this->assertEquals(count($targetElements),
            count($describedTargetElements), 
            'Description not for all targets set'
        );
    }
    /**
     * @test
     * @group structure
     */
    public function githubCloneAdhocTaskShouldBeDefined() {
        $xml = new SimpleXMLElement($this->_buildfileXml);
        $xpath = "//target[@name='init-ad-hoc-tasks']/adhoc-task";
        $adhocElement = $xml->xpath($xpath);
        $this->assertSame('github-clone',
            trim($adhocElement[0]->attributes()->name), 
            "Ad hoc task 'github-clone' isn't defined"
        );
    }
    /**
    * @test 
    * @group artifact
    */
    public function initTargetShouldCreateInitialBuildArtifacts() {
        $this->_isTearDownNecessary = true;
        $this->_buildfileRunner->runTarget(array('init'));

        $expectedInitArtifacts = array(
            "{$this->_buildfileBasedir}/build", 
            "{$this->_buildfileBasedir}/build/logs/performance/", 
            "{$this->_buildfileBasedir}/build/doc",
            "{$this->_buildfileBasedir}/build/reports"
        );

        foreach ($expectedInitArtifacts as $artifact) {
            $this->assertFileExists($artifact, 
                "Expected file '{$artifact}' doesn't exist"
            );
        }
    }
    /**
     * @test
     * @group artifact
     */
    public function sqlFilesForDatabaseSetupTargetShouldBeAvailable() {
        $expectedSqlFiles = array(
            "{$this->_buildfileBasedir}/sqlfiles", 
            "{$this->_buildfileBasedir}/sqlfiles/session-storage.sql", 
            "{$this->_buildfileBasedir}/sqlfiles/acl.sql", 
            "{$this->_buildfileBasedir}/sqlfiles/log.sql"
        );

        foreach ($expectedSqlFiles as $sqlFile) {
            $this->assertFileExists($sqlFile, 
                "SQL file '{$sqlFile}' doesn't exist"
            );
        }
    }
    /**
     * @test
     * @group artifact
     */
    public function githubTargetShouldFetchExpectedRepository() {
        $this->_isTearDownNecessary = true;
        $this->_buildfileRunner->runTarget(array('github'));
        $expectedGitRepository = "{$this->_buildfileBasedir}/build/"
            . "github-repos/phploc-phing/.git";
        $this->assertFileExists($expectedGitRepository, 
            "Github target doesn't fetch the expected 'phploc-phing' repository"
        );
    }
}
The outro screenshot shows the above stated test class run against the example buildfile on a Mac OS X system utilizing the --colors option; which by the way comes in really handy in combination with Stakeout.rb during the process of refactoring or extending/creating buildfiles the test-driven way.

PHPUnit console output

No comments: