| 1 | |
|---|
| 2 | """This plugin provides test results in the standard XUnit XML format. |
|---|
| 3 | |
|---|
| 4 | It was designed for the `Hudson`_ continuous build system but will |
|---|
| 5 | probably work for anything else that understands an XUnit-formatted XML |
|---|
| 6 | representation of test results. |
|---|
| 7 | |
|---|
| 8 | Add this shell command to your builder :: |
|---|
| 9 | |
|---|
| 10 | nosetests --with-xunit |
|---|
| 11 | |
|---|
| 12 | And by default a file named nosetests.xml will be written to the |
|---|
| 13 | working directory. |
|---|
| 14 | |
|---|
| 15 | In a Hudson builder, tick the box named "Publish JUnit test result report" |
|---|
| 16 | under the Post-build Actions and enter this value for Test report XMLs:: |
|---|
| 17 | |
|---|
| 18 | **/nosetests.xml |
|---|
| 19 | |
|---|
| 20 | If you need to change the name or location of the file, you can set the |
|---|
| 21 | ``--xunit-file`` option. |
|---|
| 22 | |
|---|
| 23 | Here is an abbreviated version of what an XML test report might look like:: |
|---|
| 24 | |
|---|
| 25 | <?xml version="1.0" encoding="UTF-8"?> |
|---|
| 26 | <testsuite name="nosetests" tests="1" errors="1" failures="0" skip="0"> |
|---|
| 27 | <testcase classname="path_to_test_suite.TestSomething" |
|---|
| 28 | name="path_to_test_suite.TestSomething.test_it" time="0"> |
|---|
| 29 | <error type="exceptions.TypeError"> |
|---|
| 30 | Traceback (most recent call last): |
|---|
| 31 | ... |
|---|
| 32 | TypeError: oops, wrong type |
|---|
| 33 | </error> |
|---|
| 34 | </testcase> |
|---|
| 35 | </testsuite> |
|---|
| 36 | |
|---|
| 37 | .. _Hudson: https://hudson.dev.java.net/ |
|---|
| 38 | |
|---|
| 39 | """ |
|---|
| 40 | |
|---|
| 41 | import os |
|---|
| 42 | import traceback |
|---|
| 43 | import re |
|---|
| 44 | import inspect |
|---|
| 45 | from nose.plugins.base import Plugin |
|---|
| 46 | from nose.exc import SkipTest |
|---|
| 47 | from time import time |
|---|
| 48 | import doctest |
|---|
| 49 | |
|---|
| 50 | def xmlsafe(s, encoding="utf-8"): |
|---|
| 51 | """Used internally to escape XML.""" |
|---|
| 52 | if isinstance(s, unicode): |
|---|
| 53 | s = s.encode(encoding) |
|---|
| 54 | s = str(s) |
|---|
| 55 | for src, rep in [('&', '&', ), |
|---|
| 56 | ('<', '<', ), |
|---|
| 57 | ('>', '>', ), |
|---|
| 58 | ('"', '"', ), |
|---|
| 59 | ("'", ''', ), |
|---|
| 60 | ]: |
|---|
| 61 | s = s.replace(src, rep) |
|---|
| 62 | return s |
|---|
| 63 | |
|---|
| 64 | def nice_classname(obj): |
|---|
| 65 | """Returns a nice name for class object or class instance. |
|---|
| 66 | |
|---|
| 67 | >>> nice_classname(Exception()) # doctest: +ELLIPSIS |
|---|
| 68 | '...Exception' |
|---|
| 69 | >>> nice_classname(Exception) |
|---|
| 70 | 'exceptions.Exception' |
|---|
| 71 | |
|---|
| 72 | """ |
|---|
| 73 | if inspect.isclass(obj): |
|---|
| 74 | cls_name = obj.__name__ |
|---|
| 75 | else: |
|---|
| 76 | cls_name = obj.__class__.__name__ |
|---|
| 77 | mod = inspect.getmodule(obj) |
|---|
| 78 | if mod: |
|---|
| 79 | name = mod.__name__ |
|---|
| 80 | # jython |
|---|
| 81 | if name.startswith('org.python.core.'): |
|---|
| 82 | name = name[len('org.python.core.'):] |
|---|
| 83 | return "%s.%s" % (name, cls_name) |
|---|
| 84 | else: |
|---|
| 85 | return cls_name |
|---|
| 86 | |
|---|
| 87 | class Xunit(Plugin): |
|---|
| 88 | """This plugin provides test results in the standard XUnit XML format.""" |
|---|
| 89 | name = 'xunit' |
|---|
| 90 | score = 2000 |
|---|
| 91 | encoding = 'UTF-8' |
|---|
| 92 | |
|---|
| 93 | def _timeTaken(self): |
|---|
| 94 | if hasattr(self, '_timer'): |
|---|
| 95 | taken = time() - self._timer |
|---|
| 96 | else: |
|---|
| 97 | # test died before it ran (probably error in setup()) |
|---|
| 98 | # or success/failure added before test started probably |
|---|
| 99 | # due to custom TestResult munging |
|---|
| 100 | taken = 0.0 |
|---|
| 101 | return taken |
|---|
| 102 | |
|---|
| 103 | def _xmlsafe(self, s): |
|---|
| 104 | return xmlsafe(s, encoding=self.encoding) |
|---|
| 105 | |
|---|
| 106 | def options(self, parser, env): |
|---|
| 107 | """Sets additional command line options.""" |
|---|
| 108 | Plugin.options(self, parser, env) |
|---|
| 109 | parser.add_option( |
|---|
| 110 | '--xunit-file', action='store', |
|---|
| 111 | dest='xunit_file', metavar="FILE", |
|---|
| 112 | default=env.get('NOSE_XUNIT_FILE', 'nosetests.xml'), |
|---|
| 113 | help=("Path to xml file to store the xunit report in. " |
|---|
| 114 | "Default is nosetests.xml in the working directory " |
|---|
| 115 | "[NOSE_XUNIT_FILE]")) |
|---|
| 116 | |
|---|
| 117 | def configure(self, options, config): |
|---|
| 118 | """Configures the xunit plugin.""" |
|---|
| 119 | Plugin.configure(self, options, config) |
|---|
| 120 | self.config = config |
|---|
| 121 | if self.enabled: |
|---|
| 122 | self.stats = {'errors': 0, |
|---|
| 123 | 'failures': 0, |
|---|
| 124 | 'passes': 0, |
|---|
| 125 | 'skipped': 0 |
|---|
| 126 | } |
|---|
| 127 | self.errorlist = [] |
|---|
| 128 | self.error_report_file = open(options.xunit_file, 'w') |
|---|
| 129 | |
|---|
| 130 | def report(self, stream): |
|---|
| 131 | """Writes an Xunit-formatted XML file |
|---|
| 132 | |
|---|
| 133 | The file includes a report of test errors and failures. |
|---|
| 134 | |
|---|
| 135 | """ |
|---|
| 136 | self.stats['encoding'] = self.encoding |
|---|
| 137 | self.stats['total'] = (self.stats['errors'] + self.stats['failures'] |
|---|
| 138 | + self.stats['passes'] + self.stats['skipped']) |
|---|
| 139 | self.error_report_file.write( |
|---|
| 140 | '<?xml version="1.0" encoding="%(encoding)s"?>' |
|---|
| 141 | '<testsuite name="nosetests" tests="%(total)d" ' |
|---|
| 142 | 'errors="%(errors)d" failures="%(failures)d" ' |
|---|
| 143 | 'skip="%(skipped)d">' % self.stats) |
|---|
| 144 | self.error_report_file.write(''.join(self.errorlist)) |
|---|
| 145 | self.error_report_file.write('</testsuite>') |
|---|
| 146 | self.error_report_file.close() |
|---|
| 147 | if self.config.verbosity > 1: |
|---|
| 148 | stream.writeln("-" * 70) |
|---|
| 149 | stream.writeln("XML: %s" % self.error_report_file.name) |
|---|
| 150 | |
|---|
| 151 | def startTest(self, test): |
|---|
| 152 | """Initializes a timer before starting a test.""" |
|---|
| 153 | self._timer = time() |
|---|
| 154 | |
|---|
| 155 | def addError(self, test, err, capt=None): |
|---|
| 156 | """Add error output to Xunit report. |
|---|
| 157 | """ |
|---|
| 158 | taken = self._timeTaken() |
|---|
| 159 | |
|---|
| 160 | if issubclass(err[0], SkipTest): |
|---|
| 161 | self.stats['skipped'] +=1 |
|---|
| 162 | return |
|---|
| 163 | tb = ''.join(traceback.format_exception(*err)) |
|---|
| 164 | self.stats['errors'] += 1 |
|---|
| 165 | id = test.id() |
|---|
| 166 | self.errorlist.append( |
|---|
| 167 | '<testcase classname="%(cls)s" name="%(name)s" time="%(taken)d">' |
|---|
| 168 | '<error type="%(errtype)s">%(tb)s</error></testcase>' % |
|---|
| 169 | {'cls': self._xmlsafe('.'.join(id.split('.')[:-1])), |
|---|
| 170 | 'name': self._xmlsafe(id), |
|---|
| 171 | 'errtype': self._xmlsafe(nice_classname(err[0])), |
|---|
| 172 | 'tb': self._xmlsafe(tb), |
|---|
| 173 | 'taken': taken, |
|---|
| 174 | }) |
|---|
| 175 | |
|---|
| 176 | def addFailure(self, test, err, capt=None, tb_info=None): |
|---|
| 177 | """Add failure output to Xunit report. |
|---|
| 178 | """ |
|---|
| 179 | taken = self._timeTaken() |
|---|
| 180 | tb = ''.join(traceback.format_exception(*err)) |
|---|
| 181 | self.stats['failures'] += 1 |
|---|
| 182 | id = test.id() |
|---|
| 183 | self.errorlist.append( |
|---|
| 184 | '<testcase classname="%(cls)s" name="%(name)s" time="%(taken)d">' |
|---|
| 185 | '<failure type="%(errtype)s">%(tb)s</failure></testcase>' % |
|---|
| 186 | {'cls': self._xmlsafe('.'.join(id.split('.')[:-1])), |
|---|
| 187 | 'name': self._xmlsafe(id), |
|---|
| 188 | 'errtype': self._xmlsafe(nice_classname(err[0])), |
|---|
| 189 | 'tb': self._xmlsafe(tb), |
|---|
| 190 | 'taken': taken, |
|---|
| 191 | }) |
|---|
| 192 | |
|---|
| 193 | def addSuccess(self, test, capt=None): |
|---|
| 194 | """Add success output to Xunit report. |
|---|
| 195 | """ |
|---|
| 196 | taken = self._timeTaken() |
|---|
| 197 | |
|---|
| 198 | self.stats['passes'] += 1 |
|---|
| 199 | id = test.id() |
|---|
| 200 | self.errorlist.append( |
|---|
| 201 | '<testcase classname="%(cls)s" name="%(name)s" ' |
|---|
| 202 | 'time="%(taken)d" />' % |
|---|
| 203 | {'cls': self._xmlsafe('.'.join(id.split('.')[:-1])), |
|---|
| 204 | 'name': self._xmlsafe(id), |
|---|
| 205 | 'taken': taken, |
|---|
| 206 | }) |
|---|
| 207 | |
|---|
| 208 | |
|---|