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 | |
---|