Implementation of an alternative PHPUnit plugin:

- Reformat the error output.
- Display collapsed trace in the Information tab widget.
- Handle incomplete tests in the results.
- Unit tests for all new classes.
- Display raw phpunit output.
This commit is contained in:
Pablo Tejada 2017-01-05 17:59:58 +07:00 committed by Dmitry Khomutov
commit c32a520b91
13 changed files with 1385 additions and 4 deletions

View file

@ -0,0 +1,272 @@
<?php
/**
* PHPCI - Continuous Integration for PHP
*
* @copyright Copyright 2014, Block 8 Limited.
* @license https://github.com/Block8/PHPCI/blob/master/LICENSE.md
* @link https://www.phptesting.org/
*/
namespace PHPCI\Plugin\Option;
/**
* Class PhpUnitOptions validates and parse the option for the PhpUnitV2 plugin
*
* @author Pablo Tejada <pablo@ptejada.com>
* @package PHPCI
* @subpackage Plugin
*/
class PhpUnitOptions
{
protected $options;
protected $arguments = array();
public function __construct($options)
{
$this->options = $options;
}
/**
* Remove a command argument
*
* @param $argumentName
*
* @return $this
*/
public function removeArgument($argumentName)
{
unset($this->arguments[$argumentName]);
return $this;
}
/**
* Combine all the argument into a string for the phpunit command
*
* @return string
*/
public function buildArgumentString()
{
$argumentString = '';
foreach ($this->getCommandArguments() as $argumentName => $argumentValues) {
$prefix = $argumentName[0] == '-' ? '' : '--';
if (!is_array($argumentValues)) {
$argumentValues = array($argumentValues);
}
foreach ($argumentValues as $argValue) {
$postfix = ' ';
if (!empty($argValue)) {
$postfix = ' "' . $argValue . '" ';
}
$argumentString .= $prefix . $argumentName . $postfix;
}
}
return $argumentString;
}
/**
* Get all the command arguments
*
* @return string[]
*/
public function getCommandArguments()
{
/*
* Return the full list of arguments
*/
return $this->parseArguments()->arguments;
}
/**
* Parse the arguments from the config options
*
* @return $this
*/
private function parseArguments()
{
if (empty($this->arguments)) {
/*
* Parse the arguments from the YML options file
*/
if (isset($this->options['args'])) {
$rawArgs = $this->options['args'];
if (is_array($rawArgs)) {
$this->arguments = $rawArgs;
} else {
/*
* Try to parse old argument in a single string
*/
preg_match_all('/--([a-z\-]+)\s?("?[^-]{2}[^"]*"?)?/', (string)$rawArgs, $argsMatch);
if (!empty($argsMatch) && sizeof($argsMatch) > 2) {
foreach ($argsMatch[1] as $index => $argName) {
$this->addArgument($argName, $argsMatch[2][$index]);
}
}
}
}
/*
* Handles command aliases outside of the args option
*/
if (isset($this->options['coverage'])) {
$this->addArgument('coverage-html', $this->options['coverage']);
}
/*
* Handles command aliases outside of the args option
*/
if (isset($this->options['config'])) {
$this->addArgument('configuration', $this->options['config']);
}
}
return $this;
}
/**
* Add an argument to the collection
* Note: adding argument before parsing the options will prevent the other options from been parsed.
*
* @param string $argumentName
* @param string $argumentValue
*/
public function addArgument($argumentName, $argumentValue)
{
if (isset($this->arguments[$argumentName])) {
if (!is_array($this->arguments[$argumentName])) {
// Convert existing argument values into an array
$this->arguments[$argumentName] = array($this->arguments[$argumentName]);
}
// Appends the new argument to the list
$this->arguments[$argumentName][] = $argumentValue;
} else {
// Adds new argument
$this->arguments[$argumentName] = $argumentValue;
}
}
/**
* Get the list of directory to run phpunit in
*
* @return string[] List of directories
*/
public function getDirectories()
{
$directories = $this->getOption('directory');
if (is_string($directories)) {
$directories = array($directories);
} else {
if (is_null($directories)) {
$directories = array();
}
}
return is_array($directories) ? $directories : array($directories);
}
/**
* Get an option if defined
*
* @param $optionName
*
* @return string[]|string|null
*/
public function getOption($optionName)
{
if (isset($this->options[$optionName])) {
return $this->options[$optionName];
}
return null;
}
/**
* Get the directory to execute the command from
*
* @return mixed|null
*/
public function getRunFrom()
{
return $this->getOption('run_from');
}
/**
* Ge the directory name where tests file reside
*
* @return string|null
*/
public function getTestsPath()
{
return $this->getOption('path');
}
/**
* Get the PHPUnit configuration from the options, or the optional path
*
* @param string $altPath
*
* @return string[] path of files
*/
public function getConfigFiles($altPath = '')
{
$configFiles = $this->getArgument('configuration');
if (empty($configFiles)) {
$configFile = self::findConfigFile($altPath);
if ($configFile) {
$configFiles[] = $configFile;
}
}
return $configFiles;
}
/**
* Get options for a given argument
*
* @param $argumentName
*
* @return string[] All the options for given argument
*/
public function getArgument($argumentName)
{
$this->parseArguments();
if (isset($this->arguments[$argumentName])) {
return is_array(
$this->arguments[$argumentName]
) ? $this->arguments[$argumentName] : array($this->arguments[$argumentName]);
}
return array();
}
/**
* Find a PHPUnit configuration file in a directory
*
* @param string $buildPath The path to configuration file
*
* @return null|string
*/
public static function findConfigFile($buildPath)
{
$files = array(
'phpunit.xml',
'phpunit.xml.dist',
'tests/phpunit.xml',
'tests/phpunit.xml.dist',
);
foreach ($files as $file) {
if (is_file($buildPath . $file)) {
return $file;
}
}
return null;
}
}

