2 A TestRunner for use with the Python unit testing framework. It
3 generates a HTML report to show the result at a glance.
5 The simplest way to use this is to invoke its main method. E.g.
10 ... define your tests ...
12 if __name__ == '__main__':
16 To customize the report, instantiates a HTMLTestRunner object and set
17 the parameters. HTMLTestRunner is a counterpart to unittest's
21 fp = file('my_report.html', 'wb')
22 runner = HTMLTestRunner.HTMLTestRunner(
25 report_attrs=[('Version','1.2.3')],
26 description='This demonstrates the report output by HTMLTestRunner.'
29 # Use an external stylesheet.
30 # See the Template_mixin class for more customizable options
31 runner.STYLESHEET_TMPL = '<link rel="stylesheet" href="my_stylesheet.css" type="text/css">'
34 runner.run(my_test_suite)
37 ------------------------------------------------------------------------
38 Copyright (c) 2004-2006, Wai Yip Tung
41 Redistribution and use in source and binary forms, with or without
42 modification, are permitted provided that the following conditions are
45 * Redistributions of source code must retain the above copyright notice,
46 this list of conditions and the following disclaimer.
47 * Redistributions in binary form must reproduce the above copyright
48 notice, this list of conditions and the following disclaimer in the
49 documentation and/or other materials provided with the distribution.
50 * Neither the name Wai Yip Tung nor the names of its contributors may be
51 used to endorse or promote products derived from this software without
52 specific prior written permission.
54 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
55 IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
56 TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
57 PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
58 OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
59 EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
60 PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
61 PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
62 LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
63 NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
64 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
67 # URL: http://tungwaiyip.info/software/HTMLTestRunner.html
69 __author__ = "Wai Yip Tung"
75 * Define Template_mixin class for customization.
76 * Workaround a IE 6 bug that it does not treat <script> block as CDATA.
79 * Back port to Python 2.3. Thank you Frank Horowitz.
80 * Fix missing scroll bars in detail log. Thank you Podi.
84 # TODO: simplify javascript using ,ore than 1 class in the class attribute?
91 from xml.sax import saxutils
94 # ------------------------------------------------------------------------
95 # The redirectors below is used to capture output during testing. Output
96 # sent to sys.stdout and sys.stderr are automatically captured. However
97 # in some cases sys.stdout is already cached before HTMLTestRunner is
98 # invoked (e.g. calling logging.basicConfig). In order to capture those
99 # output, use the redirectors for the cached stream.
102 # >>> logging.basicConfig(stream=HTMLTestRunner.stdout_redirector)
105 class OutputRedirector(object):
106 """ Wrapper to redirect stdout or stderr """
107 def __init__(self, fp):
113 def writelines(self, lines):
114 self.fp.writelines(lines)
119 stdout_redirector = OutputRedirector(sys.stdout)
120 stderr_redirector = OutputRedirector(sys.stderr)
124 # ----------------------------------------------------------------------
127 class Template_mixin(object):
129 Define a HTML template for report customerization and generation.
131 Overall structure of an HTML report
134 +------------------------+
139 | +----------------+ |
141 | +----------------+ |
148 | +----------------+ |
150 | +----------------+ |
153 | +----------------+ |
155 | +----------------+ |
158 | +----------------+ |
160 | +----------------+ |
164 +------------------------+
173 DEFAULT_TITLE = 'Unit Test Report'
174 DEFAULT_DESCRIPTION = ''
176 # ------------------------------------------------------------------------
179 HTML_TMPL = r"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
182 <title>%(title)s</title>
183 <meta name="generator" content="%(generator)s">
184 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
189 output_list = Array();
191 /* level - 0:Summary; 1:Failed; 2:All */
192 function showCase(level) {
193 trs = document.getElementsByTagName("tr");
194 for (var i = 0; i < trs.length; i++) {
197 if (id.substr(0,2) == 'ft') {
199 tr.className = 'hiddenRow';
205 if (id.substr(0,2) == 'pt') {
210 tr.className = 'hiddenRow';
216 function showClassDetail(cid, count) {
217 var id_list = Array(count);
219 for (var i = 0; i < count; i++) {
220 tid0 = 't' + cid.substr(1) + '.' + (i+1);
222 tr = document.getElementById(tid);
225 tr = document.getElementById(tid);
232 for (var i = 0; i < count; i++) {
235 document.getElementById(tid).className = 'hiddenRow';
238 document.getElementById(tid).className = '';
243 function html_escape(s) {
244 s = s.replace(/&/g,'&');
245 s = s.replace(/</g,'<');
246 s = s.replace(/>/g,'>');
250 function showOutput(id, name) {
251 var w = window.open("", //url
253 "resizable,scrollbars,status,width=800,height=450");
256 d.write(html_escape(output_list[id]));
258 d.write("<a href='javascript:window.close()'>close</a>\n");
272 # variables: (title, generator, stylesheet, heading, report, ending)
275 # ------------------------------------------------------------------------
278 # alternatively use a <link> for external style sheet, e.g.
279 # <link rel="stylesheet" href="$url" type="text/css">
281 STYLESHEET_TMPL = """
283 body { font-family: verdana, arial, helvetica, sans-serif; font-size: 80%; }
284 table { font-size: 100%; }
287 /* -- heading ---------------------------------------------------------------------- */
295 .heading .attribute {
300 .heading .description {
305 /* -- report ------------------------------------------------------------------------ */
312 border-collapse: collapse;
313 border: medium solid #777;
318 background-color: #777;
321 border: thin solid #777;
324 #total_row { font-weight: bold; }
325 .passClass { background-color: #6c6; }
326 .failClass { background-color: #c60; }
327 .errorClass { background-color: #c00; }
328 .passCase { color: #6c6; }
329 .failCase { color: #c60; font-weight: bold; }
330 .errorCase { color: #c00; font-weight: bold; }
331 .hiddenRow { display: none; }
332 .testcase { margin-left: 2em; }
335 /* -- ending ---------------------------------------------------------------------- */
344 # ------------------------------------------------------------------------
348 HEADING_TMPL = """<div class='heading'>
351 <p class='description'>%(description)s</p>
354 """ # variables: (title, parameters, description)
356 HEADING_ATTRIBUTE_TMPL = """<p class='attribute'><strong>%(name)s:</strong> %(value)s</p>
357 """ # variables: (name, value)
361 # ------------------------------------------------------------------------
366 <p id='show_detail_line'>Show
367 <a href='javascript:showCase(0)'>Summary</a>
368 <a href='javascript:showCase(1)'>Failed</a>
369 <a href='javascript:showCase(2)'>All</a>
371 <table id='result_table'>
374 <col align='right' />
375 <col align='right' />
376 <col align='right' />
377 <col align='right' />
378 <col align='right' />
381 <td>Class/Test case</td>
398 """ # variables: (test_list, count, Pass, fail, error)
401 REPORT_CLASS_TMPL = r"""
402 <tr class='%(style)s'>
408 <td><a href="javascript:showClassDetail('%(cid)s',%(count)s)">Detail</a></td>
410 """ # variables: (style, name, count, Pass, fail, error, cid)
413 REPORT_TEST_WITH_OUTPUT_TMPL = r"""
414 <tr id='%(tid)s' class='%(Class)s'>
415 <td class='%(style)s'><div class='testcase'>%(name)s<div></td>
416 <td colspan='5' align='center'><a href="javascript:showOutput('%(tid)s', '%(name)s')">%(status)s</a></td>
418 """ # variables: (tid, Class, style, name, status)
421 REPORT_TEST_NO_OUTPUT_TMPL = r"""
422 <tr id='%(tid)s' class='%(Class)s'>
423 <td class='%(style)s'><div class='testcase'>%(name)s<div></td>
424 <td colspan='5' align='center'>%(status)s</td>
426 """ # variables: (tid, Class, style, name, status)
429 REPORT_TEST_OUTPUT_TMPL = r"""
430 <script>output_list['%(id)s'] = '%(output)s';</script>
431 """ # variables: (id, output)
435 # ------------------------------------------------------------------------
439 ENDING_TMPL = """<div id='ending'> </div>"""
441 # -------------------- The end of the Template class -------------------
445 def jsEscapeString(s):
446 """ Escape s for use as a Javascript String """
447 return s.replace('\\','\\\\') \
448 .replace('\r', '\\r') \
449 .replace('\n', '\\n') \
450 .replace('"', '\\"') \
451 .replace("'", "\\'") \
452 .replace("&", '\\x26') \
453 .replace("<", '\\x3C') \
454 .replace(">", '\\x3E')
455 # Note: non-ascii unicode characters do not need to be encoded
456 # Note: previously we encode < as <, etc. However IE6 fail to treat <script> block as CDATA.
459 TestResult = unittest.TestResult
461 class _TestResult(TestResult):
462 # note: _TestResult is a pure representation of results.
463 # It lacks the output and reporting ability compares to unittest._TextTestResult.
465 def __init__(self, verbosity=1):
466 TestResult.__init__(self)
469 self.success_count = 0
470 self.failure_count = 0
472 self.verbosity = verbosity
474 # result is a list of result in 4 tuple
476 # result code (0: success; 1: fail; 2: error),
478 # Test output (byte string),
484 def startTest(self, test):
485 TestResult.startTest(self, test)
486 # just one buffer for both stdout and stderr
487 self.outputBuffer = StringIO.StringIO()
488 stdout_redirector.fp = self.outputBuffer
489 stderr_redirector.fp = self.outputBuffer
490 self.stdout0 = sys.stdout
491 self.stderr0 = sys.stderr
492 sys.stdout = stdout_redirector
493 sys.stderr = stderr_redirector
496 def complete_output(self):
498 Disconnect output redirection and return buffer.
499 Safe to call multiple times.
502 sys.stdout = self.stdout0
503 sys.stderr = self.stderr0
506 return self.outputBuffer.getvalue()
509 def stopTest(self, test):
510 # Usually one of addSuccess, addError or addFailure would have been called.
511 # But there are some path in unittest that would bypass this.
512 # We must disconnect stdout in stopTest(), which is guaranteed to be called.
513 self.complete_output()
516 def addSuccess(self, test):
517 self.success_count += 1
518 TestResult.addSuccess(self, test)
519 output = self.complete_output()
520 self.result.append((0, test, output, ''))
521 if self.verbosity > 1:
522 sys.stderr.write('ok ')
523 sys.stderr.write(str(test))
524 sys.stderr.write('\n')
526 sys.stderr.write('.')
528 def addError(self, test, err):
529 self.error_count += 1
530 TestResult.addError(self, test, err)
531 _, _exc_str = self.errors[-1]
532 output = self.complete_output()
533 self.result.append((2, test, output, _exc_str))
534 if self.verbosity > 1:
535 sys.stderr.write('E ')
536 sys.stderr.write(str(test))
537 sys.stderr.write('\n')
539 sys.stderr.write('E')
541 def addFailure(self, test, err):
542 self.failure_count += 1
543 TestResult.addFailure(self, test, err)
544 _, _exc_str = self.failures[-1]
545 output = self.complete_output()
546 self.result.append((1, test, output, _exc_str))
547 if self.verbosity > 1:
548 sys.stderr.write('F ')
549 sys.stderr.write(str(test))
550 sys.stderr.write('\n')
552 sys.stderr.write('F')
555 class HTMLTestRunner(Template_mixin):
558 def __init__(self, stream=sys.stdout, verbosity=1, title=None, report_attrs=[], description=None):
560 @param stream - output stream, default to stdout
562 @param title - use in title and heading
563 @param report_attrs - list of (name, value) to show in report
564 @param description - test description
567 self.verbosity = verbosity
569 self.title = self.DEFAULT_TITLE
572 if description is None:
573 self.description = self.DEFAULT_DESCRIPTION
575 self.description = description
576 self.report_attrs = report_attrs
578 self.startTime = datetime.datetime.now()
582 "Run the given test case or test suite."
583 result = _TestResult(self.verbosity)
585 self.stopTime = datetime.datetime.now()
586 self.generateReport(test, result)
587 print >>sys.stderr, '\nTime Elapsed: %s' % (self.stopTime-self.startTime)
591 def sortResult(self, result_list):
592 # unittest does not seems to run in any particular order.
593 # Here at least we want to group them together by class.
596 for n,t,o,e in result_list:
598 if not rmap.has_key(cls):
601 rmap[cls].append((n,t,o,e))
602 r = [(cls, rmap[cls]) for cls in classes]
606 def getReportAttributes(self, result):
608 Add a few system generated attributes on top of users defined.
609 Override this to add other dynamic custom attributes.
611 @return list of (name, value).
613 startTime = str(self.startTime)[:19]
614 duration = str(self.stopTime - self.startTime)
616 if result.success_count: status.append('Success %s' % result.success_count)
617 if result.failure_count: status.append('Failure %s' % result.failure_count)
618 if result.error_count: status.append('Error %s' % result.error_count )
620 status = ' '.join(status)
624 return [('Start Time', startTime),
625 ('Duration', duration),
627 ] + self.report_attrs
630 def generateReport(self, test, result):
631 report_attrs = self.getReportAttributes(result)
632 generator = 'HTMLTestRunner %s' % __version__
633 stylesheet = self._generate_stylesheet()
634 heading = self._generate_heading(report_attrs)
635 report = self._generate_report(result)
636 ending = self._generate_ending()
637 output = self.HTML_TMPL % dict(
638 title = saxutils.escape(self.title),
639 generator = generator,
640 stylesheet = stylesheet,
645 self.stream.write(output.encode('utf8'))
648 def _generate_stylesheet(self):
649 return self.STYLESHEET_TMPL
652 def _generate_heading(self, report_attrs):
654 for name, value in report_attrs:
655 line = self.HEADING_ATTRIBUTE_TMPL % dict(
656 name = saxutils.escape(name),
657 value = saxutils.escape(value),
660 heading = self.HEADING_TMPL % dict(
661 title = saxutils.escape(self.title),
662 parameters = ''.join(a_lines),
663 description = saxutils.escape(self.description),
668 def _generate_report(self, result):
670 sortedResult = self.sortResult(result.result)
671 for cid, (cls, cls_results) in enumerate(sortedResult):
672 # subtotal for a class
674 for n,t,o,e in cls_results:
679 row = self.REPORT_CLASS_TMPL % dict(
680 style = ne > 0 and 'errorClass' or nf > 0 and 'failClass' or 'passClass',
681 name = "%s.%s" % (cls.__module__, cls.__name__),
686 cid = 'c%s' % (cid+1),
690 for tid, (n,t,o,e) in enumerate(cls_results):
691 self._generate_report_test(rows, cid, tid, n, t, o, e)
693 report = self.REPORT_TMPL % dict(
694 test_list = ''.join(rows),
695 count = str(result.success_count+result.failure_count+result.error_count),
696 Pass = str(result.success_count),
697 fail = str(result.failure_count),
698 error = str(result.error_count),
703 def _generate_report_test(self, rows, cid, tid, n, t, o, e):
704 # e.g. 'pt1.1', 'ft1.1', etc
705 has_output = bool(o or e)
706 tid = (n == 0 and 'p' or 'f') + 't%s.%s' % (cid+1,tid+1)
707 name = t.id().split('.')[-1]
708 tmpl = has_output and self.REPORT_TEST_WITH_OUTPUT_TMPL or self.REPORT_TEST_NO_OUTPUT_TMPL
711 Class = (n == 0 and 'hiddenRow' or ''),
712 style = n == 2 and 'errorCase' or (n == 1 and 'failCase' or ''),
714 status = self.STATUS[n],
720 # o and e should be byte string because they are collected from stdout and stderr?
721 if isinstance(o,str):
722 # TODO: some problem with 'string_escape': it escape \n and mess up formating
723 # uo = unicode(o.encode('string_escape'))
724 uo = o.decode('latin-1')
727 if isinstance(e,str):
728 # TODO: some problem with 'string_escape': it escape \n and mess up formating
729 # ue = unicode(e.encode('string_escape'))
730 ue = e.decode('latin-1')
734 row = self.REPORT_TEST_OUTPUT_TMPL % dict(
736 output = jsEscapeString(uo+ue),
741 def _generate_ending(self):
742 return self.ENDING_TMPL
745 ##############################################################################
746 # Facilities for running tests from the command line
747 ##############################################################################
749 # Note: Reuse unittest.TestProgram to launch test. In the future we may
750 # build our own launcher to support more specific command line
751 # parameters like test title, CSS, etc.
752 class TestProgram(unittest.TestProgram):
754 A variation of the unittest.TestProgram. Please refer to the base
755 class for command line parameters.
758 # Pick HTMLTestRunner as the default test runner.
759 # base class's testRunner parameter is not useful because it means
760 # we have to instantiate HTMLTestRunner before we know self.verbosity.
761 if self.testRunner is None:
762 self.testRunner = HTMLTestRunner(verbosity=self.verbosity)
763 unittest.TestProgram.runTests(self)
767 ##############################################################################
768 # Executing this module from the command line
769 ##############################################################################
771 if __name__ == "__main__":