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 For more customization options, instantiates a HTMLTestRunner object.
17 HTMLTestRunner is a counterpart to unittest's TextTestRunner. E.g.
20 fp = file('my_report.html', 'wb')
21 runner = HTMLTestRunner.HTMLTestRunner(
24 description='This demonstrates the report output by HTMLTestRunner.'
27 # Use an external stylesheet.
28 # See the Template_mixin class for more customizable options
29 runner.STYLESHEET_TMPL = '<link rel="stylesheet" href="my_stylesheet.css" type="text/css">'
32 runner.run(my_test_suite)
35 ------------------------------------------------------------------------
36 Copyright (c) 2004-2007, Wai Yip Tung
39 Redistribution and use in source and binary forms, with or without
40 modification, are permitted provided that the following conditions are
43 * Redistributions of source code must retain the above copyright notice,
44 this list of conditions and the following disclaimer.
45 * Redistributions in binary form must reproduce the above copyright
46 notice, this list of conditions and the following disclaimer in the
47 documentation and/or other materials provided with the distribution.
48 * Neither the name Wai Yip Tung nor the names of its contributors may be
49 used to endorse or promote products derived from this software without
50 specific prior written permission.
52 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
53 IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
54 TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
55 PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
56 OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
57 EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
58 PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
59 PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
60 LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
61 NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
62 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
65 # URL: http://tungwaiyip.info/software/HTMLTestRunner.html
67 __author__ = "Wai Yip Tung"
75 * Show output inline instead of popup window (Viorel Lupu).
78 * Validated XHTML (Wolfgang Borgert).
79 * Added description of test classes and test cases.
82 * Define Template_mixin class for customization.
83 * Workaround a IE 6 bug that it does not treat <script> block as CDATA.
86 * Back port to Python 2.3 (Frank Horowitz).
87 * Fix missing scroll bars in detail log (Podi).
91 # TODO: simplify javascript using ,ore than 1 class in the class attribute?
98 from xml.sax import saxutils
101 # ------------------------------------------------------------------------
102 # The redirectors below are used to capture output during testing. Output
103 # sent to sys.stdout and sys.stderr are automatically captured. However
104 # in some cases sys.stdout is already cached before HTMLTestRunner is
105 # invoked (e.g. calling logging.basicConfig). In order to capture those
106 # output, use the redirectors for the cached stream.
109 # >>> logging.basicConfig(stream=HTMLTestRunner.stdout_redirector)
112 class OutputRedirector(object):
113 """ Wrapper to redirect stdout or stderr """
114 def __init__(self, fp):
120 def writelines(self, lines):
121 self.fp.writelines(lines)
126 stdout_redirector = OutputRedirector(sys.stdout)
127 stderr_redirector = OutputRedirector(sys.stderr)
131 # ----------------------------------------------------------------------
134 class Template_mixin(object):
136 Define a HTML template for report customerization and generation.
138 Overall structure of an HTML report
141 +------------------------+
146 | +----------------+ |
148 | +----------------+ |
155 | +----------------+ |
157 | +----------------+ |
160 | +----------------+ |
162 | +----------------+ |
165 | +----------------+ |
167 | +----------------+ |
171 +------------------------+
180 DEFAULT_TITLE = 'Unit Test Report'
181 DEFAULT_DESCRIPTION = ''
183 # ------------------------------------------------------------------------
186 HTML_TMPL = r"""<?xml version="1.0" encoding="UTF-8"?>
187 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
188 <html xmlns="http://www.w3.org/1999/xhtml">
190 <title>%(title)s</title>
191 <meta name="generator" content="%(generator)s"/>
192 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
196 <script language="javascript" type="text/javascript"><!--
197 output_list = Array();
199 /* level - 0:Summary; 1:Failed; 2:All */
200 function showCase(level) {
201 trs = document.getElementsByTagName("tr");
202 for (var i = 0; i < trs.length; i++) {
205 if (id.substr(0,2) == 'ft') {
207 tr.className = 'hiddenRow';
213 if (id.substr(0,2) == 'pt') {
218 tr.className = 'hiddenRow';
225 function showClassDetail(cid, count) {
226 var id_list = Array(count);
228 for (var i = 0; i < count; i++) {
229 tid0 = 't' + cid.substr(1) + '.' + (i+1);
231 tr = document.getElementById(tid);
234 tr = document.getElementById(tid);
241 for (var i = 0; i < count; i++) {
244 document.getElementById('div_'+tid).style.display = 'none'
245 document.getElementById(tid).className = 'hiddenRow';
248 document.getElementById(tid).className = '';
254 function showTestDetail(div_id){
255 var details_div = document.getElementById(div_id)
256 var displayState = details_div.style.display
257 // alert(displayState)
258 if (displayState != 'block' ) {
259 displayState = 'block'
260 details_div.style.display = 'block'
263 details_div.style.display = 'none'
268 function html_escape(s) {
269 s = s.replace(/&/g,'&');
270 s = s.replace(/</g,'<');
271 s = s.replace(/>/g,'>');
275 /* obsoleted by detail in <div>
276 function showOutput(id, name) {
277 var w = window.open("", //url
279 "resizable,scrollbars,status,width=800,height=450");
282 d.write(html_escape(output_list[id]));
284 d.write("<a href='javascript:window.close()'>close</a>\n");
298 # variables: (title, generator, stylesheet, heading, report, ending)
301 # ------------------------------------------------------------------------
304 # alternatively use a <link> for external style sheet, e.g.
305 # <link rel="stylesheet" href="$url" type="text/css">
307 STYLESHEET_TMPL = """
308 <style type="text/css" media="screen">
309 body { font-family: verdana, arial, helvetica, sans-serif; font-size: 80%; }
310 table { font-size: 100%; }
313 /* -- heading ---------------------------------------------------------------------- */
323 .heading .attribute {
328 .heading .description {
333 /* -- css div popup ------------------------------------------------------------------------ */
346 /*border: solid #627173 1px; */
348 background-color: #E6E6D6;
349 font-family: "Lucida Console", "Courier New", Courier, monospace;
356 /* -- report ------------------------------------------------------------------------ */
363 border-collapse: collapse;
364 border: 1px solid #777;
369 background-color: #777;
372 border: 1px solid #777;
375 #total_row { font-weight: bold; }
376 .passClass { background-color: #6c6; }
377 .failClass { background-color: #c60; }
378 .errorClass { background-color: #c00; }
379 .passCase { color: #6c6; }
380 .failCase { color: #c60; font-weight: bold; }
381 .errorCase { color: #c00; font-weight: bold; }
382 .hiddenRow { display: none; }
383 .testcase { margin-left: 2em; }
386 /* -- ending ---------------------------------------------------------------------- */
395 # ------------------------------------------------------------------------
399 HEADING_TMPL = """<div class='heading'>
402 <p class='description'>%(description)s</p>
405 """ # variables: (title, parameters, description)
407 HEADING_ATTRIBUTE_TMPL = """<p class='attribute'><strong>%(name)s:</strong> %(value)s</p>
408 """ # variables: (name, value)
412 # ------------------------------------------------------------------------
417 <p id='show_detail_line'>Show
418 <a href='javascript:showCase(0)'>Summary</a>
419 <a href='javascript:showCase(1)'>Failed</a>
420 <a href='javascript:showCase(2)'>All</a>
422 <table id='result_table'>
425 <col align='right' />
426 <col align='right' />
427 <col align='right' />
428 <col align='right' />
429 <col align='right' />
432 <td>Test Group/Test case</td>
449 """ # variables: (test_list, count, Pass, fail, error)
451 REPORT_CLASS_TMPL = r"""
452 <tr class='%(style)s'>
458 <td><a href="javascript:showClassDetail('%(cid)s',%(count)s)">Detail</a></td>
460 """ # variables: (style, desc, count, Pass, fail, error, cid)
463 REPORT_TEST_WITH_OUTPUT_TMPL = r"""
464 <tr id='%(tid)s' class='%(Class)s'>
465 <td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
466 <td colspan='5' align='center'>
468 <!--css div popup start-->
469 <a class="popup_link" onfocus='this.blur();' href="javascript:showTestDetail('div_%(tid)s')" >
472 <div id='div_%(tid)s' class="popup_window">
473 <div style='text-align: right; color:red;cursor:pointer'>
474 <a onfocus='this.blur();' onclick="document.getElementById('div_%(tid)s').style.display = 'none' " >
481 <!--css div popup end-->
485 """ # variables: (tid, Class, style, desc, status)
488 REPORT_TEST_NO_OUTPUT_TMPL = r"""
489 <tr id='%(tid)s' class='%(Class)s'>
490 <td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
491 <td colspan='5' align='center'>%(status)s</td>
493 """ # variables: (tid, Class, style, desc, status)
496 REPORT_TEST_OUTPUT_TMPL = r"""
498 """ # variables: (id, output)
502 # ------------------------------------------------------------------------
506 ENDING_TMPL = """<div id='ending'> </div>"""
508 # -------------------- The end of the Template class -------------------
511 TestResult = unittest.TestResult
513 class _TestResult(TestResult):
514 # note: _TestResult is a pure representation of results.
515 # It lacks the output and reporting ability compares to unittest._TextTestResult.
517 def __init__(self, verbosity=1):
518 TestResult.__init__(self)
521 self.success_count = 0
522 self.failure_count = 0
524 self.verbosity = verbosity
526 # result is a list of result in 4 tuple
528 # result code (0: success; 1: fail; 2: error),
530 # Test output (byte string),
536 def startTest(self, test):
537 TestResult.startTest(self, test)
538 # just one buffer for both stdout and stderr
539 self.outputBuffer = StringIO.StringIO()
540 stdout_redirector.fp = self.outputBuffer
541 stderr_redirector.fp = self.outputBuffer
542 self.stdout0 = sys.stdout
543 self.stderr0 = sys.stderr
544 sys.stdout = stdout_redirector
545 sys.stderr = stderr_redirector
548 def complete_output(self):
550 Disconnect output redirection and return buffer.
551 Safe to call multiple times.
554 sys.stdout = self.stdout0
555 sys.stderr = self.stderr0
558 return self.outputBuffer.getvalue()
561 def stopTest(self, test):
562 # Usually one of addSuccess, addError or addFailure would have been called.
563 # But there are some path in unittest that would bypass this.
564 # We must disconnect stdout in stopTest(), which is guaranteed to be called.
565 self.complete_output()
568 def addSuccess(self, test):
569 self.success_count += 1
570 TestResult.addSuccess(self, test)
571 output = self.complete_output()
572 self.result.append((0, test, output, ''))
573 if self.verbosity > 1:
574 sys.stderr.write('ok ')
575 sys.stderr.write(str(test))
576 sys.stderr.write('\n')
578 sys.stderr.write('.')
580 def addError(self, test, err):
581 self.error_count += 1
582 TestResult.addError(self, test, err)
583 _, _exc_str = self.errors[-1]
584 output = self.complete_output()
585 self.result.append((2, test, output, _exc_str))
586 if self.verbosity > 1:
587 sys.stderr.write('E ')
588 sys.stderr.write(str(test))
589 sys.stderr.write('\n')
591 sys.stderr.write('E')
593 def addFailure(self, test, err):
594 self.failure_count += 1
595 TestResult.addFailure(self, test, err)
596 _, _exc_str = self.failures[-1]
597 output = self.complete_output()
598 self.result.append((1, test, output, _exc_str))
599 if self.verbosity > 1:
600 sys.stderr.write('F ')
601 sys.stderr.write(str(test))
602 sys.stderr.write('\n')
604 sys.stderr.write('F')
607 class HTMLTestRunner(Template_mixin):
610 def __init__(self, stream=sys.stdout, verbosity=1, title=None, description=None):
612 self.verbosity = verbosity
614 self.title = self.DEFAULT_TITLE
617 if description is None:
618 self.description = self.DEFAULT_DESCRIPTION
620 self.description = description
622 self.startTime = datetime.datetime.now()
626 "Run the given test case or test suite."
627 result = _TestResult(self.verbosity)
629 self.stopTime = datetime.datetime.now()
630 self.generateReport(test, result)
631 print >>sys.stderr, '\nTime Elapsed: %s' % (self.stopTime-self.startTime)
635 def sortResult(self, result_list):
636 # unittest does not seems to run in any particular order.
637 # Here at least we want to group them together by class.
640 for n,t,o,e in result_list:
642 if not rmap.has_key(cls):
645 rmap[cls].append((n,t,o,e))
646 r = [(cls, rmap[cls]) for cls in classes]
650 def getReportAttributes(self, result):
652 Return report attributes as a list of (name, value).
653 Override this to add custom attributes.
655 startTime = str(self.startTime)[:19]
656 duration = str(self.stopTime - self.startTime)
658 if result.success_count: status.append('Pass %s' % result.success_count)
659 if result.failure_count: status.append('Failure %s' % result.failure_count)
660 if result.error_count: status.append('Error %s' % result.error_count )
662 status = ' '.join(status)
666 ('Start Time', startTime),
667 ('Duration', duration),
672 def generateReport(self, test, result):
673 report_attrs = self.getReportAttributes(result)
674 generator = 'HTMLTestRunner %s' % __version__
675 stylesheet = self._generate_stylesheet()
676 heading = self._generate_heading(report_attrs)
677 report = self._generate_report(result)
678 ending = self._generate_ending()
679 output = self.HTML_TMPL % dict(
680 title = saxutils.escape(self.title),
681 generator = generator,
682 stylesheet = stylesheet,
687 self.stream.write(output.encode('utf8'))
690 def _generate_stylesheet(self):
691 return self.STYLESHEET_TMPL
694 def _generate_heading(self, report_attrs):
696 for name, value in report_attrs:
697 line = self.HEADING_ATTRIBUTE_TMPL % dict(
698 name = saxutils.escape(name),
699 value = saxutils.escape(value),
702 heading = self.HEADING_TMPL % dict(
703 title = saxutils.escape(self.title),
704 parameters = ''.join(a_lines),
705 description = saxutils.escape(self.description),
710 def _generate_report(self, result):
712 sortedResult = self.sortResult(result.result)
713 for cid, (cls, cls_results) in enumerate(sortedResult):
714 # subtotal for a class
716 for n,t,o,e in cls_results:
721 # format class description
722 if cls.__module__ == "__main__":
725 name = "%s.%s" % (cls.__module__, cls.__name__)
726 doc = cls.__doc__ and cls.__doc__.split("\n")[0] or ""
727 desc = doc and '%s: %s' % (name, doc) or name
729 row = self.REPORT_CLASS_TMPL % dict(
730 style = ne > 0 and 'errorClass' or nf > 0 and 'failClass' or 'passClass',
736 cid = 'c%s' % (cid+1),
740 for tid, (n,t,o,e) in enumerate(cls_results):
741 self._generate_report_test(rows, cid, tid, n, t, o, e)
743 report = self.REPORT_TMPL % dict(
744 test_list = ''.join(rows),
745 count = str(result.success_count+result.failure_count+result.error_count),
746 Pass = str(result.success_count),
747 fail = str(result.failure_count),
748 error = str(result.error_count),
753 def _generate_report_test(self, rows, cid, tid, n, t, o, e):
754 # e.g. 'pt1.1', 'ft1.1', etc
755 has_output = bool(o or e)
756 tid = (n == 0 and 'p' or 'f') + 't%s.%s' % (cid+1,tid+1)
757 name = t.id().split('.')[-1]
758 doc = t.shortDescription() or ""
759 desc = doc and ('%s: %s' % (name, doc)) or name
760 tmpl = has_output and self.REPORT_TEST_WITH_OUTPUT_TMPL or self.REPORT_TEST_NO_OUTPUT_TMPL
762 # o and e should be byte string because they are collected from stdout and stderr?
763 if isinstance(o,str):
764 # TODO: some problem with 'string_escape': it escape \n and mess up formating
765 # uo = unicode(o.encode('string_escape'))
766 uo = o.decode('latin-1')
769 if isinstance(e,str):
770 # TODO: some problem with 'string_escape': it escape \n and mess up formating
771 # ue = unicode(e.encode('string_escape'))
772 ue = e.decode('latin-1')
776 script = self.REPORT_TEST_OUTPUT_TMPL % dict(
778 output = saxutils.escape(uo+ue),
783 Class = (n == 0 and 'hiddenRow' or 'none'),
784 style = n == 2 and 'errorCase' or (n == 1 and 'failCase' or 'none'),
787 status = self.STATUS[n],
793 def _generate_ending(self):
794 return self.ENDING_TMPL
797 ##############################################################################
798 # Facilities for running tests from the command line
799 ##############################################################################
801 # Note: Reuse unittest.TestProgram to launch test. In the future we may
802 # build our own launcher to support more specific command line
803 # parameters like test title, CSS, etc.
804 class TestProgram(unittest.TestProgram):
806 A variation of the unittest.TestProgram. Please refer to the base
807 class for command line parameters.
810 # Pick HTMLTestRunner as the default test runner.
811 # base class's testRunner parameter is not useful because it means
812 # we have to instantiate HTMLTestRunner before we know self.verbosity.
813 if self.testRunner is None:
814 self.testRunner = HTMLTestRunner(verbosity=self.verbosity)
815 unittest.TestProgram.runTests(self)
819 ##############################################################################
820 # Executing this module from the command line
821 ##############################################################################
823 if __name__ == "__main__":