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?
93
from datetime import datetime, timedelta
97
from xml.sax import saxutils
100
# ------------------------------------------------------------------------
101
# The redirectors below are used to capture output during testing. Output
102
# sent to sys.stdout and sys.stderr are automatically captured. However
103
# in some cases sys.stdout is already cached before HTMLTestRunner is
104
# invoked (e.g. calling logging.basicConfig). In order to capture those
105
# output, use the redirectors for the cached stream.
108
# >>> logging.basicConfig(stream=HTMLTestRunner.stdout_redirector)
111
class OutputRedirector(object):
112
""" Wrapper to redirect stdout or stderr """
113
def __init__(self, fp):
119
def writelines(self, lines):
120
self.fp.writelines(lines)
125
stdout_redirector = OutputRedirector(sys.stdout)
126
stderr_redirector = OutputRedirector(sys.stderr)
130
# ----------------------------------------------------------------------
133
class Template_mixin(object):
135
Define a HTML template for report customerization and generation.
137
Overall structure of an HTML report
140
+------------------------+
145
| +----------------+ |
147
| +----------------+ |
154
| +----------------+ |
156
| +----------------+ |
159
| +----------------+ |
161
| +----------------+ |
164
| +----------------+ |
166
| +----------------+ |
170
+------------------------+
179
DEFAULT_TITLE = 'Unit Test Report'
180
DEFAULT_DESCRIPTION = ''
182
# ------------------------------------------------------------------------
185
HTML_TMPL = r"""<?xml version="1.0" encoding="UTF-8"?>
186
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
187
<html xmlns="http://www.w3.org/1999/xhtml">
189
<title>%(title)s</title>
190
<meta name="generator" content="%(generator)s"/>
191
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
195
<script language="javascript" type="text/javascript"><!--
196
output_list = Array();
198
/* level - 0:Summary; 1:Failed; 2:All */
199
function showCase(level) {
200
trs = document.getElementsByTagName("tr");
201
for (var i = 0; i < trs.length; i++) {
204
if (id.substr(0,2) == 'ft') {
206
tr.className = 'hiddenRow';
212
if (id.substr(0,2) == 'pt') {
217
tr.className = 'hiddenRow';
224
function showClassDetail(cid, count) {
225
var id_list = Array(count);
227
for (var i = 0; i < count; i++) {
228
tid0 = 't' + cid.substr(1) + '.' + (i+1);
230
tr = document.getElementById(tid);
233
tr = document.getElementById(tid);
240
for (var i = 0; i < count; i++) {
243
document.getElementById('div_'+tid).style.display = 'none'
244
document.getElementById(tid).className = 'hiddenRow';
247
document.getElementById(tid).className = '';
253
function showTestDetail(div_id){
254
var details_div = document.getElementById(div_id)
255
var displayState = details_div.style.display
256
// alert(displayState)
257
if (displayState != 'block' ) {
258
displayState = 'block'
259
details_div.style.display = 'block'
262
details_div.style.display = 'none'
267
function html_escape(s) {
268
s = s.replace(/&/g,'&');
269
s = s.replace(/</g,'<');
270
s = s.replace(/>/g,'>');
274
/* obsoleted by detail in <div>
275
function showOutput(id, name) {
276
var w = window.open("", //url
278
"resizable,scrollbars,status,width=800,height=450");
281
d.write(html_escape(output_list[id]));
283
d.write("<a href='javascript:window.close()'>close</a>\n");
297
# variables: (title, generator, stylesheet, heading, report, ending)
300
# ------------------------------------------------------------------------
303
# alternatively use a <link> for external style sheet, e.g.
304
# <link rel="stylesheet" href="$url" type="text/css">
306
STYLESHEET_TMPL = """
307
<style type="text/css" media="screen">
308
body { font-family: verdana, arial, helvetica, sans-serif; font-size: 80%; }
309
table { font-size: 100%; }
312
/* -- heading ---------------------------------------------------------------------- */
322
.heading .attribute {
327
.heading .description {
332
/* -- css div popup ------------------------------------------------------------------------ */
345
/*border: solid #627173 1px; */
347
background-color: #E6E6D6;
348
font-family: "Lucida Console", "Courier New", Courier, monospace;
355
/* -- report ------------------------------------------------------------------------ */
362
border-collapse: collapse;
363
border: 1px solid #777;
368
background-color: #777;
371
border: 1px solid #777;
374
#total_row { font-weight: bold; }
375
.passClass { background-color: #6c6; }
376
.failClass { background-color: #c60; }
377
.errorClass { background-color: #c00; }
378
.passCase { color: #6c6; }
379
.failCase { color: #c60; font-weight: bold; }
380
.errorCase { color: #c00; font-weight: bold; }
381
.hiddenRow { display: none; }
382
.testcase { margin-left: 2em; }
385
/* -- ending ---------------------------------------------------------------------- */
394
# ------------------------------------------------------------------------
398
HEADING_TMPL = """<div class='heading'>
401
<p class='description'>%(description)s</p>
404
""" # variables: (title, parameters, description)
406
HEADING_ATTRIBUTE_TMPL = """<p class='attribute'><strong>%(name)s:</strong> %(value)s</p>
407
""" # variables: (name, value)
411
# ------------------------------------------------------------------------
416
<p id='show_detail_line'>Show
417
<a href='javascript:showCase(0)'>Summary</a>
418
<a href='javascript:showCase(1)'>Failed</a>
419
<a href='javascript:showCase(2)'>All</a>
421
<table id='result_table'>
424
<col align='right' />
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>
443
<td>%(duration)s</td>
451
""" # variables: (test_list, count, Pass, fail, error)
453
REPORT_CLASS_TMPL = r"""
454
<tr class='%(style)s'>
456
<td>%(duration)s</td>
461
<td><a href="javascript:showClassDetail('%(cid)s',%(count)s)">Detail</a></td>
463
""" # variables: (style, desc, count, Pass, fail, error, cid)
466
REPORT_TEST_WITH_OUTPUT_TMPL = r"""
467
<tr id='%(tid)s' class='%(Class)s'>
468
<td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
469
<td class='%(style)s'><div class='testcase'>%(duration)s</div></td>
470
<td colspan='5' align='center'>
472
<!--css div popup start-->
473
<a class="popup_link" onfocus='this.blur();' href="javascript:showTestDetail('div_%(tid)s')" >
476
<div id='div_%(tid)s' class="popup_window">
477
<div style='text-align: right; color:red;cursor:pointer'>
478
<a onfocus='this.blur();' onclick="document.getElementById('div_%(tid)s').style.display = 'none' " >
485
<!--css div popup end-->
489
""" # variables: (tid, Class, style, desc, status)
492
REPORT_TEST_NO_OUTPUT_TMPL = r"""
493
<tr id='%(tid)s' class='%(Class)s'>
494
<td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
495
<td class='%(style)s'><div class='testcase'>%(duration)s</div></td>
496
<td colspan='5' align='center'>%(status)s</td>
498
""" # variables: (tid, Class, style, desc, status)
501
REPORT_TEST_OUTPUT_TMPL = r"""
503
""" # variables: (id, output)
507
# ------------------------------------------------------------------------
511
ENDING_TMPL = """<div id='ending'> </div>"""
513
# -------------------- The end of the Template class -------------------
516
TestResult = unittest.TestResult
518
class _TestResult(TestResult):
519
# note: _TestResult is a pure representation of results.
520
# It lacks the output and reporting ability compares to unittest._TextTestResult.
522
def __init__(self, verbosity=1):
523
TestResult.__init__(self)
526
self.success_count = 0
527
self.failure_count = 0
530
self.verbosity = verbosity
532
# result is a list of result in 4 tuple
534
# result code (0: success; 1: fail; 2: error),
536
# Test output (byte string),
541
def startTest(self, test):
542
self.start_time = datetime.now()
543
TestResult.startTest(self, test)
544
# just one buffer for both stdout and stderr
545
self.outputBuffer = StringIO.StringIO()
546
stdout_redirector.fp = self.outputBuffer
547
stderr_redirector.fp = self.outputBuffer
548
self.stdout0 = sys.stdout
549
self.stderr0 = sys.stderr
550
sys.stdout = stdout_redirector
551
sys.stderr = stderr_redirector
554
def complete_output(self):
556
Disconnect output redirection and return buffer.
557
Safe to call multiple times.
560
sys.stdout = self.stdout0
561
sys.stderr = self.stderr0
564
return self.outputBuffer.getvalue()
567
def stopTest(self, test):
568
# Usually one of addSuccess, addError or addFailure would have been called.
569
# But there are some path in unittest that would bypass this.
570
# We must disconnect stdout in stopTest(), which is guaranteed to be called.
571
self.complete_output()
574
def addSuccess(self, test):
575
self.success_count += 1
576
TestResult.addSuccess(self, test)
577
output = self.complete_output()
578
current_test_duration = datetime.now() - self.start_time
579
self.result.append((0, test, current_test_duration, output, ''))
580
if self.verbosity > 1:
581
sys.stderr.write('ok ')
582
sys.stderr.write(str(test))
583
sys.stderr.write('\n')
585
sys.stderr.write('.')
587
def addError(self, test, err):
588
self.error_count += 1
589
TestResult.addError(self, test, err)
590
_, _exc_str = self.errors[-1]
591
output = self.complete_output()
592
current_test_duration = datetime.now() - self.start_time
593
self.result.append((2, test, current_test_duration, output, _exc_str))
594
if self.verbosity > 1:
595
sys.stderr.write('E ')
596
sys.stderr.write(str(test))
597
sys.stderr.write('\n')
599
sys.stderr.write('E')
601
def addFailure(self, test, err):
602
self.failure_count += 1
603
TestResult.addFailure(self, test, err)
604
_, _exc_str = self.failures[-1]
605
output = self.complete_output()
606
current_test_duration = datetime.now() - self.start_time
607
self.result.append((1, test, current_test_duration, output, _exc_str))
608
if self.verbosity > 1:
609
sys.stderr.write('F ')
610
sys.stderr.write(str(test))
611
sys.stderr.write('\n')
613
sys.stderr.write('F')
616
class HTMLTestRunner(Template_mixin):
619
def __init__(self, stream=sys.stdout, verbosity=1, title=None, description=None):
621
self.verbosity = verbosity
623
self.title = self.DEFAULT_TITLE
626
if description is None:
627
self.description = self.DEFAULT_DESCRIPTION
629
self.description = description
633
"Run the given test case or test suite."
634
start_time = datetime.now()
635
result = _TestResult(self.verbosity)
637
self.total_duration = str(datetime.now() - start_time)
638
self.generateReport(test, result)
639
print >>sys.stderr, '\nTime Elapsed: %s' % (self.total_duration)
643
def sortResult(self, result_list):
644
# unittest does not seems to run in any particular order.
645
# Here at least we want to group them together by class.
648
for n,t,d,o,e in result_list:
650
if not rmap.has_key(cls):
653
rmap[cls].append((n,t,d,o,e))
654
r = [(cls, rmap[cls]) for cls in classes]
658
def getReportAttributes(self, result):
660
Return report attributes as a list of (name, value).
661
Override this to add custom attributes.
664
if result.success_count: status.append('Pass %s' % result.success_count)
665
if result.failure_count: status.append('Failure %s' % result.failure_count)
666
if result.error_count: status.append('Error %s' % result.error_count )
668
status = ' '.join(status)
672
('Start Time', str(result.start_time)),
673
('Duration', str(self.total_duration)),
678
def generateReport(self, test, result):
679
report_attrs = self.getReportAttributes(result)
680
generator = 'HTMLTestRunner %s' % __version__
681
stylesheet = self._generate_stylesheet()
682
heading = self._generate_heading(report_attrs)
683
report = self._generate_report(result)
684
ending = self._generate_ending()
685
output = self.HTML_TMPL % dict(
686
title = saxutils.escape(self.title),
687
generator = generator,
688
stylesheet = stylesheet,
693
self.stream.write(output.encode('utf8'))
696
def _generate_stylesheet(self):
697
return self.STYLESHEET_TMPL
700
def _generate_heading(self, report_attrs):
702
for name, value in report_attrs:
703
line = self.HEADING_ATTRIBUTE_TMPL % dict(
704
name = saxutils.escape(name),
705
value = saxutils.escape(value),
708
heading = self.HEADING_TMPL % dict(
709
title = saxutils.escape(self.title),
710
parameters = ''.join(a_lines),
711
description = saxutils.escape(self.description),
716
def _generate_report(self, result):
718
sortedResult = self.sortResult(result.result)
719
for cid, (cls, cls_results) in enumerate(sortedResult):
720
test_class_duration = timedelta(0)
721
# subtotal for a class
723
for n,t,d,o,e in cls_results:
727
test_class_duration += d
729
# format class description
730
if cls.__module__ == "__main__":
733
name = "%s.%s" % (cls.__module__, cls.__name__)
734
doc = cls.__doc__ and cls.__doc__.split("\n")[0] or ""
735
desc = doc and '%s: %s' % (name, doc) or name
737
row = self.REPORT_CLASS_TMPL % dict(
738
style = ne > 0 and 'errorClass' or nf > 0 and 'failClass' or 'passClass',
740
duration = test_class_duration,
745
cid = 'c%s' % (cid+1),
749
for tid, (n,t,d,o,e) in enumerate(cls_results):
750
self._generate_report_test(rows, cid, tid, n, t,d, o, e)
752
report = self.REPORT_TMPL % dict(
753
test_list = ''.join(rows),
754
duration = self.total_duration,
755
count = str(result.success_count+result.failure_count+result.error_count),
756
Pass = str(result.success_count),
757
fail = str(result.failure_count),
758
error = str(result.error_count),
763
def _generate_report_test(self, rows, cid, tid, n, t,d, o, e):
764
# e.g. 'pt1.1', 'ft1.1', etc
765
has_output = bool(o or e)
766
tid = (n == 0 and 'p' or 'f') + 't%s.%s' % (cid+1,tid+1)
767
name = t.id().split('.')[-1]
768
doc = t.shortDescription() or ""
769
desc = doc and ('%s: %s' % (name, doc)) or name
770
tmpl = has_output and self.REPORT_TEST_WITH_OUTPUT_TMPL or self.REPORT_TEST_NO_OUTPUT_TMPL
772
# o and e should be byte string because they are collected from stdout and stderr?
773
if isinstance(o,str):
774
# TODO: some problem with 'string_escape': it escape \n and mess up formating
775
# uo = unicode(o.encode('string_escape'))
776
uo = o.decode('latin-1')
779
if isinstance(e,str):
780
# TODO: some problem with 'string_escape': it escape \n and mess up formating
781
# ue = unicode(e.encode('string_escape'))
782
ue = e.decode('latin-1')
786
script = self.REPORT_TEST_OUTPUT_TMPL % dict(
788
output = saxutils.escape(uo+ue),
793
Class = (n == 0 and 'hiddenRow' or 'none'),
794
style = n == 2 and 'errorCase' or (n == 1 and 'failCase' or 'none'),
798
status = self.STATUS[n],
804
def _generate_ending(self):
805
return self.ENDING_TMPL
808
##############################################################################
809
# Facilities for running tests from the command line
810
##############################################################################
812
# Note: Reuse unittest.TestProgram to launch test. In the future we may
813
# build our own launcher to support more specific command line
814
# parameters like test title, CSS, etc.
815
class TestProgram(unittest.TestProgram):
817
A variation of the unittest.TestProgram. Please refer to the base
818
class for command line parameters.
821
# Pick HTMLTestRunner as the default test runner.
822
# base class's testRunner parameter is not useful because it means
823
# we have to instantiate HTMLTestRunner before we know self.verbosity.
824
if self.testRunner is None:
825
self.testRunner = HTMLTestRunner(verbosity=self.verbosity)
826
unittest.TestProgram.runTests(self)
830
##############################################################################
831
# Executing this module from the command line
832
##############################################################################
834
if __name__ == "__main__":