View file

@ -0,0 +1,207 @@
<?php
/**
* PHPCI - Continuous Integration for PHP
*
* @copyright Copyright 2014, Block 8 Limited.
* @license https://github.com/Block8/PHPCI/blob/master/LICENSE.md
* @link https://www.phptesting.org/
*/
namespace PHPCI\Plugin;
use PHPCI;
use PHPCI\Builder;
use PHPCI\Helper\Lang;
use PHPCI\Model\Build;
use PHPCI\Model\BuildError;
use PHPCI\Plugin\Option\PhpUnitOptions;
use PHPCI\Plugin\Util\PhpUnitResult;
/**
* PHP Unit Plugin V2 - Extends the functionality of the original PHP Unit plugin
*
* @author Pablo Tejada <pablo@ptejada.com>
* @package PHPCI
* @subpackage Plugins
*/
class PhpUnitV2 implements PHPCI\Plugin, PHPCI\ZeroConfigPlugin
{
protected $phpci;
protected $build;
/** @var string[] Raw options from the PHPCI config file */
protected $options = array();
/**
* Standard Constructor
* $options['config'] Path to a PHPUnit XML configuration file.
* $options['run_from'] The directory where the phpunit command will run from when using 'config'.
* $options['coverage'] Value for the --coverage-html command line flag.
* $options['directory'] Optional directory or list of directories to run PHPUnit on.
* $options['args'] Command line args (in string format) to pass to PHP Unit
*
* @param Builder $phpci
* @param Build $build
* @param string[] $options
*/
public function __construct(Builder $phpci, Build $build, array $options = array())
{
$this->phpci = $phpci;
$this->build = $build;
$this->options = new PhpUnitOptions($options);
}
/**
* Check if the plugin can be executed without any configurations
*
* @param $stage
* @param Builder $builder
* @param Build $build
*
* @return bool
*/
public static function canExecute($stage, Builder $builder, Build $build)
{
if ($stage == 'test' && !is_null(PhpUnitOptions::findConfigFile($build->getBuildPath()))) {
return true;
}
return false;
}
/**
* Runs PHP Unit tests in a specified directory, optionally using specified config file(s).
*/
public function execute()
{
$xmlConfigFiles = $this->options->getConfigFiles($this->build->getBuildPath());
$directories = $this->options->getDirectories();
if (empty($xmlConfigFiles) && empty($directories)) {
$this->phpci->logFailure(Lang::get('phpunit_fail_init'));
return false;
}
$success = array();
// Run any directories
if (!empty($directories)) {
foreach ($directories as $directory) {
$success[] = $this->runDir($directory);
}
} else {
// Run any config files
if (!empty($xmlConfigFiles)) {
foreach ($xmlConfigFiles as $configFile) {
$success[] = $this->runConfigFile($configFile);
}
}
}
return !in_array(false, $success);
}
/**
* Run the PHPUnit tests in a specific directory or array of directories.
*
* @param $directory
*
* @return bool|mixed
*/
protected function runDir($directory)
{
$options = clone $this->options;
$buildPath = $this->build->getBuildPath() . DIRECTORY_SEPARATOR;
$currentPath = getcwd();
// Change the directory
chdir($buildPath);
// Save the results into a json file
$jsonFile = tempnam(dirname($buildPath), 'jLog_');
$options->addArgument('log-json', $jsonFile);
// Removes any current configurations files
$options->removeArgument('configuration');
$arguments = $this->phpci->interpolate($options->buildArgumentString());
$cmd = $this->phpci->findBinary('phpunit') . ' %s "%s"';
$success = $this->phpci->executeCommand($cmd, $arguments, $directory);
// Change to che original path
chdir($currentPath);
$this->processResults($jsonFile);
return $success;
}
/**
* Run the tests defined in a PHPUnit config file.
*
* @param $configFile
*
* @return bool|mixed
*/
protected function runConfigFile($configFile)
{
$options = clone $this->options;
$runFrom = $options->getRunFrom();
$buildPath = $this->build->getBuildPath() . DIRECTORY_SEPARATOR;
if ($runFrom) {
$originalPath = getcwd();
// Change the directory
chdir($buildPath . $runFrom);
}
// Save the results into a json file
$jsonFile = tempnam($this->phpci->buildPath, 'jLog_');
$options->addArgument('log-json', $jsonFile);
// Removes any current configurations files
$options->removeArgument('configuration');
// Only the add the configuration file been passed
$options->addArgument('configuration', $buildPath . $configFile);
$arguments = $this->phpci->interpolate($options->buildArgumentString());
$cmd = $this->phpci->findBinary('phpunit') . ' %s %s';
$success = $this->phpci->executeCommand($cmd, $arguments, $options->getTestsPath());
if (!empty($originalPath)) {
// Change to che original path
chdir($originalPath);
}
$this->processResults($jsonFile);
return $success;
}
/**
* Saves the test results
*
* @param string $jsonFile
*
* @throws \Exception If the failed to parse the JSON file
*/
protected function processResults($jsonFile)
{
if (is_file($jsonFile)) {
$parser = new PhpUnitResult($jsonFile, $this->build->getBuildPath());
$this->build->storeMeta('phpunit-data', $parser->parse()->getResults());
$this->build->storeMeta('phpunit-errors', $parser->getFailures());
foreach ($parser->getErrors() as $error) {
$severity = $error['severity'] == $parser::SEVERITY_ERROR ? BuildError::SEVERITY_CRITICAL : BuildError::SEVERITY_HIGH;
$this->build->reportError(
$this->phpci, 'php_unit', $error['message'], $severity, $error['file'], $error['line']
);
}
} else {
throw new \Exception('JSON output file does not exist: ' . $jsonFile);
}
}
}

