diff --git a/scripts/__init_script__.php b/scripts/__init_script__.php index 70708eca..38c6e4ed 100644 --- a/scripts/__init_script__.php +++ b/scripts/__init_script__.php @@ -1,67 +1,72 @@ addTranslations(array( 'Locally modified path(s) are not included in this revision:' => array( 'A locally modified path is not included in this revision:', 'Locally modified paths are not included in this revision:', ), 'They will NOT be committed. Commit this revision anyway?' => array( 'It will NOT be committed. Commit this revision anyway?', 'They will NOT be committed. Commit this revision anyway?', ), 'Revision includes changes to path(s) that do not exist:' => array( 'Revision includes changes to a path that does not exist:', 'Revision includes changes to paths that do not exist:', ), 'This diff includes file(s) which are not valid UTF-8 (they contain '. 'invalid byte sequences). You can either stop this workflow and fix '. 'these files, or continue. If you continue, these files will be '. 'marked as binary.' => array( 'This diff includes a file which is not valid UTF-8 (it has invalid '. 'byte sequences). You can either stop this workflow and fix it, or '. 'continue. If you continue, this file will be marked as binary.', 'This diff includes files which are not valid UTF-8 (they contain '. 'invalid byte sequences). You can either stop this workflow and fix '. 'these files, or continue. If you continue, these files will be '. 'marked as binary.', ), 'AFFECTED FILE(S)' => array('AFFECTED FILE', 'AFFECTED FILES'), 'Do you want to mark these files as binary and continue?' => array( 'Do you want to mark this file as binary and continue?', 'Do you want to mark these files as binary and continue?', ), 'line(s)' => array('line', 'lines'), + + '%d assertion(s) passed.' => array( + '%d assertion passed.', + '%d assertions passed.', + ), )); phutil_load_library(dirname(dirname(__FILE__)).'/src/'); diff --git a/src/unit/engine/phutil/ArcanistPhutilTestCase.php b/src/unit/engine/phutil/ArcanistPhutilTestCase.php index cea863d0..e0ec47d8 100644 --- a/src/unit/engine/phutil/ArcanistPhutilTestCase.php +++ b/src/unit/engine/phutil/ArcanistPhutilTestCase.php @@ -1,542 +1,545 @@ 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_combine(array_keys($map), 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; } /* -( 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) { $coverage = $this->endCoverage(); $result = new ArcanistUnitTestResult(); $result->setCoverage($coverage); $result->setName($this->runningTest); $result->setResult(ArcanistUnitTestResult::RESULT_FAIL); $result->setDuration(microtime(true) - $this->testStartTime); $result->setUserData($reason); $this->results[] = $result; } /** * 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) { $coverage = $this->endCoverage(); $result = new ArcanistUnitTestResult(); $result->setCoverage($coverage); $result->setName($this->runningTest); $result->setResult(ArcanistUnitTestResult::RESULT_PASS); $result->setDuration(microtime(true) - $this->testStartTime); $result->setUserData($reason); $this->results[] = $result; } /** * 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) { $coverage = $this->endCoverage(); $result = new ArcanistUnitTestResult(); $result->setCoverage($coverage); $result->setName($this->runningTest); $result->setResult(ArcanistUnitTestResult::RESULT_SKIP); $result->setDuration(microtime(true) - $this->testStartTime); $result->setUserData($reason); $this->results[] = $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(); $test_exception = null; try { call_user_func_array( array($this, $name), array()); - $this->passTest("All assertions passed."); + $this->passTest(pht('%d assertion(s) passed.', $this->assertions)); } catch (Exception $ex) { $test_exception = $ex; } $this->didRunOneTest($name); if ($test_exception) { throw $test_exception; } } 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. $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; } }