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)
113 class OutputRedirector(object):
114 """Wrapper to redirect stdout or stderr"""
116 def __init__(self, fp):
122 def writelines(self, lines):
123 self.fp.writelines(lines)
129 stdout_redirector = OutputRedirector(sys.stdout)
130 stderr_redirector = OutputRedirector(sys.stderr)
133 # ----------------------------------------------------------------------
137 class Template_mixin(object):
139 Define a HTML template for report customerization and generation.
141 Overall structure of an HTML report
144 +------------------------+
149 | +----------------+ |
151 | +----------------+ |
158 | +----------------+ |
160 | +----------------+ |
163 | +----------------+ |
165 | +----------------+ |
168 | +----------------+ |
170 | +----------------+ |
174 +------------------------+
183 DEFAULT_TITLE = "Unit Test Report"
184 DEFAULT_DESCRIPTION = ""
186 # ------------------------------------------------------------------------
189 HTML_TMPL = r"""<?xml version="1.0" encoding="UTF-8"?>
190 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
191 <html xmlns="http://www.w3.org/1999/xhtml">
193 <title>%(title)s</title>
194 <meta name="generator" content="%(generator)s"/>
195 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
199 <script language="javascript" type="text/javascript"><!--
200 output_list = Array();
202 /* level - 0:Summary; 1:Failed; 2:All */
203 function showCase(level) {
204 trs = document.getElementsByTagName("tr");
205 for (var i = 0; i < trs.length; i++) {
208 if (id.substr(0,2) == 'ft') {
210 tr.className = 'hiddenRow';
216 if (id.substr(0,2) == 'pt') {
221 tr.className = 'hiddenRow';
228 function showClassDetail(cid, count) {
229 var id_list = Array(count);
231 for (var i = 0; i < count; i++) {
232 tid0 = 't' + cid.substr(1) + '.' + (i+1);
234 tr = document.getElementById(tid);
237 tr = document.getElementById(tid);
244 for (var i = 0; i < count; i++) {
247 document.getElementById('div_'+tid).style.display = 'none'
248 document.getElementById(tid).className = 'hiddenRow';
251 document.getElementById(tid).className = '';
257 function showTestDetail(div_id){
258 var details_div = document.getElementById(div_id)
259 var displayState = details_div.style.display
260 // alert(displayState)
261 if (displayState != 'block' ) {
262 displayState = 'block'
263 details_div.style.display = 'block'
266 details_div.style.display = 'none'
271 function html_escape(s) {
272 s = s.replace(/&/g,'&');
273 s = s.replace(/</g,'<');
274 s = s.replace(/>/g,'>');
278 /* obsoleted by detail in <div>
279 function showOutput(id, name) {
280 var w = window.open("", //url
282 "resizable,scrollbars,status,width=800,height=450");
285 d.write(html_escape(output_list[id]));
287 d.write("<a href='javascript:window.close()'>close</a>\n");
301 # variables: (title, generator, stylesheet, heading, report, ending)
303 # ------------------------------------------------------------------------
306 # alternatively use a <link> for external style sheet, e.g.
307 # <link rel="stylesheet" href="$url" type="text/css">
309 STYLESHEET_TMPL = """
310 <style type="text/css" media="screen">
311 body { font-family: verdana, arial, helvetica, sans-serif; font-size: 80%; }
312 table { font-size: 100%; }
315 /* -- heading ---------------------------------------------------------------------- */
325 .heading .attribute {
330 .heading .description {
335 /* -- css div popup ------------------------------------------------------------------------ */
348 /*border: solid #627173 1px; */
350 background-color: #E6E6D6;
351 font-family: "Lucida Console", "Courier New", Courier, monospace;
358 /* -- report ------------------------------------------------------------------------ */
365 border-collapse: collapse;
366 border: 1px solid #777;
371 background-color: #777;
374 border: 1px solid #777;
377 #total_row { font-weight: bold; }
378 .passClass { background-color: #6c6; }
379 .failClass { background-color: #c60; }
380 .errorClass { background-color: #c00; }
381 .passCase { color: #6c6; }
382 .failCase { color: #c60; font-weight: bold; }
383 .errorCase { color: #c00; font-weight: bold; }
384 .hiddenRow { display: none; }
385 .testcase { margin-left: 2em; }
388 /* -- 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)
410 # ------------------------------------------------------------------------
415 <p id='show_detail_line'>Show
416 <a href='javascript:showCase(0)'>Summary</a>
417 <a href='javascript:showCase(1)'>Failed</a>
418 <a href='javascript:showCase(2)'>All</a>
420 <table id='result_table'>
423 <col align='right' />
424 <col align='right' />
425 <col align='right' />
426 <col align='right' />
427 <col align='right' />
430 <td>Test Group/Test case</td>
447 """ # variables: (test_list, count, Pass, fail, error)
449 REPORT_CLASS_TMPL = r"""
450 <tr class='%(style)s'>
456 <td><a href="javascript:showClassDetail('%(cid)s',%(count)s)">Detail</a></td>
458 """ # variables: (style, desc, count, Pass, fail, error, cid)
460 REPORT_TEST_WITH_OUTPUT_TMPL = r"""
461 <tr id='%(tid)s' class='%(Class)s'>
462 <td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
463 <td colspan='5' align='center'>
465 <!--css div popup start-->
466 <a class="popup_link" onfocus='this.blur();' href="javascript:showTestDetail('div_%(tid)s')" >
469 <div id='div_%(tid)s' class="popup_window">
470 <div style='text-align: right; color:red;cursor:pointer'>
471 <a onfocus='this.blur();' onclick="document.getElementById('div_%(tid)s').style.display = 'none' " >
478 <!--css div popup end-->
482 """ # variables: (tid, Class, style, desc, status)
484 REPORT_TEST_NO_OUTPUT_TMPL = r"""
485 <tr id='%(tid)s' class='%(Class)s'>
486 <td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
487 <td colspan='5' align='center'>%(status)s</td>
489 """ # variables: (tid, Class, style, desc, status)
491 REPORT_TEST_OUTPUT_TMPL = r"""
493 """ # variables: (id, output)
495 # ------------------------------------------------------------------------
499 ENDING_TMPL = """<div id='ending'> </div>"""
502 # -------------------- The end of the Template class -------------------
505 TestResult = unittest.TestResult
508 class _TestResult(TestResult):
509 # note: _TestResult is a pure representation of results.
510 # It lacks the output and reporting ability compares to unittest._TextTestResult.
512 def __init__(self, verbosity=1):
513 TestResult.__init__(self)
516 self.success_count = 0
517 self.failure_count = 0
519 self.verbosity = verbosity
521 # result is a list of result in 4 tuple
523 # result code (0: success; 1: fail; 2: error),
525 # Test output (byte string),
530 def startTest(self, test):
531 TestResult.startTest(self, test)
532 # just one buffer for both stdout and stderr
533 self.outputBuffer = StringIO.StringIO()
534 stdout_redirector.fp = self.outputBuffer
535 stderr_redirector.fp = self.outputBuffer
536 self.stdout0 = sys.stdout
537 self.stderr0 = sys.stderr
538 sys.stdout = stdout_redirector
539 sys.stderr = stderr_redirector
541 def complete_output(self):
543 Disconnect output redirection and return buffer.
544 Safe to call multiple times.
547 sys.stdout = self.stdout0
548 sys.stderr = self.stderr0
551 return self.outputBuffer.getvalue()
553 def stopTest(self, test):
554 # Usually one of addSuccess, addError or addFailure would have been called.
555 # But there are some path in unittest that would bypass this.
556 # We must disconnect stdout in stopTest(), which is guaranteed to be called.
557 self.complete_output()
559 def addSuccess(self, test):
560 self.success_count += 1
561 TestResult.addSuccess(self, test)
562 output = self.complete_output()
563 self.result.append((0, test, output, ""))
564 if self.verbosity > 1:
565 sys.stderr.write("ok ")
566 sys.stderr.write(str(test))
567 sys.stderr.write("\n")
569 sys.stderr.write(".")
571 def addError(self, test, err):
572 self.error_count += 1
573 TestResult.addError(self, test, err)
574 _, _exc_str = self.errors[-1]
575 output = self.complete_output()
576 self.result.append((2, test, output, _exc_str))
577 if self.verbosity > 1:
578 sys.stderr.write("E ")
579 sys.stderr.write(str(test))
580 sys.stderr.write("\n")
582 sys.stderr.write("E")
584 def addFailure(self, test, err):
585 self.failure_count += 1
586 TestResult.addFailure(self, test, err)
587 _, _exc_str = self.failures[-1]
588 output = self.complete_output()
589 self.result.append((1, test, output, _exc_str))
590 if self.verbosity > 1:
591 sys.stderr.write("F ")
592 sys.stderr.write(str(test))
593 sys.stderr.write("\n")
595 sys.stderr.write("F")
598 class HTMLTestRunner(Template_mixin):
601 def __init__(self, stream=sys.stdout, verbosity=1, title=None, description=None):
603 self.verbosity = verbosity
605 self.title = self.DEFAULT_TITLE
608 if description is None:
609 self.description = self.DEFAULT_DESCRIPTION
611 self.description = description
613 self.startTime = datetime.datetime.now()
616 "Run the given test case or test suite."
617 result = _TestResult(self.verbosity)
619 self.stopTime = datetime.datetime.now()
620 self.generateReport(test, result)
621 print >>sys.stderr, "\nTime Elapsed: %s" % (self.stopTime - self.startTime)
624 def sortResult(self, result_list):
625 # unittest does not seems to run in any particular order.
626 # Here at least we want to group them together by class.
629 for n, t, o, e in result_list:
634 rmap[cls].append((n, t, o, e))
635 r = [(cls, rmap[cls]) for cls in classes]
638 def getReportAttributes(self, result):
640 Return report attributes as a list of (name, value).
641 Override this to add custom attributes.
643 startTime = str(self.startTime)[:19]
644 duration = str(self.stopTime - self.startTime)
646 if result.success_count:
647 status.append("Pass %s" % result.success_count)
648 if result.failure_count:
649 status.append("Failure %s" % result.failure_count)
650 if result.error_count:
651 status.append("Error %s" % result.error_count)
653 status = " ".join(status)
657 ("Start Time", startTime),
658 ("Duration", duration),
662 def generateReport(self, test, result):
663 report_attrs = self.getReportAttributes(result)
664 generator = "HTMLTestRunner %s" % __version__
665 stylesheet = self._generate_stylesheet()
666 heading = self._generate_heading(report_attrs)
667 report = self._generate_report(result)
668 ending = self._generate_ending()
669 output = self.HTML_TMPL % dict(
670 title=saxutils.escape(self.title),
672 stylesheet=stylesheet,
677 self.stream.write(output.encode("utf8"))
679 def _generate_stylesheet(self):
680 return self.STYLESHEET_TMPL
682 def _generate_heading(self, report_attrs):
684 for name, value in report_attrs:
685 line = self.HEADING_ATTRIBUTE_TMPL % dict(
686 name=saxutils.escape(name),
687 value=saxutils.escape(value),
690 heading = self.HEADING_TMPL % dict(
691 title=saxutils.escape(self.title),
692 parameters="".join(a_lines),
693 description=saxutils.escape(self.description),
697 def _generate_report(self, result):
699 sortedResult = self.sortResult(result.result)
700 for cid, (cls, cls_results) in enumerate(sortedResult):
701 # subtotal for a class
703 for n, t, o, e in cls_results:
711 # format class description
712 if cls.__module__ == "__main__":
715 name = "%s.%s" % (cls.__module__, cls.__name__)
716 doc = cls.__doc__ and cls.__doc__.split("\n")[0] or ""
717 desc = doc and "%s: %s" % (name, doc) or name
719 row = self.REPORT_CLASS_TMPL % dict(
720 style=ne > 0 and "errorClass" or nf > 0 and "failClass" or "passClass",
726 cid="c%s" % (cid + 1),
730 for tid, (n, t, o, e) in enumerate(cls_results):
731 self._generate_report_test(rows, cid, tid, n, t, o, e)
733 report = self.REPORT_TMPL % dict(
734 test_list="".join(rows),
735 count=str(result.success_count + result.failure_count + result.error_count),
736 Pass=str(result.success_count),
737 fail=str(result.failure_count),
738 error=str(result.error_count),
742 def _generate_report_test(self, rows, cid, tid, n, t, o, e):
743 # e.g. 'pt1.1', 'ft1.1', etc
744 has_output = bool(o or e)
745 tid = (n == 0 and "p" or "f") + "t%s.%s" % (cid + 1, tid + 1)
746 name = t.id().split(".")[-1]
747 doc = t.shortDescription() or ""
748 desc = doc and ("%s: %s" % (name, doc)) or name
751 and self.REPORT_TEST_WITH_OUTPUT_TMPL
752 or self.REPORT_TEST_NO_OUTPUT_TMPL
755 # o and e should be byte string because they are collected from stdout and stderr?
756 if isinstance(o, str):
757 # TODO: some problem with 'string_escape': it escape \n and mess up formating
758 # uo = unicode(o.encode('string_escape'))
759 uo = o.decode("latin-1")
762 if isinstance(e, str):
763 # TODO: some problem with 'string_escape': it escape \n and mess up formating
764 # ue = unicode(e.encode('string_escape'))
765 ue = e.decode("latin-1")
769 script = self.REPORT_TEST_OUTPUT_TMPL % dict(
771 output=saxutils.escape(uo + ue),
776 Class=(n == 0 and "hiddenRow" or "none"),
777 style=n == 2 and "errorCase" or (n == 1 and "failCase" or "none"),
780 status=self.STATUS[n],
786 def _generate_ending(self):
787 return self.ENDING_TMPL
790 ##############################################################################
791 # Facilities for running tests from the command line
792 ##############################################################################
794 # Note: Reuse unittest.TestProgram to launch test. In the future we may
795 # build our own launcher to support more specific command line
796 # parameters like test title, CSS, etc.
797 class TestProgram(unittest.TestProgram):
799 A variation of the unittest.TestProgram. Please refer to the base
800 class for command line parameters.
804 # Pick HTMLTestRunner as the default test runner.
805 # base class's testRunner parameter is not useful because it means
806 # we have to instantiate HTMLTestRunner before we know self.verbosity.
807 if self.testRunner is None:
808 self.testRunner = HTMLTestRunner(verbosity=self.verbosity)
809 unittest.TestProgram.runTests(self)
814 ##############################################################################
815 # Executing this module from the command line
816 ##############################################################################
818 if __name__ == "__main__":