View file

@ -0,0 +1,227 @@
<?php
/**
* PHPCI - Continuous Integration for PHP
*
* @copyright Copyright 2014, Block 8 Limited.
* @license https://github.com/Block8/PHPCI/blob/master/LICENSE.md
* @link https://www.phptesting.org/
*/
namespace PHPCI\Plugin\Util;
/**
* Class PhpUnitResult parses the results for the PhpUnitV2 plugin
*
* @author Pablo Tejada <pablo@ptejada.com>
* @package PHPCI
* @subpackage Plugin
*/
class PhpUnitResult
{
const EVENT_TEST = 'test';
const EVENT_TEST_START = 'testStart';
const EVENT_SUITE_START = 'suiteStart';
const SEVERITY_PASS = 'success';
const SEVERITY_FAIL = 'fail';
const SEVERITY_ERROR = 'error';
const SEVERITY_SKIPPED = 'skipped';
protected $options;
protected $arguments = array();
protected $results;
protected $failures = 0;
protected $errors = array();
public function __construct($outputFile, $buildPath = '')
{
$this->outputFile = $outputFile;
$this->buildPath = $buildPath;
}
/**
* Parse the results
*
* @return $this
* @throws \Exception If fails to parse the output
*/
public function parse()
{
$rawResults = file_get_contents($this->outputFile);
if ($rawResults[0] == '{') {
$fixedJson = '[' . str_replace('}{', '},{', $rawResults) . ']';
$events = json_decode($fixedJson, true);
} else {
$events = json_decode($rawResults, true);
}
// Reset the parsing variables
$this->results = array();
$this->errors = array();
$this->failures = 0;
if (is_array($events)) {
foreach ($events as $event) {
if ($event['event'] == self::EVENT_TEST) {
$this->results[] = $this->parseEvent($event);
}
}
} else {
throw new \Exception('Failed to parse the JSON output.');
}
return $this;
}
/**
* Parse a test event
*
* @param array $event
*
* @return string[]
*/
protected function parseEvent($event)
{
list($pass, $severity) = $this->getStatus($event);
$data = array(
'pass' => $pass,
'severity' => $severity,
'message' => $this->buildMessage($event),
'trace' => $pass ? array() : $this->buildTrace($event),
'output' => $event['output'],
);
if (!$pass) {
$this->failures++;
$this->addError($data, $event);
}
return $data;
}
/**
* Build the status of the event
*
* @param $event
*
* @return mixed[bool,string] - The pass and severity flags
* @throws \Exception
*/
protected function getStatus($event)
{
$status = $event['status'];
switch ($status) {
case 'fail':
$pass = false;
$severity = self::SEVERITY_FAIL;
break;
case 'error':
if (strpos($event['message'], 'Skipped') === 0 || strpos($event['message'], 'Incomplete') === 0) {
$pass = true;
$severity = self::SEVERITY_SKIPPED;
} else {
$pass = false;
$severity = self::SEVERITY_ERROR;
}
break;
case 'pass':
$pass = true;
$severity = self::SEVERITY_PASS;
break;
default:
throw new \Exception("Unexpected PHPUnit test status: {$status}");
break;
}
return array($pass, $severity);
}
/**
* Build the message string for an event
*
* @param array $event
*
* @return string
*/
protected function buildMessage($event)
{
$message = $event['test'];
if ($event['message']) {
$message .= PHP_EOL . $event ['message'];
}
return $message;
}
/**
* Build a string base trace of the failure
*
* @param array $event
*
* @return string[]
*/
protected function buildTrace($event)
{
$formattedTrace = array();
if (!empty($event['trace'])) {
foreach ($event['trace'] as $step){
$line = str_replace($this->buildPath, '', $step['file']) . ':' . $step['line'];
$formattedTrace[] = $line;
}
}
return $formattedTrace;
}
/**
* Saves additional info for a failing test
*
* @param array $data
* @param array $event
*/
protected function addError($data, $event)
{
$firstTrace = end($event['trace']);
reset($event['trace']);
$this->errors[] = array(
'message' => $data['message'],
'severity' => $data['severity'],
'file' => str_replace($this->buildPath, '', $firstTrace['file']),
'line' => $firstTrace['line'],
);
}
/**
* Get the parse results
*
* @return string[]
*/
public function getResults()
{
return $this->results;
}
/**
* Get the total number of failing tests
*
* @return int
*/
public function getFailures()
{
return $this->failures;
}
/**
* Get the tests with failing status
*
* @return string[]
*/
public function getErrors()
{
return $this->errors;
}
}