Repository: pantsbuild/pants

Issue:
JUnit Test Report file parsing fails when a test case is missing the "time" attribute
Following the upgrade to `1.3.0rc2`, we started receiving multiple errors while pants is trying to process the JUnit test result files.  The errors are of form:

```
Error parsing test result file /path/to/TEST-suite.xml: could not convert string to float
```

This is a regression from the previous version, `1.3.0.dev19`.  Upon investigating, it became apparent that every test suite with an error has at least one test case that lacks the `"time"` attribute in the test report.  For example, `TEST-mypackage.MyTest.xml` might have the following contents:

```
   ...
    <testcase classname="mypackage.MyTest" name="test1"/>
    <testcase classname="mypackage.MyTest" name="test2" time="0.001"/>
    <testcase classname="mypackage.MyTest" name="test3" time="0.004"/>
   ...
```

The error appears to be caused by the following commit: https://github.com/pantsbuild/pants/pull/4561/files.  The problematic code appears to be on line 120 in `src/python/pants/task/testrunner_task_mixin.py`:

```
test_info = {'time': float(testcase.getAttribute('time'))}
```

The code doesn't check if the `"time"` attribute before converting it to a float.


Discussion:
Thanks for the report!
Thanks! Marked as a `1.3.0` blocker. `rc2` and `rc3` ran our entire internal sandbox and saw this in only one case (which succeeded after retry), so it's interesting that you're seeing it more frequently.
@stuhood Thanks for the quick reply!  I didn't dig in too deeply into when the `"time"` attribute is omitted from test reports, but we have a ton of those cases in our code base, especially for ScalaTest tests.  In particular, I've noticed that every time a test is canceled (e.g. because an `assume` condition doesn't hold up), the `"time"` attribute is omitted.


Resolving patch:
diff --git a/src/python/pants/task/testrunner_task_mixin.py b/src/python/pants/task/testrunner_task_mixin.py
--- a/src/python/pants/task/testrunner_task_mixin.py
+++ b/src/python/pants/task/testrunner_task_mixin.py
@@ -101,9 +101,12 @@ def parse_test_info(xml_path, error_handler, additional_testcase_attributes=None
     """Parses the junit file for information needed about each test.
 
     Will include:
-      - test result
-      - test run time duration
       - test name
+      - test result
+      - test run time duration or None if not a parsable float
+
+    If additional test case attributes are defined, then it will include those as well.
+
     :param string xml_path: The path of the xml file to be parsed.
     :param function error_handler: The error handler function.
     :param list of string additional_testcase_attributes: A list of additional attributes belonging
@@ -133,7 +136,13 @@ def parse_xml_file(path):
       try:
         xml = XmlParser.from_file(path)
         for testcase in xml.parsed.getElementsByTagName('testcase'):
-          test_info = {'time': float(testcase.getAttribute('time'))}
+          test_info = {}
+
+          try:
+            test_info.update({'time': float(testcase.getAttribute('time'))})
+          except:
+            test_info.update({'time': None})
+
           for attribute in testcase_attributes:
             test_info[attribute] = testcase.getAttribute(attribute)
 


Test patch:
diff --git a/tests/python/pants_test/task/test_testrunner_task_mixin.py b/tests/python/pants_test/task/test_testrunner_task_mixin.py
--- a/tests/python/pants_test/task/test_testrunner_task_mixin.py
+++ b/tests/python/pants_test/task/test_testrunner_task_mixin.py
@@ -433,6 +433,38 @@ def test_parse_test_info_all_testcases(self):
           }
         }, tests_info)
 
+  def test_parse_test_info_with_missing_attributes(self):
+    with temporary_dir() as xml_dir:
+      with open(os.path.join(xml_dir, 'TEST-a.xml'), 'w') as fp:
+        fp.write("""
+        <testsuite failures="1">
+          <testcase classname="org.pantsbuild.Green" name="testOK"/>
+          <testcase classname="org.pantsbuild.Failure" time="0.27">
+            <failure/>
+          </testcase>
+          <testcase classname="org.pantsbuild.Skipped" name="testSkipped" time="0.1" extra="">
+            <skipped/>
+          </testcase>
+        </testsuite>
+        """)
+
+      tests_info = self.parse_test_info(xml_dir, self._raise_handler)
+      self.assertEqual(
+        {
+          'testOK': {
+            'result_code': 'success',
+            'time': None
+          },
+          '': {
+            'result_code': 'failure',
+            'time': 0.27
+          },
+          'testSkipped': {
+            'result_code': 'skipped',
+            'time': 0.1
+          }
+        }, tests_info)
+
   def test_parse_test_info_invalid_file_name(self):
     with temporary_dir() as xml_dir:
       with open(os.path.join(xml_dir, 'random.xml'), 'w') as fp:
@@ -463,13 +495,7 @@ def test_parse_test_info_error_continue(self):
     with temporary_dir() as xml_dir:
       bad_file1 = os.path.join(xml_dir, 'TEST-bad1.xml')
       with open(bad_file1, 'w') as fp:
-        fp.write("""
-        <testsuite failures="0" errors="1">
-          <testcase classname="org.pantsbuild.Error" name="testError" time="zero">
-            <error/>
-          </testcase>
-        </testsuite>
-        """)
+        fp.write('<invalid></xml>')
       with open(os.path.join(xml_dir, 'TEST-good.xml'), 'w') as fp:
         fp.write("""
         <testsuite failures="0" errors="1">
@@ -478,14 +504,11 @@ def test_parse_test_info_error_continue(self):
           </testcase>
         </testsuite>
         """)
-      bad_file2 = os.path.join(xml_dir, 'TEST-bad2.xml')
-      with open(bad_file2, 'w') as fp:
-        fp.write('<invalid></xml>')
 
       collect_handler = self.CollectHandler()
       tests_info = self.parse_test_info(xml_dir, collect_handler)
-      self.assertEqual(2, len(collect_handler.errors))
-      self.assertEqual({bad_file1, bad_file2}, {e.xml_path for e in collect_handler.errors})
+      self.assertEqual(1, len(collect_handler.errors))
+      self.assertEqual({bad_file1}, {e.xml_path for e in collect_handler.errors})
 
       self.assertEqual(
         {'testError':
@@ -500,7 +523,11 @@ def test_parse_test_info_extra_attributes(self):
       with open(os.path.join(xml_dir, 'TEST-a.xml'), 'w') as fp:
         fp.write("""
         <testsuite errors="1">
-          <testcase classname="org.pantsbuild.Green" name="testOK" time="1.290" file="file.py"/>
+          <testcase classname="org.pantsbuild.Green" name="testOK1" time="1.290" file="file.py"/>
+          <testcase classname="org.pantsbuild.Green" name="testOK2" time="1.12"/>
+          <testcase classname="org.pantsbuild.Green" name="testOK3" file="file.py"/>
+          <testcase name="testOK4" time="1.79" file="file.py"/>
+          <testcase name="testOK5" time="0.832"/>
           <testcase classname="org.pantsbuild.Error" name="testError" time="0.27" file="file.py">
             <error/>
           </testcase>
@@ -510,12 +537,36 @@ def test_parse_test_info_extra_attributes(self):
       tests_info = self.parse_test_info(xml_dir, self._raise_handler, ['file', 'classname'])
       self.assertEqual(
         {
-          'testOK': {
+          'testOK1': {
             'file': 'file.py',
             'classname': 'org.pantsbuild.Green',
             'result_code': 'success',
             'time': 1.290
           },
+          'testOK2': {
+            'file': '',
+            'classname': 'org.pantsbuild.Green',
+            'result_code': 'success',
+            'time': 1.12
+          },
+          'testOK3': {
+            'file': 'file.py',
+            'classname': 'org.pantsbuild.Green',
+            'result_code': 'success',
+            'time': None
+          },
+          'testOK4': {
+            'file': 'file.py',
+            'classname': '',
+            'result_code': 'success',
+            'time': 1.79
+          },
+          'testOK5': {
+            'file': '',
+            'classname': '',
+            'result_code': 'success',
+            'time': 0.832
+          },
           'testError': {
             'file': 'file.py',
             'classname': 'org.pantsbuild.Error',