Imported Upstream version 1.1.2
[platform/upstream/python-nose.git] / nose / plugins / xunit.py
1 """This plugin provides test results in the standard XUnit XML format.
2
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.
6
7 Add this shell command to your builder ::
8
9     nosetests --with-xunit
10
11 And by default a file named nosetests.xml will be written to the
12 working directory.
13
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::
16
17     **/nosetests.xml
18
19 If you need to change the name or location of the file, you can set the
20 ``--xunit-file`` option.
21
22 Here is an abbreviated version of what an XML test report might look like::
23
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):
30             ...
31             TypeError: oops, wrong type
32             </error>
33         </testcase>
34     </testsuite>
35
36 .. _Jenkins: http://jenkins-ci.org/
37
38 """
39 import codecs
40 import doctest
41 import os
42 import traceback
43 import re
44 import inspect
45 from time import time
46 from xml.sax import saxutils
47
48 from nose.plugins.base import Plugin
49 from nose.exc import SkipTest
50 from nose.pyversion import UNICODE_STRINGS
51
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]")
54
55 TEST_ID = re.compile(r'^(.*?)(\(.*\))$')
56
57 def xml_safe(value):
58     """Replaces invalid XML characters with '?'."""
59     return CONTROL_CHARACTERS.sub('?', value)
60
61 def escape_cdata(cdata):
62     """Escape a string for an XML CDATA section."""
63     return xml_safe(cdata).replace(']]>', ']]>]]&gt;<![CDATA[')
64
65 def id_split(idval):
66     m = TEST_ID.match(idval)
67     if m:
68         name, fargs = m.groups()
69         head, tail = name.rsplit(".", 1)
70         return [head, tail+fargs]
71     else:
72         return idval.rsplit(".", 1)
73
74 def nice_classname(obj):
75     """Returns a nice name for class object or class instance.
76
77         >>> nice_classname(Exception()) # doctest: +ELLIPSIS
78         '...Exception'
79         >>> nice_classname(Exception) # doctest: +ELLIPSIS
80         '...Exception'
81
82     """
83     if inspect.isclass(obj):
84         cls_name = obj.__name__
85     else:
86         cls_name = obj.__class__.__name__
87     mod = inspect.getmodule(obj)
88     if mod:
89         name = mod.__name__
90         # jython
91         if name.startswith('org.python.core.'):
92             name = name[len('org.python.core.'):]
93         return "%s.%s" % (name, cls_name)
94     else:
95         return cls_name
96
97 def exc_message(exc_info):
98     """Return the exception's message."""
99     exc = exc_info[1]
100     if exc is None:
101         # str exception
102         result = exc_info[0]
103     else:
104         try:
105             result = str(exc)
106         except UnicodeEncodeError:
107             try:
108                 result = unicode(exc)
109             except UnicodeError:
110                 # Fallback to args as neither str nor
111                 # unicode(Exception(u'\xe6')) work in Python < 2.6
112                 result = exc.args[0]
113     return xml_safe(result)
114
115 class Xunit(Plugin):
116     """This plugin provides test results in the standard XUnit XML format."""
117     name = 'xunit'
118     score = 2000
119     encoding = 'UTF-8'
120     error_report_file = None
121
122     def _timeTaken(self):
123         if hasattr(self, '_timer'):
124             taken = time() - self._timer
125         else:
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
129             taken = 0.0
130         return taken
131
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)
138
139     def options(self, parser, env):
140         """Sets additional command line options."""
141         Plugin.options(self, parser, env)
142         parser.add_option(
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]"))
149
150     def configure(self, options, config):
151         """Configures the xunit plugin."""
152         Plugin.configure(self, options, config)
153         self.config = config
154         if self.enabled:
155             self.stats = {'errors': 0,
156                           'failures': 0,
157                           'passes': 0,
158                           'skipped': 0
159                           }
160             self.errorlist = []
161             self.error_report_file = codecs.open(options.xunit_file, 'w',
162                                                  self.encoding, 'replace')
163
164     def report(self, stream):
165         """Writes an Xunit-formatted XML file
166
167         The file includes a report of test errors and failures.
168
169         """
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)
185
186     def startTest(self, test):
187         """Initializes a timer before starting a test."""
188         self._timer = time()
189
190     def addError(self, test, err, capt=None):
191         """Add error output to Xunit report.
192         """
193         taken = self._timeTaken()
194
195         if issubclass(err[0], SkipTest):
196             type = 'skipped'
197             self.stats['skipped'] += 1
198         else:
199             type = 'error'
200             self.stats['errors'] += 1
201         tb = ''.join(traceback.format_exception(*err))
202         id = test.id()
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]),
209              'taken': taken,
210              'type': type,
211              'errtype': self._quoteattr(nice_classname(err[0])),
212              'message': self._quoteattr(exc_message(err)),
213              'tb': escape_cdata(tb),
214              })
215
216     def addFailure(self, test, err, capt=None, tb_info=None):
217         """Add failure output to Xunit report.
218         """
219         taken = self._timeTaken()
220         tb = ''.join(traceback.format_exception(*err))
221         self.stats['failures'] += 1
222         id = test.id()
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]),
229              'taken': taken,
230              'errtype': self._quoteattr(nice_classname(err[0])),
231              'message': self._quoteattr(exc_message(err)),
232              'tb': escape_cdata(tb),
233              })
234
235     def addSuccess(self, test, capt=None):
236         """Add success output to Xunit report.
237         """
238         taken = self._timeTaken()
239         self.stats['passes'] += 1
240         id = test.id()
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]),
246              'taken': taken,
247              })
248
249     def _forceUnicode(self, s):
250         if not UNICODE_STRINGS:
251             if isinstance(s, str):
252                 s = s.decode(self.encoding, 'replace')
253         return s