Files
moodle/lib/simpletestcoveragelib.php
T

428 lines
15 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Extend simpletest to support code coverage analysis
*
* This package contains a collection of classes that, extending standard simpletest
* ones, provide code coverage analysis to already existing tests. Also there are some
* utility functions designed to make the coverage control easier.
*
* @package moodlecore
* @subpackage simpletestcoverage
* @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/// TODO: implement safe and simple file server to dataroot/coverage
/// TODO: serialize summary in reporter (to render it inline)
/// TODO: provide one helper function to show links from test page to coverage report
/// TODO: use dirroot-based names/links in the report
/**
* Includes
*/
require_once(dirname(__FILE__) . '/../config.php');
require_once($CFG->libdir.'/tablelib.php');
require_once($CFG->libdir . '/simpletestlib.php');
require_once($CFG->dirroot . '/' . $CFG->admin . '/report/unittest/ex_simple_test.php');
require_once($CFG->libdir . '/spikephpcoverage/src/CoverageRecorder.php');
require_once($CFG->libdir . '/spikephpcoverage/src/reporter/HtmlCoverageReporter.php');
/**
* AutoGroupTest class extension supporting code coverage
*
* This class extends AutoGroupTest to add the funcitionalities
* necessary to run code coverage, allowing its activation and
* specifying included / excluded files to be analysed
*
* @package moodlecore
* @subpackage simpletestcoverage
* @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class autogroup_test_coverage extends AutoGroupTest {
private $performcoverage; // boolean
private $coveragename; // title of the coverage report
private $coveragedir; // dir, relative to dataroot/coverage where the report will be saved
private $includecoverage; // paths to be analysed by the coverage report
private $excludecoverage; // paths to be excluded from the coverage report
function __construct($showsearch, $test_name = null,
$performcoverage = false, $coveragename = 'Code Coverage Report',
$coveragedir = 'report') {
parent::__construct($showsearch, $test_name);
$this->performcoverage = $performcoverage;
$this->coveragename = $coveragename;
$this->coveragedir = $coveragedir;
$this->includecoverage = array();
$this->excludecoverage = array();
}
public function addTestFile($file, $internalcall = false) {
global $CFG;
if ($this->performcoverage) {
$refinfo = moodle_reflect_file($file);
require_once($file);
if ($refinfo->classes) {
foreach ($refinfo->classes as $class) {
$reflection = new ReflectionClass($class);
if ($staticprops = $reflection->getStaticProperties()) {
if (isset($staticprops['includecoverage']) && is_array($staticprops['includecoverage'])) {
foreach ($staticprops['includecoverage'] as $toinclude) {
$this->add_coverage_include_path($toinclude);
}
}
if (isset($staticprops['excludecoverage']) && is_array($staticprops['excludecoverage'])) {
foreach ($staticprops['excludecoverage'] as $toexclude) {
$this->add_coverage_exclude_path($toexclude);
}
}
}
}
// Automatically add the test dir itself, so nothing will be covered there
$this->add_coverage_exclude_path(dirname($file));
}
}
parent::addTestFile($file, $internalcall);
}
public function add_coverage_include_path($path) {
global $CFG;
$path = $CFG->dirroot . '/' . $path; // Convert to full path
if (!in_array($path, $this->includecoverage)) {
array_push($this->includecoverage, $path);
}
}
public function add_coverage_exclude_path($path) {
global $CFG;
$path = $CFG->dirroot . '/' . $path; // Convert to full path
if (!in_array($path, $this->excludecoverage)) {
array_push($this->excludecoverage, $path);
}
}
/**
* Run the autogroup_test_coverage using one internally defined code coverage reporter
* automatically generating the coverage report. Only supports one instrumentation
* to be executed and reported.
*/
public function run(&$simpletestreporter) {
global $CFG;
if (moodle_coverage_recorder::can_run_codecoverage() && $this->performcoverage) {
// Testing with coverage
$covreporter = new moodle_coverage_reporter($this->coveragename, $this->coveragedir);
$covrecorder = new moodle_coverage_recorder($covreporter);
$covrecorder->setIncludePaths($this->includecoverage);
$covrecorder->setExcludePaths($this->excludecoverage);
$covrecorder->start_instrumentation();
parent::run($simpletestreporter);
$covrecorder->stop_instrumentation();
$covrecorder->generate_report();
} else {
// Testing without coverage
parent::run($simpletestreporter);
}
}
/**
* Run the autogroup_test_coverage tests using one externally defined code coverage reporter
* allowing further process of coverage data once tests are over. Supports multiple
* instrumentations (code coverage gathering sessions) to be executed.
*/
public function run_with_external_coverage(&$simpletestreporter, &$covrecorder) {
if (moodle_coverage_recorder::can_run_codecoverage() && $this->performcoverage) {
$covrecorder->setIncludePaths($this->includecoverage);
$covrecorder->setExcludePaths($this->excludecoverage);
$covrecorder->start_instrumentation();
parent::run($simpletestreporter);
$covrecorder->stop_instrumentation();
} else {
// Testing without coverage
parent::run($simpletestreporter);
}
}
}
/**
* CoverageRecorder class extension supporting multiple
* coverage instrumentations to be accumulated
*
* This class extends CoverageRecorder class in order to
* support multimple xdebug code coverage sessions to be
* executed and get acummulated info about all them in order
* to produce one unique report (default CoverageRecorder
* resets info on each instrumentation (coverage session)
*
* @package moodlecore
* @subpackage simpletestcoverage
* @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class moodle_coverage_recorder extends CoverageRecorder {
public function __construct($reporter='new moodle_coverage_reporter()') {
parent::__construct(array(), array(), $reporter);
}
/**
* Stop gathering coverage data, saving it for later reporting
*/
public function stop_instrumentation() {
if(extension_loaded("xdebug")) {
$lastcoveragedata = xdebug_get_code_coverage(); // Get last instrumentation coverage data
xdebug_stop_code_coverage(); // Stop code coverage
$this->coverageData = self::merge_coverage_data($this->coverageData, $lastcoveragedata); // Append lastcoveragedata
$this->logger->debug("[moodle_coverage_recorder::stopInstrumentation()] Code coverage: " . print_r($this->coverageData, true),
__FILE__, __LINE__);
return true;
} else {
$this->logger->critical("[moodle_coverage_recorder::stopInstrumentation()] Xdebug not loaded.", __FILE__, __LINE__);
}
return false;
}
/**
* Start gathering coverage data
*/
public function start_instrumentation() {
$this->startInstrumentation(); /// Simple lowercase wrap over Spike function
}
/**
* Generate the code coverage report
*/
public function generate_report() {
$this->generateReport(); /// Simple lowercase wrap over Spike function
}
/**
* Determines if the server is able to run code coverage analysis
*
* @return bool
*/
static public function can_run_codecoverage() {
// Only req is xdebug loaded. PEAR XML is already in place and available
if(!extension_loaded("xdebug")) {
return false;
}
return true;
}
/**
* Merge two collections of complete code coverage data
*/
protected static function merge_coverage_data($cov1, $cov2) {
$result = array();
// protection against empty coverage collections
if (!is_array($cov1)) {
$cov1 = array();
}
if (!is_array($cov2)) {
$cov2 = array();
}
// Get all the files used in both coverage datas
$files = array_unique(array_merge(array_keys($cov1), array_keys($cov2)));
// Iterate, getting results
foreach($files as $file) {
// If file exists in both coverages, let's merge their lines
if (array_key_exists($file, $cov1) && array_key_exists($file, $cov2)) {
$result[$file] = self::merge_lines_coverage_data($cov1[$file], $cov2[$file]);
// Only one of the coverages has the file
} else if (array_key_exists($file, $cov1)) {
$result[$file] = $cov1[$file];
} else {
$result[$file] = $cov2[$file];
}
}
return $result;
}
/**
* Merge two collections of lines of code coverage data belonging to the same file
*
* Merge algorithm obtained from Phing: http://phing.info
*/
protected static function merge_lines_coverage_data($lines1, $lines2) {
$result = array();
reset($lines1);
reset($lines2);
while (current($lines1) && current($lines2)) {
$linenr1 = key($lines1);
$linenr2 = key($lines2);
if ($linenr1 < $linenr2) {
$result[$linenr1] = current($lines1);
next($lines1);
} else if ($linenr2 < $linenr1) {
$result[$linenr2] = current($lines2);
next($lines2);
} else {
if (current($lines1) < 0) {
$result[$linenr2] = current($lines2);
} else if (current($lines2) < 0) {
$result[$linenr2] = current($lines1);
} else {
$result[$linenr2] = current($lines1) + current($lines2);
}
next($lines1);
next($lines2);
}
}
while (current($lines1)) {
$result[key($lines1)] = current($lines1);
next($lines1);
}
while (current($lines2)) {
$result[key($lines2)] = current($lines2);
next($lines2);
}
return $result;
}
}
/**
* HtmlCoverageReporter class extension supporting Moodle customizations
*
* This class extends the HtmlCoverageReporter class in order to
* implement Moodle look and feel, inline reporting after executing
* unit tests, proper linking and other tweaks here and there.
*
* @package moodlecore
* @subpackage simpletestcoverage
* @copyright 2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class moodle_coverage_reporter extends HtmlCoverageReporter {
public function __construct($heading='Coverage Report', $dir='report') {
global $CFG;
parent::__construct($heading, '', $CFG->dataroot . '/codecoverage/' . $dir);
}
}
/**
* Return information about classes and functions
*
* This function will parse any PHP file, extracting information about the
* classes and functions defined within it, providing "File Reflection" as
* PHP standard reflection classes don't support that.
*
* The idea and the code has been obtained from the Zend Framework Reflection API
* http://framework.zend.com/manual/en/zend.reflection.reference.html
*
* Usage: $ref_file = moodle_reflect_file($file);
*
* @param string $file full path to the php file to introspect
* @return object object with both 'classes' and 'functions' properties
*/
function moodle_reflect_file($file) {
$contents = file_get_contents($file);
$tokens = token_get_all($contents);
$functionTrapped = false;
$classTrapped = false;
$openBraces = 0;
$classes = array();
$functions = array();
foreach ($tokens as $token) {
/*
* Tokens are characters representing symbols or arrays
* representing strings. The keys/values in the arrays are
*
* - 0 => token id,
* - 1 => string,
* - 2 => line number
*
* Token ID's are explained here:
* http://www.php.net/manual/en/tokens.php.
*/
if (is_array($token)) {
$type = $token[0];
$value = $token[1];
$lineNum = $token[2];
} else {
// It's a symbol
// Maintain the count of open braces
if ($token == '{') {
$openBraces++;
} else if ($token == '}') {
$openBraces--;
}
continue;
}
switch ($type) {
// Name of something
case T_STRING:
if ($functionTrapped) {
$functions[] = $value;
$functionTrapped = false;
} elseif ($classTrapped) {
$classes[] = $value;
$classTrapped = false;
}
continue;
// Functions
case T_FUNCTION:
if ($openBraces == 0) {
$functionTrapped = true;
}
break;
// Classes
case T_CLASS:
$classTrapped = true;
break;
// Default case: do nothing
default:
break;
}
}
return (object)array('classes' => $classes, 'functions' => $functions);
}
?>