Page Menu
Home
Sealhub
Search
Configure Global Search
Log In
Files
F995720
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
22 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/unit/engine/PhutilUnitTestEngine.php b/src/unit/engine/PhutilUnitTestEngine.php
index 2179d5ba..1a3a110c 100644
--- a/src/unit/engine/PhutilUnitTestEngine.php
+++ b/src/unit/engine/PhutilUnitTestEngine.php
@@ -1,179 +1,192 @@
<?php
/**
* Very basic unit test engine which runs libphutil tests.
*
* @group unitrun
*/
final class PhutilUnitTestEngine extends ArcanistBaseUnitTestEngine {
protected function supportsRunAllTests() {
return true;
}
public function run() {
if ($this->getRunAllTests()) {
$run_tests = $this->getAllTests();
} else {
$run_tests = $this->getTestsForPaths();
}
if (!$run_tests) {
throw new ArcanistNoEffectException("No tests to run.");
}
$enable_coverage = $this->getEnableCoverage();
if ($enable_coverage !== false) {
if (!function_exists('xdebug_start_code_coverage')) {
if ($enable_coverage === true) {
throw new ArcanistUsageException(
"You specified --coverage but xdebug is not available, so ".
"coverage can not be enabled for PhutilUnitTestEngine.");
}
} else {
$enable_coverage = true;
}
}
$project_root = $this->getWorkingCopy()->getProjectRoot();
- $results = array();
+ $test_cases = array();
foreach ($run_tests as $test_class) {
$test_case = newv($test_class, array());
$test_case->setEnableCoverage($enable_coverage);
$test_case->setProjectRoot($project_root);
if ($this->getPaths()) {
$test_case->setPaths($this->getPaths());
}
if ($this->renderer) {
$test_case->setRenderer($this->renderer);
}
- $results[] = $test_case->run();
+ $test_cases[] = $test_case;
}
+ foreach ($test_cases as $test_case) {
+ $test_case->willRunTestCases($test_cases);
+ }
+
+ $results = array();
+ foreach ($test_cases as $test_case) {
+ $results[] = $test_case->run();
+ }
$results = array_mergev($results);
+
+ foreach ($test_cases as $test_case) {
+ $test_case->didRunTestCases($test_cases);
+ }
+
return $results;
}
private function getAllTests() {
$project_root = $this->getWorkingCopy()->getProjectRoot();
$symbols = id(new PhutilSymbolLoader())
->setType('class')
->setAncestorClass('ArcanistPhutilTestCase')
->setConcreteOnly(true)
->selectSymbolsWithoutLoading();
$in_working_copy = array();
$run_tests = array();
foreach ($symbols as $symbol) {
if (!preg_match('@/__tests__/@', $symbol['where'])) {
continue;
}
$library = $symbol['library'];
if (!isset($in_working_copy[$library])) {
$library_root = phutil_get_library_root($library);
$in_working_copy[$library] = Filesystem::isDescendant(
$library_root,
$project_root);
}
if ($in_working_copy[$library]) {
$run_tests[] = $symbol['name'];
}
}
return $run_tests;
}
private function getTestsForPaths() {
$project_root = $this->getWorkingCopy()->getProjectRoot();
$look_here = array();
foreach ($this->getPaths() as $path) {
$library_root = phutil_get_library_root_for_path($path);
if (!$library_root) {
continue;
}
$library_name = phutil_get_library_name_for_root($library_root);
if (!$library_name) {
throw new Exception(
"Attempting to run unit tests on a libphutil library which has not ".
"been loaded, at:\n\n".
" {$library_root}\n\n".
"This probably means one of two things:\n\n".
" - You may need to add this library to .arcconfig.\n".
" - You may be running tests on a copy of libphutil or arcanist\n".
" using a different copy of libphutil or arcanist. This\n".
" operation is not supported.");
}
$path = Filesystem::resolvePath($path, $project_root);
if (!is_dir($path)) {
$path = dirname($path);
}
if ($path == $library_root) {
continue;
}
if (!Filesystem::isDescendant($path, $library_root)) {
// We have encountered some kind of symlink maze -- for instance, $path
// is some symlink living outside the library that links into some file
// inside the library. Just ignore these cases, since the affected file
// does not actually lie within the library.
continue;
}
$library_path = Filesystem::readablePath($path, $library_root);
do {
$look_here[$library_name.':'.$library_path] = array(
'library' => $library_name,
'path' => $library_path,
);
$library_path = dirname($library_path);
} while ($library_path != '.');
}
// Look for any class that extends ArcanistPhutilTestCase inside a
// __tests__ directory in any parent directory of every affected file.
//
// The idea is that "infrastructure/__tests__/" tests defines general tests
// for all of "infrastructure/", and those tests run for any change in
// "infrastructure/". However, "infrastructure/concrete/rebar/__tests__/"
// defines more specific tests that run only when rebar/ (or some
// subdirectory) changes.
$run_tests = array();
foreach ($look_here as $path_info) {
$library = $path_info['library'];
$path = $path_info['path'];
$symbols = id(new PhutilSymbolLoader())
->setType('class')
->setLibrary($library)
->setPathPrefix($path.'/__tests__/')
->setAncestorClass('ArcanistPhutilTestCase')
->setConcreteOnly(true)
->selectAndLoadSymbols();
foreach ($symbols as $symbol) {
$run_tests[$symbol['name']] = true;
}
}
$run_tests = array_keys($run_tests);
return $run_tests;
}
public function shouldEchoTestResults() {
return !$this->renderer;
}
}
diff --git a/src/unit/engine/phutil/ArcanistPhutilTestCase.php b/src/unit/engine/phutil/ArcanistPhutilTestCase.php
index b1cef23a..259aa5fe 100644
--- a/src/unit/engine/phutil/ArcanistPhutilTestCase.php
+++ b/src/unit/engine/phutil/ArcanistPhutilTestCase.php
@@ -1,547 +1,570 @@
<?php
/**
* Base test case for the very simple libphutil test framework.
*
* @task assert Making Test Assertions
* @task exceptions Exception Handling
* @task hook Hooks for Setup and Teardown
* @task internal Internals
*
* @group unitrun
*/
abstract class ArcanistPhutilTestCase {
private $assertions = 0;
private $runningTest;
private $testStartTime;
private $results = array();
private $enableCoverage;
private $coverage = array();
private $projectRoot;
private $paths;
private $renderer;
/* -( Making Test Assertions )--------------------------------------------- */
/**
* Assert that two values are equal. The test fails if they are not.
*
* NOTE: This method uses PHP's strict equality test operator ("===") to
* compare values. This means values and types must be equal, key order must
* be identical in arrays, and objects must be referentially identical.
*
* @param wild The theoretically expected value, generated by careful
* reasoning about the properties of the system.
* @param wild The empirically derived value, generated by executing the
* test.
* @param string A human-readable description of what these values represent,
* and particularly of what a discrepancy means.
*
* @return void
* @task assert
*/
final protected function assertEqual($expect, $result, $message = null) {
if ($expect === $result) {
$this->assertions++;
return;
}
$expect = PhutilReadableSerializer::printableValue($expect);
$result = PhutilReadableSerializer::printableValue($result);
$where = debug_backtrace();
$where = array_shift($where);
$line = idx($where, 'line');
$file = basename(idx($where, 'file'));
$output = "Assertion failed at line {$line} in {$file}";
if ($message) {
$output .= ": {$message}";
}
$output .= "\n";
if (strpos($expect, "\n") === false && strpos($result, "\n") === false) {
$output .= "Expected: {$expect}\n";
$output .= "Actual: {$result}";
} else {
$output .= "Expected vs Actual Output Diff\n";
$output .= ArcanistDiffUtils::renderDifferences(
$expect,
$result,
$lines = 0xFFFF);
}
$this->failTest($output);
throw new ArcanistPhutilTestTerminatedException($output);
}
/**
* Assert an unconditional failure. This is just a convenience method that
* better indicates intent than using dummy values with assertEqual(). This
* causes test failure.
*
* @param string Human-readable description of the reason for test failure.
* @return void
* @task assert
*/
final protected function assertFailure($message) {
$this->failTest($message);
throw new ArcanistPhutilTestTerminatedException($message);
}
/**
* End this test by asserting that the test should be skipped for some
* reason.
*
* @param string Reason for skipping this test.
* @return void
* @task assert
*/
final protected function assertSkipped($message) {
$this->skipTest($message);
throw new ArcanistPhutilTestSkippedException($message);
}
/* -( Exception Handling )------------------------------------------------- */
/**
* This simplest way to assert exceptions are thrown.
*
* @param exception The expected exception.
* @param callable The thing which throws the exception.
*
* @return void
* @task exceptions
*/
final protected function assertException($expected_exception_class,
$callable) {
$this->tryTestCases(
array('assertException' => array()),
array(false),
$callable,
$expected_exception_class);
}
/**
* Straightforward method for writing unit tests which check if some block of
* code throws an exception. For example, this allows you to test the
* exception behavior of ##is_a_fruit()## on various inputs:
*
* public function testFruit() {
* $this->tryTestCases(
* array(
* 'apple is a fruit' => new Apple(),
* 'rock is not a fruit' => new Rock(),
* ),
* array(
* true,
* false,
* ),
* array($this, 'tryIsAFruit'),
* 'NotAFruitException');
* }
*
* protected function tryIsAFruit($input) {
* is_a_fruit($input);
* }
*
* @param map Map of test case labels to test case inputs.
* @param list List of expected results, true to indicate that the case
* is expected to succeed and false to indicate that the case
* is expected to throw.
* @param callable Callback to invoke for each test case.
* @param string Optional exception class to catch, defaults to
* 'Exception'.
* @return void
* @task exceptions
*/
final protected function tryTestCases(
array $inputs,
array $expect,
$callable,
$exception_class = 'Exception') {
if (count($inputs) !== count($expect)) {
$this->assertFailure(
"Input and expectations must have the same number of values.");
}
$labels = array_keys($inputs);
$inputs = array_values($inputs);
$expecting = array_values($expect);
foreach ($inputs as $idx => $input) {
$expect = $expecting[$idx];
$label = $labels[$idx];
$caught = null;
try {
call_user_func($callable, $input);
} catch (Exception $ex) {
if ($ex instanceof ArcanistPhutilTestTerminatedException) {
throw $ex;
}
if (!($ex instanceof $exception_class)) {
throw $ex;
}
$caught = $ex;
}
$actual = !($caught instanceof Exception);
if ($expect === $actual) {
if ($expect) {
$message = "Test case '{$label}' did not throw, as expected.";
} else {
$message = "Test case '{$label}' threw, as expected.";
}
} else {
if ($expect) {
$message = "Test case '{$label}' was expected to succeed, but it ".
"raised an exception of class ".get_class($ex)." with ".
"message: ".$ex->getMessage();
} else {
$message = "Test case '{$label}' was expected to raise an ".
"exception, but it did not throw anything.";
}
}
$this->assertEqual($expect, $actual, $message);
}
}
/**
* Convenience wrapper around @{method:tryTestCases} for cases where your
* inputs are scalar. For example:
*
* public function testFruit() {
* $this->tryTestCaseMap(
* array(
* 'apple' => true,
* 'rock' => false,
* ),
* array($this, 'tryIsAFruit'),
* 'NotAFruitException');
* }
*
* protected function tryIsAFruit($input) {
* is_a_fruit($input);
* }
*
* For cases where your inputs are not scalar, use @{method:tryTestCases}.
*
* @param map Map of scalar test inputs to expected success (true
* expects success, false expects an exception).
* @param callable Callback to invoke for each test case.
* @param string Optional exception class to catch, defaults to
* 'Exception'.
* @return void
* @task exceptions
*/
final protected function tryTestCaseMap(
array $map,
$callable,
$exception_class = 'Exception') {
return $this->tryTestCases(
array_fuse(array_keys($map)),
array_values($map),
$callable,
$exception_class);
}
/* -( Hooks for Setup and Teardown )--------------------------------------- */
/**
* This hook is invoked once, before any tests in this class are run. It
* gives you an opportunity to perform setup steps for the entire class.
*
* @return void
* @task hook
*/
protected function willRunTests() {
return;
}
/**
* This hook is invoked once, after any tests in this class are run. It gives
* you an opportunity to perform teardown steps for the entire class.
*
* @return void
* @task hook
*/
protected function didRunTests() {
return;
}
/**
* This hook is invoked once per test, before the test method is invoked.
*
* @param string Method name of the test which will be invoked.
* @return void
* @task hook
*/
protected function willRunOneTest($test_method_name) {
return;
}
/**
* This hook is invoked once per test, after the test method is invoked.
*
* @param string Method name of the test which was invoked.
* @return void
* @task hook
*/
protected function didRunOneTest($test_method_name) {
return;
}
+ /**
+ * This hook is invoked once, before any test cases execute. It gives you
+ * an opportunity to perform setup steps for the entire suite of test cases.
+ *
+ * @return void
+ * @task hook
+ */
+ public function willRunTestCases(array $test_cases) {
+ return;
+ }
+
+
+ /**
+ * This hook is invoked once, after all test cases execute.
+ *
+ * @return void
+ * @task hook
+ */
+ public function didRunTestCases(array $test_cases) {
+ return;
+ }
+
+
/* -( Internals )---------------------------------------------------------- */
/**
* Construct a new test case. This method is ##final##, use willRunTests() to
* provide test-wide setup logic.
*
* @task internal
*/
final public function __construct() {
}
/**
* Mark the currently-running test as a failure.
*
* @param string Human-readable description of problems.
* @return void
*
* @task internal
*/
final private function failTest($reason) {
$this->resultTest(ArcanistUnitTestResult::RESULT_FAIL, $reason);
}
/**
* This was a triumph. I'm making a note here: HUGE SUCCESS.
*
* @param string Human-readable overstatement of satisfaction.
* @return void
*
* @task internal
*/
final private function passTest($reason) {
$this->resultTest(ArcanistUnitTestResult::RESULT_PASS, $reason);
}
/**
* Mark the current running test as skipped.
*
* @param string Description for why this test was skipped.
* @return void
* @task internal
*/
final private function skipTest($reason) {
$this->resultTest(ArcanistUnitTestResult::RESULT_SKIP, $reason);
}
final private function resultTest($test_result, $reason) {
$coverage = $this->endCoverage();
$result = new ArcanistUnitTestResult();
$result->setCoverage($coverage);
$result->setName($this->runningTest);
$result->setLink($this->getLink($this->runningTest));
$result->setResult($test_result);
$result->setDuration(microtime(true) - $this->testStartTime);
$result->setUserData($reason);
$this->results[] = $result;
if ($this->renderer) {
echo $this->renderer->renderUnitResult($result);
}
}
/**
* Execute the tests in this test case. You should not call this directly;
* use @{class:PhutilUnitTestEngine} to orchestrate test execution.
*
* @return void
* @task internal
*/
final public function run() {
$this->results = array();
$reflection = new ReflectionClass($this);
$methods = $reflection->getMethods();
// Try to ensure that poorly-written tests which depend on execution order
// (and are thus not properly isolated) will fail.
shuffle($methods);
$this->willRunTests();
foreach ($methods as $method) {
$name = $method->getName();
if (preg_match('/^test/', $name)) {
$this->runningTest = $name;
$this->assertions = 0;
$this->testStartTime = microtime(true);
try {
$this->willRunOneTest($name);
$this->beginCoverage();
$exceptions = array();
try {
call_user_func_array(
array($this, $name),
array());
$this->passTest(pht('%d assertion(s) passed.', $this->assertions));
} catch (Exception $ex) {
$exceptions['Execution'] = $ex;
}
try {
$this->didRunOneTest($name);
} catch (Exception $ex) {
$exceptions['Shutdown'] = $ex;
}
if ($exceptions) {
if (count($exceptions) == 1) {
throw head($exceptions);
} else {
throw new PhutilAggregateException(
"Multiple exceptions were raised during test execution.",
$exceptions);
}
}
} catch (ArcanistPhutilTestTerminatedException $ex) {
// Continue with the next test.
} catch (ArcanistPhutilTestSkippedException $ex) {
// Continue with the next test.
} catch (Exception $ex) {
$ex_class = get_class($ex);
$ex_message = $ex->getMessage();
$ex_trace = $ex->getTraceAsString();
$message = "EXCEPTION ({$ex_class}): {$ex_message}\n{$ex_trace}";
$this->failTest($message);
}
}
}
$this->didRunTests();
return $this->results;
}
final public function setEnableCoverage($enable_coverage) {
$this->enableCoverage = $enable_coverage;
return $this;
}
/**
* @phutil-external-symbol function xdebug_start_code_coverage
*/
final private function beginCoverage() {
if (!$this->enableCoverage) {
return;
}
$this->assertCoverageAvailable();
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);
}
/**
* @phutil-external-symbol function xdebug_get_code_coverage
* @phutil-external-symbol function xdebug_stop_code_coverage
*/
final private function endCoverage() {
if (!$this->enableCoverage) {
return;
}
$result = xdebug_get_code_coverage();
xdebug_stop_code_coverage($cleanup = false);
$coverage = array();
foreach ($result as $file => $report) {
if (strncmp($file, $this->projectRoot, strlen($this->projectRoot))) {
continue;
}
$max = max(array_keys($report));
$str = '';
for ($ii = 1; $ii <= $max; $ii++) {
$c = idx($report, $ii);
if ($c === -1) {
$str .= 'U'; // Un-covered.
} else if ($c === -2) {
// TODO: This indicates "unreachable", but it flags the closing braces
// of functions which end in "return", which is super ridiculous. Just
// ignore it for now.
$str .= 'N'; // Not executable.
} else if ($c === 1) {
$str .= 'C'; // Covered.
} else {
$str .= 'N'; // Not executable.
}
}
$coverage[substr($file, strlen($this->projectRoot) + 1)] = $str;
}
// Only keep coverage information for files modified by the change. In
// the case of --everything, we won't have paths, so just return all the
// coverage data.
if ($this->paths) {
$coverage = array_select_keys($coverage, $this->paths);
}
return $coverage;
}
final private function assertCoverageAvailable() {
if (!function_exists('xdebug_start_code_coverage')) {
throw new Exception(
"You've enabled code coverage but XDebug is not installed.");
}
}
final public function setProjectRoot($project_root) {
$this->projectRoot = $project_root;
return $this;
}
final public function setPaths(array $paths) {
$this->paths = $paths;
return $this;
}
protected function getLink($method) {
return null;
}
public function setRenderer(ArcanistUnitRenderer $renderer) {
$this->renderer = $renderer;
return $this;
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Dec 23, 13:57 (1 d, 8 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
556935
Default Alt Text
(22 KB)
Attached To
Mode
R118 Arcanist - fork
Attached
Detach File
Event Timeline
Log In to Comment