1 """This plugin provides test results in the standard XUnit XML format.
3 It's designed for the `Jenkins`_ (previously Hudson) continuous build
4 system, but will probably work for anything else that understands an
5 XUnit-formatted XML representation of test results.
7 Add this shell command to your builder ::
11 And by default a file named nosetests.xml will be written to the
14 In a Jenkins builder, tick the box named "Publish JUnit test result report"
15 under the Post-build Actions and enter this value for Test report XMLs::
19 If you need to change the name or location of the file, you can set the
20 ``--xunit-file`` option.
22 Here is an abbreviated version of what an XML test report might look like::
24 <?xml version="1.0" encoding="UTF-8"?>
25 <testsuite name="nosetests" tests="1" errors="1" failures="0" skip="0">
26 <testcase classname="path_to_test_suite.TestSomething"
27 name="test_it" time="0">
28 <error type="exceptions.TypeError" message="oops, wrong type">
29 Traceback (most recent call last):
31 TypeError: oops, wrong type
36 .. _Jenkins: http://jenkins-ci.org/
46 from xml.sax import saxutils
48 from nose.plugins.base import Plugin
49 from nose.exc import SkipTest
50 from nose.pyversion import UNICODE_STRINGS
52 # Invalid XML characters, control characters 0-31 sans \t, \n and \r
53 CONTROL_CHARACTERS = re.compile(r"[\000-\010\013\014\016-\037]")
55 TEST_ID = re.compile(r'^(.*?)(\(.*\))$')
58 """Replaces invalid XML characters with '?'."""
59 return CONTROL_CHARACTERS.sub('?', value)
61 def escape_cdata(cdata):
62 """Escape a string for an XML CDATA section."""
63 return xml_safe(cdata).replace(']]>', ']]>]]><![CDATA[')
66 m = TEST_ID.match(idval)
68 name, fargs = m.groups()
69 head, tail = name.rsplit(".", 1)
70 return [head, tail+fargs]
72 return idval.rsplit(".", 1)
74 def nice_classname(obj):
75 """Returns a nice name for class object or class instance.
77 >>> nice_classname(Exception()) # doctest: +ELLIPSIS
79 >>> nice_classname(Exception) # doctest: +ELLIPSIS
83 if inspect.isclass(obj):
84 cls_name = obj.__name__
86 cls_name = obj.__class__.__name__
87 mod = inspect.getmodule(obj)
91 if name.startswith('org.python.core.'):
92 name = name[len('org.python.core.'):]
93 return "%s.%s" % (name, cls_name)
97 def exc_message(exc_info):
98 """Return the exception's message."""
106 except UnicodeEncodeError:
108 result = unicode(exc)
110 # Fallback to args as neither str nor
111 # unicode(Exception(u'\xe6')) work in Python < 2.6
113 return xml_safe(result)
116 """This plugin provides test results in the standard XUnit XML format."""
120 error_report_file = None
122 def _timeTaken(self):
123 if hasattr(self, '_timer'):
124 taken = time() - self._timer
126 # test died before it ran (probably error in setup())
127 # or success/failure added before test started probably
128 # due to custom TestResult munging
132 def _quoteattr(self, attr):
133 """Escape an XML attribute. Value can be unicode."""
134 attr = xml_safe(attr)
135 if isinstance(attr, unicode) and not UNICODE_STRINGS:
136 attr = attr.encode(self.encoding)
137 return saxutils.quoteattr(attr)
139 def options(self, parser, env):
140 """Sets additional command line options."""
141 Plugin.options(self, parser, env)
143 '--xunit-file', action='store',
144 dest='xunit_file', metavar="FILE",
145 default=env.get('NOSE_XUNIT_FILE', 'nosetests.xml'),
146 help=("Path to xml file to store the xunit report in. "
147 "Default is nosetests.xml in the working directory "
148 "[NOSE_XUNIT_FILE]"))
150 def configure(self, options, config):
151 """Configures the xunit plugin."""
152 Plugin.configure(self, options, config)
155 self.stats = {'errors': 0,
161 self.error_report_file = codecs.open(options.xunit_file, 'w',
162 self.encoding, 'replace')
164 def report(self, stream):
165 """Writes an Xunit-formatted XML file
167 The file includes a report of test errors and failures.
170 self.stats['encoding'] = self.encoding
171 self.stats['total'] = (self.stats['errors'] + self.stats['failures']
172 + self.stats['passes'] + self.stats['skipped'])
173 self.error_report_file.write(
174 u'<?xml version="1.0" encoding="%(encoding)s"?>'
175 u'<testsuite name="nosetests" tests="%(total)d" '
176 u'errors="%(errors)d" failures="%(failures)d" '
177 u'skip="%(skipped)d">' % self.stats)
178 self.error_report_file.write(u''.join([self._forceUnicode(e)
179 for e in self.errorlist]))
180 self.error_report_file.write(u'</testsuite>')
181 self.error_report_file.close()
182 if self.config.verbosity > 1:
183 stream.writeln("-" * 70)
184 stream.writeln("XML: %s" % self.error_report_file.name)
186 def startTest(self, test):
187 """Initializes a timer before starting a test."""
190 def addError(self, test, err, capt=None):
191 """Add error output to Xunit report.
193 taken = self._timeTaken()
195 if issubclass(err[0], SkipTest):
197 self.stats['skipped'] += 1
200 self.stats['errors'] += 1
201 tb = ''.join(traceback.format_exception(*err))
203 self.errorlist.append(
204 '<testcase classname=%(cls)s name=%(name)s time="%(taken).3f">'
205 '<%(type)s type=%(errtype)s message=%(message)s><![CDATA[%(tb)s]]>'
206 '</%(type)s></testcase>' %
207 {'cls': self._quoteattr(id_split(id)[0]),
208 'name': self._quoteattr(id_split(id)[-1]),
211 'errtype': self._quoteattr(nice_classname(err[0])),
212 'message': self._quoteattr(exc_message(err)),
213 'tb': escape_cdata(tb),
216 def addFailure(self, test, err, capt=None, tb_info=None):
217 """Add failure output to Xunit report.
219 taken = self._timeTaken()
220 tb = ''.join(traceback.format_exception(*err))
221 self.stats['failures'] += 1
223 self.errorlist.append(
224 '<testcase classname=%(cls)s name=%(name)s time="%(taken).3f">'
225 '<failure type=%(errtype)s message=%(message)s><![CDATA[%(tb)s]]>'
226 '</failure></testcase>' %
227 {'cls': self._quoteattr(id_split(id)[0]),
228 'name': self._quoteattr(id_split(id)[-1]),
230 'errtype': self._quoteattr(nice_classname(err[0])),
231 'message': self._quoteattr(exc_message(err)),
232 'tb': escape_cdata(tb),
235 def addSuccess(self, test, capt=None):
236 """Add success output to Xunit report.
238 taken = self._timeTaken()
239 self.stats['passes'] += 1
241 self.errorlist.append(
242 '<testcase classname=%(cls)s name=%(name)s '
243 'time="%(taken).3f" />' %
244 {'cls': self._quoteattr(id_split(id)[0]),
245 'name': self._quoteattr(id_split(id)[-1]),
249 def _forceUnicode(self, s):
250 if not UNICODE_STRINGS:
251 if isinstance(s, str):
252 s = s.decode(self.encoding, 'replace')