Created
October 4, 2011 14:04
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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