URL Options * - SkipTests: A comma-separated list of test classes to skip (useful when running dev/tests/all) * * See {@link browse()} output for generic usage instructions. * * @package framework * @subpackage testing */ class TestRunner extends Controller { /** @ignore */ private static $default_reporter; static $url_handlers = array( '' => 'browse', 'coverage/module/$ModuleName' => 'coverageModule', 'coverage/$TestCase!' => 'coverageOnly', 'coverage' => 'coverageAll', 'sessionloadyml' => 'sessionloadyml', 'startsession' => 'startsession', 'endsession' => 'endsession', 'setdb' => 'setdb', 'cleanupdb' => 'cleanupdb', 'emptydb' => 'emptydb', 'module/$ModuleName' => 'module', 'all' => 'all', 'build' => 'build', '$TestCase' => 'only' ); static $allowed_actions = array( 'index', 'browse', 'coverage', 'coverageAll', 'coverageModule', 'coverageOnly', 'startsession', 'endsession', 'setdb', 'cleanupdb', 'module', 'all', 'build', 'only' ); /** * @var Array Blacklist certain directories for the coverage report. * Filepaths are relative to the webroot, without leading slash. * * @see http://www.phpunit.de/manual/current/en/appendixes.configuration.html * #appendixes.configuration.blacklist-whitelist */ static $coverage_filter_dirs = array( '*/thirdparty', '*/tests', '*/lang', ); /** * Override the default reporter with a custom configured subclass. * * @param string $reporter */ public static function set_reporter($reporter) { if (is_string($reporter)) $reporter = new $reporter; self::$default_reporter = $reporter; } /** * Pushes a class and template manifest instance that include tests onto the * top of the loader stacks. */ public static function use_test_manifest() { $classManifest = new SS_ClassManifest( BASE_PATH, true, isset($_GET['flush']) ); SS_ClassLoader::instance()->pushManifest($classManifest); SapphireTest::set_test_class_manifest($classManifest); SS_TemplateLoader::instance()->pushManifest(new SS_TemplateManifest( BASE_PATH, true, isset($_GET['flush']) )); } public function init() { parent::init(); $canAccess = (Director::isDev() || Director::is_cli() || Permission::check("ADMIN")); if(!$canAccess) return Security::permissionFailure($this); if (!self::$default_reporter) self::set_reporter(Director::is_cli() ? 'CliDebugView' : 'DebugView'); if(!PhpUnitWrapper::has_php_unit()) { die("Please install PHPUnit using pear"); } if(!isset($_GET['flush']) || !$_GET['flush']) { Debug::message( "WARNING: Manifest not flushed. " . "Add flush=1 as an argument to discover new classes or files.\n", false ); } } public function Link() { return Controller::join_links(Director::absoluteBaseURL(), 'dev/tests/'); } /** * Run test classes that should be run with every commit. * Currently excludes PhpSyntaxTest */ public function all($request, $coverage = false) { self::use_test_manifest(); $tests = ClassInfo::subclassesFor('SapphireTest'); array_shift($tests); unset($tests['FunctionalTest']); // Remove tests that don't need to be executed every time unset($tests['PhpSyntaxTest']); foreach($tests as $class => $v) { $reflection = new ReflectionClass($class); if(!$reflection->isInstantiable()) unset($tests[$class]); } $this->runTests($tests, $coverage); } /** * Run test classes that should be run before build - i.e., everything possible. */ public function build() { self::use_test_manifest(); $tests = ClassInfo::subclassesFor('SapphireTest'); array_shift($tests); unset($tests['FunctionalTest']); foreach($tests as $class => $v) { $reflection = new ReflectionClass($class); if(!$reflection->isInstantiable()) unset($tests[$class]); } $this->runTests($tests); } /** * Browse all enabled test cases in the environment */ public function browse() { self::use_test_manifest(); self::$default_reporter->writeHeader(); self::$default_reporter->writeInfo('Available Tests', false); if(Director::is_cli()) { $tests = ClassInfo::subclassesFor('SapphireTest'); $relativeLink = Director::makeRelative($this->Link()); echo "sake {$relativeLink}all: Run all " . count($tests) . " tests\n"; echo "sake {$relativeLink}coverage: Runs all tests and make test coverage report\n"; echo "sake {$relativeLink}module/: Runs all tests in a module folder\n"; foreach ($tests as $test) { echo "sake {$relativeLink}$test: Run $test\n"; } } else { echo '
'; $tests = ClassInfo::subclassesFor('SapphireTest'); asort($tests); echo "

Link() . "all\">Run all " . count($tests) . " tests

"; echo "

Link() . "coverage\">Runs all tests and make test coverage report

"; echo "
"; foreach ($tests as $test) { echo "

Link() . "$test\">Run $test

"; } echo '
'; } self::$default_reporter->writeFooter(); } /** * Run a coverage test across all modules */ public function coverageAll($request) { self::use_test_manifest(); $this->all($request, true); } /** * Run only a single coverage test class or a comma-separated list of tests */ public function coverageOnly($request) { $this->only($request, true); } /** * Run coverage tests for one or more "modules". * A module is generally a toplevel folder, e.g. "mysite" or "framework". */ public function coverageModule($request) { $this->module($request, true); } public function cleanupdb() { SapphireTest::delete_all_temp_dbs(); } /** * Run only a single test class or a comma-separated list of tests */ public function only($request, $coverage = false) { self::use_test_manifest(); if($request->param('TestCase') == 'all') { $this->all(); } else { $classNames = explode(',', $request->param('TestCase')); foreach($classNames as $className) { if(!class_exists($className) || !is_subclass_of($className, 'SapphireTest')) { user_error("TestRunner::only(): Invalid TestCase '$className', cannot find matching class", E_USER_ERROR); } } $this->runTests($classNames, $coverage); } } /** * Run tests for one or more "modules". * A module is generally a toplevel folder, e.g. "mysite" or "framework". */ public function module($request, $coverage = false) { self::use_test_manifest(); $classNames = array(); $moduleNames = explode(',', $request->param('ModuleName')); $ignored = array('functionaltest', 'phpsyntaxtest'); foreach($moduleNames as $moduleName) { $classesForModule = ClassInfo::classes_for_folder($moduleName); if($classesForModule) { foreach($classesForModule as $className) { if(class_exists($className) && is_subclass_of($className, 'SapphireTest')) { if(!in_array($className, $ignored)) $classNames[] = $className; } } } } $this->runTests($classNames, $coverage); } /** * @param array $classList * @param boolean $coverage */ public function runTests($classList, $coverage = false) { $startTime = microtime(true); // disable xdebug, as it messes up test execution if(function_exists('xdebug_disable')) xdebug_disable(); ini_set('max_execution_time', 0); $this->setUp(); // Optionally skip certain tests $skipTests = array(); if($this->request->getVar('SkipTests')) { $skipTests = explode(',', $this->request->getVar('SkipTests')); } $classList = array_diff($classList, $skipTests); // run tests before outputting anything to the client $suite = new PHPUnit_Framework_TestSuite(); natcasesort($classList); foreach($classList as $className) { // Ensure that the autoloader pulls in the test class, as PHPUnit won't know how to do this. class_exists($className); $suite->addTest(new SapphireTestSuite($className)); } // Remove the error handler so that PHPUnit can add its own restore_error_handler(); self::$default_reporter->writeHeader("SilverStripe Test Runner"); if (count($classList) > 1) { self::$default_reporter->writeInfo("All Tests", "Running test cases: ",implode(", ", $classList)); } elseif (count($classList) == 1) { self::$default_reporter->writeInfo($classList[0], ''); } else { // border case: no tests are available. self::$default_reporter->writeInfo('', ''); } // perform unit tests (use PhpUnitWrapper or derived versions) $phpunitwrapper = PhpUnitWrapper::inst(); $phpunitwrapper->setSuite($suite); $phpunitwrapper->setCoverageStatus($coverage); $phpunitwrapper->runTests(); // get results of the PhpUnitWrapper class $reporter = $phpunitwrapper->getReporter(); $results = $phpunitwrapper->getFrameworkTestResults(); if(!Director::is_cli()) echo '
'; $reporter->writeResults(); $endTime = microtime(true); if(Director::is_cli()) echo "\n\nTotal time: " . round($endTime-$startTime,3) . " seconds\n"; else echo "

Total time: " . round($endTime-$startTime,3) . " seconds

\n"; if(!Director::is_cli()) echo '
'; // Put the error handlers back Debug::loadErrorHandlers(); if(!Director::is_cli()) self::$default_reporter->writeFooter(); $this->tearDown(); // Todo: we should figure out how to pass this data back through Director more cleanly if(Director::is_cli() && ($results->failureCount() + $results->errorCount()) > 0) exit(2); } /** * Start a test session. * Usage: visit dev/tests/startsession?fixture=(fixturefile). A test database will be constructed, and your * browser session will be amended to use this database. This can only be run on dev and test sites. * * See {@link setdb()} for an alternative approach which just sets a database * name, and is used for more advanced use cases like interacting with test databases * directly during functional tests. * * Requires PHP's mycrypt extension in order to set the database name * as an encrypted cookie. */ public function startsession() { if(!Director::isLive()) { if(SapphireTest::using_temp_db()) { $endLink = Director::baseURL() . "dev/tests/endsession"; return "

You're in the middle of a test session;" . " click here to end it.

"; } else if(!isset($_GET['fixture'])) { $me = Director::baseURL() . "dev/tests/startsession"; return <<

Enter a fixture file name to start a new test session. Don't forget to visit dev/tests/endsession when you're done!

Fixture file (leave blank to start with default set-up):

HTML; } else { $fixtureFile = $_GET['fixture']; if($fixtureFile) { // Validate fixture file $realFile = realpath(BASE_PATH.'/'.$fixtureFile); $baseDir = realpath(Director::baseFolder()); if(!$realFile || !file_exists($realFile)) { return "

Fixture file doesn't exist

"; } else if(substr($realFile,0,strlen($baseDir)) != $baseDir) { return "

Fixture file must be inside $baseDir

"; } else if(substr($realFile,-4) != '.yml') { return "

Fixture file must be a .yml file

"; } else if(!preg_match('/^([^\/.][^\/]+)\/tests\//', $fixtureFile)) { return "

Fixture file must be inside the tests subfolder of one of your modules.

"; } } $dbname = SapphireTest::create_temp_db(); DB::set_alternative_database_name($dbname); // Fixture if($fixtureFile) { $fixture = Injector::inst()->create('YamlFixture', $fixtureFile); $fixture->saveIntoDatabase(); // If no fixture, then use defaults } else { $dataClasses = ClassInfo::subclassesFor('DataObject'); array_shift($dataClasses); foreach($dataClasses as $dataClass) singleton($dataClass)->requireDefaultRecords(); } return "

Started testing session with fixture '$fixtureFile'. Time to start testing; where would you like to start?

"; } } else { return "

startession can only be used on dev and test sites

"; } } /** * Set an alternative database name in the current browser session as a cookie. * Useful for functional testing libraries like behat to create a "clean slate". * Does not actually create the database, that's usually handled * by {@link SapphireTest::create_temp_db()}. * * The database names are limited to a specific naming convention as a security measure: * The "tmpdb" prefix and a random sequence of seven digits. * This avoids the user gaining access to other production databases * available on the same connection. * * See {@link startsession()} for a different approach which actually creates * the DB and loads a fixture file instead. * * Requires PHP's mycrypt extension in order to set the database name * as an encrypted cookie. */ public function setdb() { if(Director::isLive()) { return $this->httpError(403, "dev/tests/setdb can only be used on dev and test sites"); } if(!isset($_GET['database'])) { return $this->httpError(400, "dev/tests/setdb must be used with a 'database' parameter"); } $name = $_GET['database']; $prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_'; $pattern = strtolower(sprintf('#^%stmpdb\d{7}#', $prefix)); if($name && !preg_match($pattern, $name)) { return $this->httpError(400, "Invalid database name format"); } DB::set_alternative_database_name($name); if($name) { return "

Set database session to '$name'.

"; } else { return "

Unset database session.

"; } } public function emptydb() { if(SapphireTest::using_temp_db()) { SapphireTest::empty_temp_db(); if(isset($_GET['fixture']) && ($fixtureFile = $_GET['fixture'])) { $fixture = Injector::inst()->create('YamlFixture', $fixtureFile); $fixture->saveIntoDatabase(); return "

Re-test the test database with fixture '$fixtureFile'. Time to start testing; where would" . " you like to start?

"; } else { return "

Re-test the test database. Time to start testing; where would you like to start?

"; } } else { return "

dev/tests/emptydb can only be used with a temporary database. Perhaps you should use" . " dev/tests/startsession first?

"; } } public function endsession() { SapphireTest::kill_temp_db(); DB::set_alternative_database_name(null); return "

Test session ended.

"; } public function sessionloadyml() { // Load incremental YAML fixtures // TODO: We will probably have to filter out the admin member here, // as it is supplied by Bare.yml if(Director::isLive()) { return "

sessionloadyml can only be used on dev and test sites

"; } if (!SapphireTest::using_temp_db()) { return "

Please load /dev/tests/startsession first

"; } $fixtureFile = isset($_GET['fixture']) ? $_GET['fixture'] : null; if (empty($fixtureFile)) { $me = Director::baseURL() . "/dev/tests/sessionloadyml"; return <<

Enter a fixture file name to load a new YAML fixture into the session.

Fixture file

HTML; } // Validate fixture file $realFile = realpath(BASE_PATH.'/'.$fixtureFile); $baseDir = realpath(Director::baseFolder()); if(!$realFile || !file_exists($realFile)) { return "

Fixture file doesn't exist

"; } else if(substr($realFile,0,strlen($baseDir)) != $baseDir) { return "

Fixture file must be inside $baseDir

"; } else if(substr($realFile,-4) != '.yml') { return "

Fixture file must be a .yml file

"; } else if(!preg_match('/^([^\/.][^\/]+)\/tests\//', $fixtureFile)) { return "

Fixture file must be inside the tests subfolder of one of your modules.

"; } // Fixture $fixture = Injector::inst()->create('YamlFixture', $fixtureFile); $fixture->saveIntoDatabase(); return "

Loaded fixture '$fixtureFile' into session

"; } public function setUp() { // The first DB test will sort out the DB, we don't have to SSViewer::flush_template_cache(); } public function tearDown() { SapphireTest::kill_temp_db(); DB::set_alternative_database_name(null); } }