Skip to content

Instantly share code, notes, and snippets.

@alkemann
Created October 4, 2011 14:04
Show Gist options
  • Save alkemann/1261721 to your computer and use it in GitHub Desktop.
Save alkemann/1261721 to your computer and use it in GitHub Desktop.
Run PHPUnit tests in browser for ease of reading, running subsets or debuggin.
<?php
/**
* Place this file in the webroot of your project,
* access it through http://project.dev/tests.php or http://localhost/project/webroot/tests.php
*
* Assuming you have defined a BASEPATH to the root of your project and
*/
define('BASEPATH', __DIR__.'/..'); // root of project, this one assumes /project/webroot/tests.php
define('LOCATION', BASEPATH .'/app'); // in a SF2 project, this is where the phpunit.xml file is
// the location can also be set as GET, ie: tests.php?loc=vendor/bundles/RedpillLinpro/GamineBundle
/** -- Changes should not be needed below -- **/
/**
* Utility class, runs and analyzes phpunit tests, outputs as html
*
* It is configurable to add/change phpunit options through constructor.
*
* It uses tempnam(sys_get_temp_dir(), 'log_') to write and read
* tempoary files during run. PHP process needs write access to this place.
*
* It also writes err to tempnam(sys_get_temp_dir(), 'err_').
*
*/
class PhpUnitRunner
{
private $project = null;
private $tests = array();
private $config = array();
private $command = null;
private $__defaults = array(
'options' => array(
'strict' => false,
'verbose' => false,
),
'command' => 'phpunit -c {:path} --log-json {:tmpfile}',
);
private $__raw = null;
private $__err = null;
private $__output = '';
private $__return = '';
private $__overflow = array();
/**
* Merge default options with constructor options. Inject options from $_GET
* Sets up the command line command to be run later
*
* @param array $options
*/
public function __construct($options = array()) {
$this->config = $this->__defaults + $options ;
$this->config['logfile'] = tempnam(sys_get_temp_dir(), 'log_');
$this->config['errfile'] = tempnam(sys_get_temp_dir(), 'err_');
if (isset($_GET['loc'])) {
$this->config['path'] = BASEPATH.'/'.$_GET['loc'];
} elseif (!isset($this->config['path'])) $this->config['path'] = LOCATION;
$command = str_replace('{:path}', $this->config['path'], $this->config['command']);
$command = str_replace('{:tmpfile}', $this->config['logfile'], $command);
if (isset($_GET['filter'])) {
$this->config['options']['filter'] = $_GET['filter'];
}
foreach ($this->config['options'] as $option => $v) {
if ($v) {
$command .= " --$option $v";
} else {
$command .= " --$option";
}
}
$this->command = $command . ' 2>'.$this->config['errfile'];
}
/**
* executes the commandline
*/
public function run() {
exec($this->command, $output, $return);
$this->__output = $output;
$this->__return = $return;
}
/**
* Reads the two temporary files, json decode the log and sends it to analyze
*/
public function extract() {
$this->__raw = file_get_contents($this->config['logfile']);
unlink($this->config['logfile']);
$this->__err = file_get_contents($this->config['errfile']);
unlink($this->config['errfile']);
$json_strings = array();
$pieces = explode('}{', $this->__raw);
$count = sizeof($pieces);
foreach ($pieces as $k => $piece) {
if ($k == 0) {
$json_strings[] = $piece.'}';
} elseif ($k == ($count-1)) {
$json_strings[] = '{'.$piece;
} else {
$json_strings[] = '{'.$piece.'}';
}
}
$json_objects = array();
foreach ($json_strings as $string) {
$json_objects[] = json_decode($string);
}
foreach ($json_objects as $k => $obj) {
if (is_null($obj)) {
if ($this->__err) {
return;
} else
throw new \Exception("Failed to json_decode : \n<br>" . $this->__raw);
}
}
$this->analyze($json_objects);
}
/**
* Analyze and populate ::$project and ::$tests based on json objects from the phpunit log file
* It also grabs text from the output to add more info and context to the tests
*
* @param array $objects
*/
public function analyze($objects) {
$this->project = array_shift($objects);
$this->project->testCount = 0;
$classes = array();
$passes = 0;
foreach ($objects as $test) {
if ($test->event == 'suiteStart') {
$test->tests = array();
$test->passes = 0;
$classes[$test->suite] = $test;
} elseif ( $test->event == 'testStart') {
continue;
} else {
$classes[$test->suite]->tests[$test->test] = $test;
if ($test->status == 'pass') {
$classes[$test->suite]->passes++;
$passes++;
}
}
}
foreach ($classes as $class => $testClass) {
if (empty($testClass->tests)) { unset($classes[$class]); continue; }
$testClass->testCount = count($testClass->tests);
$testClass->status = $testClass->passes == $testClass->testCount ? 'pass' : 'fail';
$this->project->testCount += $testClass->testCount;
}
$mode = 'assert'; $i = 0;
while ($i < (sizeof($this->__output)-1)) {
$content = trim($this->__output[$i]);
if (empty($content)) { $i++; continue; }
$matches = null;
preg_match('/^There (was|were) \d+ (failure|error|incomplete test)[s]{0,1}:$/', $content, $matches);
if (!empty($matches)) $mode = $matches[2];
switch ($mode) {
case 'incomplete test':
// do nothing for this part.
break;
case 'error':
// do nothing, trace in test obj
break;
case 'failure':
$matches = null;
if (preg_match("/^\d+\) ((.*)\:\:.*)$/", $content, $matches)) {
$test_func = $matches[1];
$test_class = $matches[2];
} elseif (preg_match("/.*\d+$/", $content, $m)) {
$classes[$test_class]->tests[$test_func]->failAt = $m[0];
}
break;
default:
case 'assert':
if (isset($classes[$content])) {
$classes[$content]->output = trim($this->__output[++$i], ".FIE ");
} else {
$content = trim($content,'_-.');
if (!empty($content))
$this->__overflow[] = $content;
}
break;
}
$i++;
}
$this->project->passes = $passes;
$this->project->status = ($passes == $this->project->testCount) ? 'pass' : 'fail';
$this->tests = $classes;
}
/**
* Create HTML output based on the analyzed project and tests.
*
* @return string generated HTML
*/
public function out() {
$ret = '<div class="project">';
if ($this->project) {
$ret .= '<h1><a href="tests.php'.((isset($_GET['loc'])) ? '?class='.$_GET['loc'] : '').'">'.$this->project->suite.'</a></h1>';
$ret .= '<h3 class="'.$this->project->status.'">'.$this->project->passes.' of '.$this->project->testCount.' tests passed</h3>';
} else {
$ret .= '<h1><a href="tests.php'.((isset($_GET['loc'])) ? '?class='.$_GET['loc'] : '').'">Tests</a></h1>';
}
$ret .= '<p>'.array_pop($this->__output) . '<br>' . array_pop($this->__output).'</p>';
$ret .= '<hr>';
if ($this->__err) {
$ret .= '<h3 class="fail">One or more tests failed with FATAL error:</h3>';
$ret .= "<p class='error'>$this->__err</p>";
$ret .= '<hr>';
}
foreach ($this->tests as $testClass) {
$ret .= '<div class="suite"><h2>\ ';
$testParts = explode('\\', $testClass->suite);
foreach ($testParts as $filter) {
$gets = 'filter='.$filter . ((isset($_GET['loc'])) ? '&class='.$_GET['loc'] : '');
$ret .= '<a href="tests.php?'.$gets.'">'.$filter.'</a> \\ ';
}
$ret = substr($ret, 0, -3);
$ret .= '</h2><h3 class="'.$testClass->status.'">'.$testClass->passes.' of '. count($testClass->tests).' tests passed</h3>';
foreach ($testClass->tests as $test) {
list(,$method) = explode('::', $test->test, 2);
$time = round($test->time, 4);
if ($test->status != 'pass') {
$msg = htmlspecialchars($test->message);
$ret .= '<div class="test '.$test->status.'"><h4>';
$gets = 'filter='.$method . ((isset($_GET['loc'])) ? '&class='.$_GET['loc'] : '');
$ret .= '<a href="tests.php?'.$gets.'">'.$method.'</a> | '.$test->status.' | ';
if ($test->status == 'fail') $ret .= '<small>'.substr($test->failAt, strlen(realpath(__DIR__.'/..'))).'</small> | ';
$ret .= '<small>time: '.$time.'sec</small></h4>';
if ($test->status == 'fail' && substr($msg,0,6) !== 'Failed') {
list($desc,$msg) = explode("\n", $msg, 2);
$ret .= '<br><h5><strong>'.$desc.'</strong></h5>';
$msg = substr($msg, 0, -1 * strlen($desc) - 2);
}
$ret .= '<pre>'. trim($msg,'.') .'</pre>';
if (!empty($test->trace)) {
$trace = array_shift($test->trace); $start = strlen(realpath(__DIR__.'/..'));
$ret .= '<p class="trace">'.substr($trace->file,$start).' : '.$trace->line.'</p>';
}
$ret .= '</div>';
} else {
$ret .= '<div class="test '.$test->status.'"><h4>';
$gets = 'filter='.$method . ((isset($_GET['loc'])) ? '&class='.$_GET['loc'] : '');
$ret .= '<a href="tests.php?'.$gets.'">'.$method.'</a>';
$ret .= ' | pass | <small>time: '.$time.'sec</small></h4></div>';
}
}
if (isset($testClass->output)) {
$ret .= '<div class="output">'.$testClass->output.'</div>';
}
$ret .= '</div>';
}
$ret .= '<div class="overflow">'.implode("<br>\n", $this->__overflow).'</div>';
return $ret;
}
}
// Create phprunner instance
$phpuniter = new PhpUnitRunner();
// Run it
$phpuniter->run();
// Extract log and error files
$phpuniter->extract();
?>
<html>
<head>
<title>PHPUnitTests - <?php echo isset($_GET['filter']) ?$_GET['filter']: '';?></title>
<style>
.project {padding: 0 1em; }
.suite {margin: 1em 0;padding:0.4em 1em;border:1px dashed #AAA;}
.test {padding: 0.5em 1em; margin-bottom: 1em;}
a { color: blue }
h3.pass { color: green; }
h3.fail { color: red; }
.test h4 { margin: 0; padding: 0; font-weight:normal; }
.test h4 a { font-weight: bold; }
.test h5 { margin: 0; padding: 0; font-weight:normal; }
div.pass { background-color: lightgreen; }
div.fail { background-color: pink; }
div.error { background-color: red; color: #DDD; font-weight: bold; }
p.error { background-color: red;color:white;padding: 1em; }
div.overflow { font-size: 9px; color: grey; margin-left: 2.1em; }
p.trace {margin:0;padding:0; font-size:0.75em;}
</style>
</head>
<body>
<?php echo $phpuniter->out(); ?>
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment