From cb051d85681c750a2397b31500c3ccfe50f6a170 Mon Sep 17 00:00:00 2001 From: Julius Seporaitis Date: Fri, 27 Apr 2012 12:11:41 -0700 Subject: [PATCH] Wrapper for 'nose' unittest and code coverage tools. Summary: Wrapper for Python 'nose' (http://readthedocs.org/docs/nose/en/latest/) testing tool. Test Plan: Install latest 'nose' v1.1.3. Currently it is available through Github only (``pep install git+https://github.com/nose-devs/nose.git``). Create a Python project with following structure: /package_name/module_name.py /tests/package_name/test_module_name.py Write some tests Run ``arc unit`` Reviewers: epriestley Reviewed By: epriestley CC: aran, Koolvin, zeeg Differential Revision: https://secure.phabricator.com/D2322 --- src/__phutil_library_map__.php | 2 + src/unit/engine/nose/NoseTestEngine.php | 257 ++++++++++++++++++++++++ src/unit/engine/nose/__init__.php | 20 ++ 3 files changed, 279 insertions(+) create mode 100644 src/unit/engine/nose/NoseTestEngine.php create mode 100644 src/unit/engine/nose/__init__.php diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 5b9b0239..03923ffd 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -112,6 +112,7 @@ phutil_register_library_map(array( 'BranchInfo' => 'branch', 'ComprehensiveLintEngine' => 'lint/engine/comprehensive', 'ExampleLintEngine' => 'lint/engine/example', + 'NoseTestEngine' => 'unit/engine/nose', 'PhutilLintEngine' => 'lint/engine/phutil', 'PhutilModuleRequirements' => 'parser/phutilmodule', 'PhutilUnitTestEngine' => 'unit/engine/phutil', @@ -188,6 +189,7 @@ phutil_register_library_map(array( 'ArcanistXHPASTLinterTestCase' => 'ArcanistLinterTestCase', 'ComprehensiveLintEngine' => 'ArcanistLintEngine', 'ExampleLintEngine' => 'ArcanistLintEngine', + 'NoseTestEngine' => 'ArcanistBaseUnitTestEngine', 'PhutilLintEngine' => 'ArcanistLintEngine', 'PhutilUnitTestEngine' => 'ArcanistBaseUnitTestEngine', 'PhutilUnitTestEngineTestCase' => 'ArcanistPhutilTestCase', diff --git a/src/unit/engine/nose/NoseTestEngine.php b/src/unit/engine/nose/NoseTestEngine.php new file mode 100644 index 00000000..9a99207b --- /dev/null +++ b/src/unit/engine/nose/NoseTestEngine.php @@ -0,0 +1,257 @@ +getPaths(); + + $affected_tests = array(); + foreach ($paths as $path) { + $absolute_path = Filesystem::resolvePath($path); + + if (is_dir($absolute_path)) { + $absolute_test_path = Filesystem::resolvePath("tests/".$path); + if (is_readable($absolute_test_path)) { + $affected_tests[] = $absolute_test_path; + } + } + + if (is_readable($absolute_path)) { + $filename = basename($path); + $directory = dirname($path); + + // assumes directory layout: tests//test_.py + $relative_test_path = "tests/".$directory."/test_".$filename; + $absolute_test_path = Filesystem::resolvePath($relative_test_path); + + if (is_readable($absolute_test_path)) { + $affected_tests[] = $absolute_test_path; + } + } + } + + if (empty($affected_tests)) { + return array(); + } + + $futures = array(); + $tmpfiles = array(); + foreach ($affected_tests as $test_path) { + $xunit_tmp = new TempFile(); + $cover_tmp = new TempFile(); + + $future = $this->buildTestFuture($test_path, + $xunit_tmp, + $cover_tmp); + + $futures[$test_path] = $future; + $tmpfiles[$test_path] = array( + 'xunit' => $xunit_tmp, + 'cover' => $cover_tmp, + ); + } + + $results = array(); + foreach (Futures($futures)->limit(4) as $test_path => $future) { + try { + list($stdout, $stderr) = $future->resolvex(); + } catch(CommandException $exc) { + if ($exc->getError() > 1) { + // 'nose' returns 1 when tests are failing/broken. + throw $exc; + } + } + + $xunit_tmp = $tmpfiles[$test_path]['xunit']; + $cover_tmp = $tmpfiles[$test_path]['cover']; + + $results[] = $this->parseTestResults($test_path, + $xunit_tmp, + $cover_tmp); + } + + return array_mergev($results); + } + + public function buildTestFuture($path, $xunit_tmp, $cover_tmp) { + $cmd_line = csprintf("nosetests --with-xunit --xunit-file=%s", + $xunit_tmp); + + if ($this->getEnableCoverage() !== false) { + $cmd_line .= csprintf(" --with-coverage --cover-xml " . + "--cover-xml-file=%s", + $cover_tmp); + } + + return new ExecFuture("%C %s", $cmd_line, $path); + } + + public function parseTestResults($path, $xunit_tmp, $cover_tmp) { + // xunit xsd: https://gist.github.com/959290 + $xunit_dom = new DOMDocument(); + $xunit_dom->loadXML(Filesystem::readFile($xunit_tmp)); + + // coverage is for all testcases in the executed $path + $coverage = array(); + if ($this->getEnableCoverage() !== false) { + $coverage = $this->readCoverage($cover_tmp); + } + + $results = array(); + $testcases = $xunit_dom->getElementsByTagName("testcase"); + foreach ($testcases as $testcase) { + $classname = $testcase->getAttribute("classname"); + $name = $testcase->getAttribute("name"); + $time = $testcase->getAttribute("time"); + + $status = ArcanistUnitTestResult::RESULT_PASS; + $user_data = ""; + + // A skipped test is a test which was ignored using framework + // mechanizms (e.g. @skip decorator) + $skipped = $testcase->getElementsByTagName("skipped"); + if ($skipped->length > 0) { + $status = ArcanistUnitTestResult::RESULT_SKIP; + $messages = array(); + for ($ii = 0; $ii < $skipped->length; $ii++) { + $messages[] = trim($skipped->item($ii)->nodeValue, " \n"); + } + + $user_data .= implode("\n", $messages); + } + + // Failure is a test which the code has explicitly failed by using + // the mechanizms for that purpose. e.g., via an assertEquals + $failures = $testcase->getElementsByTagName("failure"); + if ($failures->length > 0) { + $status = ArcanistUnitTestResult::RESULT_FAIL; + $messages = array(); + for ($ii = 0; $ii < $failures->length; $ii++) { + $messages[] = trim($failures->item($ii)->nodeValue, " \n"); + } + + $user_data .= implode("\n", $messages)."\n"; + } + + // An errored test is one that had an unanticipated problem. e.g., an + // unchecked throwable, or a problem with an implementation of the + // test. + $errors = $testcase->getElementsByTagName("error"); + if ($errors->length > 0) { + $status = ArcanistUnitTestResult::RESULT_BROKEN; + $messages = array(); + for ($ii = 0; $ii < $errors->length; $ii++) { + $messages[] = trim($errors->item($ii)->nodeValue, " \n"); + } + + $user_data .= implode("\n", $messages)."\n"; + } + + $result = new ArcanistUnitTestResult(); + $result->setName($classname.".".$name); + $result->setResult($status); + $result->setDuration($time); + $result->setCoverage($coverage); + $result->setUserData($user_data); + + $results[] = $result; + } + + return $results; + } + + public function readCoverage($path) { + $coverage_dom = new DOMDocument(); + $coverage_dom->loadXML(Filesystem::readFile($path)); + + $reports = array(); + $classes = $coverage_dom->getElementsByTagName("class"); + + foreach ($classes as $class) { + // filename is actually python module path with ".py" at the end, + // e.g.: tornado.web.py + $relative_path = explode(".", $class->getAttribute("filename")); + array_pop($relative_path); + $relative_path = implode("/", $relative_path); + + // first we check if the path is a directory (a Python package), if it is + // set relative and absolute paths to have __init__.py at the end. + $absolute_path = Filesystem::resolvePath($relative_path); + if (is_dir($absolute_path)) { + $relative_path .= "/__init__.py"; + $absolute_path .= "/__init__.py"; + } + + // then we check if the path with ".py" at the end is file (a Python + // submodule), if it is - set relative and absolute paths to have + // ".py" at the end. + if (is_file($absolute_path.".py")) { + $relative_path .= ".py"; + $absolute_path .= ".py"; + } + + if (!file_exists($absolute_path)) { + continue; + } + + // get total line count in file + $line_count = count(file($absolute_path)); + + $coverage = ""; + $start_line = 1; + $lines = $class->getElementsByTagName("line"); + for ($ii = 0; $ii < $lines->length; $ii++) { + $line = $lines->item($ii); + + $next_line = intval($line->getAttribute("number")); + for ($start_line; $start_line < $next_line; $start_line++) { + $coverage .= "N"; + } + + if (intval($line->getAttribute("hits")) == 0) { + $coverage .= "U"; + } + else if (intval($line->getAttribute("hits")) > 0) { + $coverage .= "C"; + } + + $start_line++; + } + + if ($start_line < $line_count) { + foreach (range($start_line, $line_count) as $line_num) { + $coverage .= "N"; + } + } + + $reports[$relative_path] = $coverage; + } + + return $reports; + } + +} diff --git a/src/unit/engine/nose/__init__.php b/src/unit/engine/nose/__init__.php new file mode 100644 index 00000000..cb8d2beb --- /dev/null +++ b/src/unit/engine/nose/__init__.php @@ -0,0 +1,20 @@ +