第 7 章 unittest 扩展

第 7 章 unittest 扩展

在第 6 章中,我们介绍了 unittest 的主要功能,但是如果只用它来写 Web 自动化测试,则仍稍显不足。例如,它不能生成 HTML 格式的报告、它不能提供参数化功能等。不过,我们可以借助第三方扩展来弥补这些不足

本教程的练习实践源码请点击下载

7.1 HTML 测试报告

HTMLTestRunner 是 unittest 的一个扩展,它可以生成易于使用的 HTML 测试报告。HTMLTestRunner 是在 BSD 许可证下发布的。
下载地址:http://tungwaiyip.info/software/HTMLTestRunner.html

因为该扩展不支持 Python 3,所以笔者做了一些修改,使它可以在 Python 3.7 下运行。
GitHub 地址:https://github.com/defnngj/HTMLTestRunner

7.1.1 下载与安装

HTMLTestRunner 的使用非常简单,它是一个独立的 HTMLTestRunner.py 文件,既可以把它当作 Python 的第三方库来使用,也可以把它当作项目的一部分来使用。
首先打开上面的 GitHub 地址,下载整个项目。然后把 HTMLTestRunner.py 单独放到 Python 的安装目录下面,如 C:Python37Lib

如果把 HTMLTestRunner 当作项目的一部分来使用,就把它放到项目目录中。笔者推荐这种方式,因为可以方便地定制生成的 HTMLTestRunner 报告。

其中,test_report/用于存放测试报告,稍后将会用到

7.1.2 生成 HTML 测试报告

如果想用 HTMLTestRunner 生成测试报告,那么请查看本书 6.1.4 节 run_tests.py 文件的实现。测试用例的执行是通过 TextTestRunner 类提供的 run()方法完成的。这里需要把 HTMLTestRunner.py 文件中的 HTMLTestRunner 类替换 TextTestRunner 类。
打开 HTMLTestRunner.py 文件,在第 877 行(如果代码更新,则行号会发生变化)可以找到 HTMLTestRunner 类。

使用Sublime打开HTMLTestRunner.py文件,查找关键字“HTMLTestRunner”。

HTMLTestRunner

"""
A TestRunner for use with the Python unit testing framework. It
generates a HTML report to show the result at a glance.

The simplest way to use this is to invoke its main method. E.g.

    import unittest
    import HTMLTestRunner

    ... define your tests ...

    if __name__ == '__main__':
        HTMLTestRunner.main()


For more customization options, instantiates a HTMLTestRunner object.
HTMLTestRunner is a counterpart to unittest's TextTestRunner. E.g.

    # output to a file
    fp = file('my_report.html', 'wb')
    runner = HTMLTestRunner.HTMLTestRunner(
                stream=fp,
                title='My unit test',
                description='This demonstrates the report output by HTMLTestRunner.'
                )

    # Use an external stylesheet.
    # See the Template_mixin class for more customizable options
    runner.STYLESHEET_TMPL = '<link rel="stylesheet" href="my_stylesheet.css" type="text/css">'

    # run the test
    runner.run(my_test_suite)


------------------------------------------------------------------------
Copyright (c) 2004-2007, Wai Yip Tung
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

* Redistributions of source code must retain the above copyright notice,
  this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
  notice, this list of conditions and the following disclaimer in the
  documentation and/or other materials provided with the distribution.
* Neither the name Wai Yip Tung nor the names of its contributors may be
  used to endorse or promote products derived from this software without
  specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""

# URL: http://tungwaiyip.info/software/HTMLTestRunner.html

__author__ = "Wai Yip Tung , bugmaster"
__version__ = "0.8.2"


"""
Change History

Version 0.8.2
* Show output inline instead of popup window (Viorel Lupu).

Version in 0.8.1
* Validated XHTML (Wolfgang Borgert).
* Added description of test classes and test cases.

Version in 0.8.0
* Define Template_mixin class for customization.
* Workaround a IE 6 bug that it does not treat <script> block as CDATA.

Version in 0.7.1
* Back port to Python 2.3 (Frank Horowitz).
* Fix missing scroll bars in detail log (Podi).
"""

# TODO: color stderr
# TODO: simplify javascript using ,ore than 1 class in the class attribute?

import datetime
import io
import sys
import time
import unittest
from xml.sax import saxutils


# ------------------------------------------------------------------------
# The redirectors below are used to capture output during testing. Output
# sent to sys.stdout and sys.stderr are automatically captured. However
# in some cases sys.stdout is already cached before HTMLTestRunner is
# invoked (e.g. calling logging.basicConfig). In order to capture those
# output, use the redirectors for the cached stream.
#
# e.g.
#   >>> logging.basicConfig(stream=HTMLTestRunner.stdout_redirector)
#   >>>

class OutputRedirector(object):
    """ Wrapper to redirect stdout or stderr """
    def __init__(self, fp):
        self.fp = fp

    def write(self, s):
        self.fp.write(s)

    def writelines(self, lines):
        self.fp.writelines(lines)

    def flush(self):
        self.fp.flush()

stdout_redirector = OutputRedirector(sys.stdout)
stderr_redirector = OutputRedirector(sys.stderr)



# ----------------------------------------------------------------------
# Template

class Template_mixin(object):
    """
    Define a HTML template for report customerization and generation.

    Overall structure of an HTML report

    HTML
    +------------------------+
    |<html>                  |
    |  <head>                |
    |                        |
    |   STYLESHEET           |
    |   +----------------+   |
    |   |                |   |
    |   +----------------+   |
    |                        |
    |  </head>               |
    |                        |
    |  <body>                |
    |                        |
    |   HEADING              |
    |   +----------------+   |
    |   |                |   |
    |   +----------------+   |
    |                        |
    |   REPORT               |
    |   +----------------+   |
    |   |                |   |
    |   +----------------+   |
    |                        |
    |   ENDING               |
    |   +----------------+   |
    |   |                |   |
    |   +----------------+   |
    |                        |
    |  </body>               |
    |</html>                 |
    +------------------------+
    """

    STATUS = {
    0: 'pass',
    1: 'fail',
    2: 'error',
    }

    DEFAULT_TITLE = 'Unit Test Report'
    DEFAULT_DESCRIPTION = ''

    # ------------------------------------------------------------------------
    # HTML Template

    HTML_TMPL = r"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>%(title)s</title>
    <meta name="generator" content="%(generator)s"/>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <link rel="stylesheet" href="http://cdn.bootcss.com/bootstrap/3.3.0/css/bootstrap.min.css">
    <script src="http://cdn.bootcss.com/bootstrap/3.3.0/js/bootstrap.min.js"></script>
    <script src="http://apps.bdimg.com/libs/Chart.js/0.2.0/Chart.min.js"></script>
    <!-- <link href="https://cdn.bootcss.com/echarts/3.8.5/echarts.common.min.js" rel="stylesheet">   -->
    
    %(stylesheet)s
</head>
<body>
<script language="javascript" type="text/javascript"><!--
output_list = Array();

/* level - 0:Summary; 1:Failed; 2:All */
function showCase(level) {
    trs = document.getElementsByTagName("tr");
    for (var i = 0; i < trs.length; i++) {
        tr = trs[i];
        id = tr.id;
        if (id.substr(0,2) == 'ft') {
            if (level < 1) {
                tr.className = 'hiddenRow';
            }
            else {
                tr.className = '';
            }
        }
        if (id.substr(0,2) == 'pt') {
            if (level > 1) {
                tr.className = '';
            }
            else {
                tr.className = 'hiddenRow';
            }
        }
    }
}


function showClassDetail(cid, count) {
    var id_list = Array(count);
    var toHide = 1;
    for (var i = 0; i < count; i++) {
        tid0 = 't' + cid.substr(1) + '.' + (i+1);
        tid = 'f' + tid0;
        tr = document.getElementById(tid);
        if (!tr) {
            tid = 'p' + tid0;
            tr = document.getElementById(tid);
        }
        id_list[i] = tid;
        if (tr.className) {
            toHide = 0;
        }
    }
    for (var i = 0; i < count; i++) {
        tid = id_list[i];
        if (toHide) {
            document.getElementById('div_'+tid).style.display = 'none'
            document.getElementById(tid).className = 'hiddenRow';
        }
        else {
            document.getElementById(tid).className = '';
        }
    }
}


function showTestDetail(div_id){
    var details_div = document.getElementById(div_id)
    var displayState = details_div.style.display
    // alert(displayState)
    if (displayState != 'block' ) {
        displayState = 'block'
        details_div.style.display = 'block'
    }
    else {
        details_div.style.display = 'none'
    }
}


function html_escape(s) {
    s = s.replace(/&/g,'&amp;');
    s = s.replace(/</g,'&lt;');
    s = s.replace(/>/g,'&gt;');
    return s;
}

/* obsoleted by detail in <div>
function showOutput(id, name) {
    var w = window.open("", //url
                    name,
                    "resizable,scrollbars,status,width=800,height=450");
    d = w.document;
    d.write("<pre>");
    d.write(html_escape(output_list[id]));
    d.write("
");
    d.write("<a href='javascript:window.close()'>close</a>
");
    d.write("</pre>
");
    d.close();
}
*/
--></script>

%(heading)s
%(report)s
%(ending)s
%(chart_script)s
</body>
</html>
"""
    # variables: (title, generator, stylesheet, heading, report, ending)


    # ------------------------------------------------------------------------
    # Stylesheet
    #
    # alternatively use a <link> for external style sheet, e.g.
    #   <link rel="stylesheet" href="$url" type="text/css">

    STYLESHEET_TMPL = """
<style type="text/css" media="screen">
body        { font-family: verdana, arial, helvetica, sans-serif; font-size: 80%; }
table       { font-size: 100%; }
pre         {  }

/* -- heading ---------------------------------------------------------------------- */
h1 {
    font-size: 16pt;
    color: gray;
}
.heading {
    margin-top: 0ex;
    margin-bottom: 1ex;
    margin-left: 10px;
}

.heading .attribute {
    margin-top: 1ex;
    margin-bottom: 0;
}

.heading .description {
    margin-top: 4ex;
    margin-bottom: 6ex;
}

/* -- css div popup ------------------------------------------------------------------------ */
a.popup_link {
}

a.popup_link:hover {
    color: red;
}

.popup_window {
    display: none;
    position: relative;
    left: 0px;
    top: 0px;
    /*border: solid #627173 1px; */
    font-family: "Lucida Console", "Courier New", Courier, monospace;
    text-align: left;
    font-size: 8pt;
     500px;
}

}
/* -- report ------------------------------------------------------------------------ */
#show_detail_line {
    margin-top: 3ex;
    margin-bottom: 1ex;
    margin-left: 10px;
}
#result_table {
     80%;
    border-collapse: collapse;
    border: 1px solid #777;
    margin-left: 10px;
}
#header_row {
    font-weight: bold;
    color: #606060;
    background-color: #f5f5f5;
    border-top- 10px;
    border-color: #d6e9c6;
    font-size: 12px;
}
#result_table td {
    border: 1px solid #f5f5f5;
    padding: 2px;

}
#total_row  { font-weight: bold; }
.passClass  { background-color: #d6e9c6; }
.failClass  { background-color: #faebcc; }
.errorClass { background-color: #ebccd1; }
.passCase   { color: #6c6; }
.failCase   { color: #c60; font-weight: bold; }
.errorCase  { color: #c00; font-weight: bold; }
.hiddenRow  { display: none; }
.testcase   { margin-left: 2em; }


/* -- ending ---------------------------------------------------------------------- */
#ending {
}

/* -- chars ---------------------------------------------------------------------- */
.testChars {margin-left: 150px;}

.btn-info1 {
    color: #fff;
    background-color: #d6e9c6;
    border-color: #d6e9c6;
}

.btn-info2 {
    color: #fff;
    background-color: #faebcc;
    border-color: #faebcc;
}

.btn-info3 {
    color: #fff;
    background-color: #ebccd1;
    border-color: #ebccd1;
}
</style>
"""



    # ------------------------------------------------------------------------
    # Heading
    #

    HEADING_TMPL = """<div class='heading'>
<h1>%(title)s</h1>
%(parameters)s
<p class='description'>%(description)s</p>
</div>

<div style="float:left; margin-left: 10px;">
    <p> Test Case Pie charts </p>
    <a class="btn btn-xs btn-info1">-Pass-</a><br>
    <a class="btn btn-xs btn-info2">-Faild-</a><br>
    <a class="btn btn-xs btn-info3">-Error-</a><br>
</div>

<div class="testChars">
    <canvas id="myChart" width="250" height="250"></canvas>
</div>

""" # variables: (title, parameters, description)



    # ------------------------------------------------------------------------
    # Pie chart
    #

    ECHARTS_SCRIPT = """
    <script type="text/javascript">
var data = [
    {
        value: %(error)s,
        color: "#ebccd1",
        label: "Error",
        labelColor: 'white',
        labelFontSize: '16'
    },
    {
        value : %(fail)s,
        color : "#faebcc",
        label: "Fail",
        labelColor: 'white',
        labelFontSize: '16'
    },
    {
        value : %(Pass)s,
        color : "#d6e9c6",
        label : "Pass",
        labelColor: 'white',
        labelFontSize: '16'
    }            
]

var newopts = {
     animationSteps: 100,
         animationEasing: 'easeInOutQuart',
}

//Get the context of the canvas element we want to select
var ctx = document.getElementById("myChart").getContext("2d");

var myNewChart = new Chart(ctx).Pie(data,newopts);

</script>
    """
    
    
    HEADING_ATTRIBUTE_TMPL = """<p class='attribute'><strong>%(name)s:</strong> %(value)s</p>
""" # variables: (name, value)



    # ------------------------------------------------------------------------
    # Report
    #

    REPORT_TMPL = """
<p id='show_detail_line' style="margin-left: 10px;">Show
<a href='javascript:showCase(0)' class="btn btn-xs btn-primary">Summary</a>
<a href='javascript:showCase(1)' class="btn btn-xs btn-danger">Failed</a>
<a href='javascript:showCase(2)' class="btn btn-xs btn-info">All</a>
</p>
<table id='result_table'>
<colgroup>
<col align='left' />
<col align='right' />
<col align='right' />
<col align='right' />
<col align='right' />
<col align='right' />
</colgroup>
<tr id='header_row' class="panel-title">
    <td>Test Group/Test case</td>
    <td>Count</td>
    <td>Pass</td>
    <td>Fail</td>
    <td>Error</td>
    <td>View</td>
</tr>
%(test_list)s
<tr id='total_row'>
    <td>Total</td>
    <td>%(count)s</td>
    <td class="text text-success">%(Pass)s</td>
    <td class="text text-danger">%(fail)s</td>
    <td class="text text-warning">%(error)s</td>
    <td>&nbsp;</td>
</tr>
</table>
""" # variables: (test_list, count, Pass, fail, error)

    REPORT_CLASS_TMPL = r"""
<tr class='%(style)s'>
    <td>%(desc)s</td>
    <td>%(count)s</td>
    <td>%(Pass)s</td>
    <td>%(fail)s</td>
    <td>%(error)s</td>
    <td><a href="javascript:showClassDetail('%(cid)s',%(count)s)">Detail</a></td>
</tr>
""" # variables: (style, desc, count, Pass, fail, error, cid)


    REPORT_TEST_WITH_OUTPUT_TMPL = r"""
<tr id='%(tid)s' class='%(Class)s'>
    <td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
    <td colspan='5' align='center'>

    <!--css div popup start-->
    <a class="popup_link" onfocus='this.blur();' href="javascript:showTestDetail('div_%(tid)s')" >
        %(status)s</a>

    <div id='div_%(tid)s' class="popup_window">
        <div style='text-align: right; color:red;cursor:pointer'>
        <a onfocus='this.blur();' onclick="document.getElementById('div_%(tid)s').style.display = 'none' " >
           [x]</a>
        </div>
        <pre>
        %(script)s
        </pre>
    </div>
    <!--css div popup end-->

    </td>
</tr>
""" # variables: (tid, Class, style, desc, status)


    REPORT_TEST_NO_OUTPUT_TMPL = r"""
<tr id='%(tid)s' class='%(Class)s'>
    <td class='%(style)s'><div class='testcase'>%(desc)s</div></td>
    <td colspan='5' align='center'>%(status)s</td>
</tr>
""" # variables: (tid, Class, style, desc, status)


    REPORT_TEST_OUTPUT_TMPL = r"""
%(id)s: %(output)s
""" # variables: (id, output)



    # ------------------------------------------------------------------------
    # ENDING
    #

    ENDING_TMPL = """<div id='ending'>&nbsp;</div>"""

# -------------------- The end of the Template class -------------------


TestResult = unittest.TestResult

class _TestResult(TestResult):
    # note: _TestResult is a pure representation of results.
    # It lacks the output and reporting ability compares to unittest._TextTestResult.

    def __init__(self, verbosity=1):
        TestResult.__init__(self)
        self.stdout0 = None
        self.stderr0 = None
        self.success_count = 0
        self.failure_count = 0
        self.error_count = 0
        self.verbosity = verbosity

        # result is a list of result in 4 tuple
        # (
        #   result code (0: success; 1: fail; 2: error),
        #   TestCase object,
        #   Test output (byte string),
        #   stack trace,
        # )
        self.result = []


    def startTest(self, test):
        TestResult.startTest(self, test)
        # just one buffer for both stdout and stderr
        self.outputBuffer = io.StringIO()
        stdout_redirector.fp = self.outputBuffer
        stderr_redirector.fp = self.outputBuffer
        self.stdout0 = sys.stdout
        self.stderr0 = sys.stderr
        sys.stdout = stdout_redirector
        sys.stderr = stderr_redirector


    def complete_output(self):
        """
        Disconnect output redirection and return buffer.
        Safe to call multiple times.
        """
        if self.stdout0:
            sys.stdout = self.stdout0
            sys.stderr = self.stderr0
            self.stdout0 = None
            self.stderr0 = None
        return self.outputBuffer.getvalue()


    def stopTest(self, test):
        # Usually one of addSuccess, addError or addFailure would have been called.
        # But there are some path in unittest that would bypass this.
        # We must disconnect stdout in stopTest(), which is guaranteed to be called.
        self.complete_output()


    def addSuccess(self, test):
        self.success_count += 1
        TestResult.addSuccess(self, test)
        output = self.complete_output()
        self.result.append((0, test, output, ''))
        if self.verbosity > 1:
            sys.stderr.write('ok ')
            sys.stderr.write(str(test))
            sys.stderr.write('
')
        else:
            sys.stderr.write('.'+str(self.success_count))

    def addError(self, test, err):
        self.error_count += 1
        TestResult.addError(self, test, err)
        _, _exc_str = self.errors[-1]
        output = self.complete_output()
        self.result.append((2, test, output, _exc_str))
        if self.verbosity > 1:
            sys.stderr.write('E  ')
            sys.stderr.write(str(test))
            sys.stderr.write('
')
        else:
            sys.stderr.write('E')

    def addFailure(self, test, err):
        self.failure_count += 1
        TestResult.addFailure(self, test, err)
        _, _exc_str = self.failures[-1]
        output = self.complete_output()
        self.result.append((1, test, output, _exc_str))
        if self.verbosity > 1:
            sys.stderr.write('F  ')
            sys.stderr.write(str(test))
            sys.stderr.write('
')
        else:
            sys.stderr.write('F')


class HTMLTestRunner(Template_mixin):
    """
    """
    def __init__(self, stream=sys.stdout, verbosity=1, title=None, description=None):
        self.stream = stream
        self.verbosity = verbosity
        if title is None:
            self.title = self.DEFAULT_TITLE
        else:
            self.title = title
        if description is None:
            self.description = self.DEFAULT_DESCRIPTION
        else:
            self.description = description

        self.startTime = datetime.datetime.now()


    def run(self, test):
        "Run the given test case or test suite."
        result = _TestResult(self.verbosity)
        test(result)
        self.stopTime = datetime.datetime.now()
        self.generateReport(test, result)
        #print(sys.stderr, '
Time Elapsed: %s' % (self.stopTime-self.startTime))
        return result


    def sortResult(self, result_list):
        # unittest does not seems to run in any particular order.
        # Here at least we want to group them together by class.
        rmap = {}
        classes = []
        for n,t,o,e in result_list:
            cls = t.__class__
            if not cls in rmap:
                rmap[cls] = []
                classes.append(cls)
            rmap[cls].append((n,t,o,e))
        r = [(cls, rmap[cls]) for cls in classes]
        return r


    def getReportAttributes(self, result):
        """
        Return report attributes as a list of (name, value).
        Override this to add custom attributes.
        """
        startTime = str(self.startTime)[:19]
        duration = str(self.stopTime - self.startTime)
        status = []
        if result.success_count: status.append('Pass %s'    % result.success_count)
        if result.failure_count: status.append('Failure %s' % result.failure_count)
        if result.error_count:   status.append('Error %s'   % result.error_count  )
        if status:
            status = ' '.join(status)
        else:
            status = 'none'
        return [
            ('Start Time', startTime),
            ('Duration', duration),
            ('Status', status),
        ]


    def generateReport(self, test, result):
        report_attrs = self.getReportAttributes(result)
        generator = 'HTMLTestRunner %s' % __version__
        stylesheet = self._generate_stylesheet()
        heading = self._generate_heading(report_attrs)
        report = self._generate_report(result)
        ending = self._generate_ending()
        chart = self._generate_chart(result)
        output = self.HTML_TMPL % dict(
            title = saxutils.escape(self.title),
            generator = generator,
            stylesheet = stylesheet,
            heading = heading,
            report = report,
            ending = ending,
            chart_script = chart,
        )
        self.stream.write(output.encode('utf8'))


    def _generate_stylesheet(self):
        return self.STYLESHEET_TMPL


    def _generate_heading(self, report_attrs):
        a_lines = []
        for name, value in report_attrs:
            line = self.HEADING_ATTRIBUTE_TMPL % dict(
                    name = saxutils.escape(name),
                    value = saxutils.escape(value),
                )
            a_lines.append(line)
        heading = self.HEADING_TMPL % dict(
            title = saxutils.escape(self.title),
            parameters = ''.join(a_lines),
            description = saxutils.escape(self.description),
        )
        return heading


    def _generate_report(self, result):
        rows = []
        sortedResult = self.sortResult(result.result)
        for cid, (cls, cls_results) in enumerate(sortedResult):
            # subtotal for a class
            np = nf = ne = 0
            for n,t,o,e in cls_results:
                if n == 0: np += 1
                elif n == 1: nf += 1
                else: ne += 1

            # format class description
            if cls.__module__ == "__main__":
                name = cls.__name__
            else:
                name = "%s.%s" % (cls.__module__, cls.__name__)
            doc = cls.__doc__ and cls.__doc__.split("
")[0] or ""
            desc = doc and '%s: %s' % (name, doc) or name

            row = self.REPORT_CLASS_TMPL % dict(
                style = ne > 0 and 'errorClass' or nf > 0 and 'failClass' or 'passClass',
                desc = desc,
                count = np+nf+ne,
                Pass = np,
                fail = nf,
                error = ne,
                cid = 'c%s' % (cid+1),
            )
            rows.append(row)

            for tid, (n,t,o,e) in enumerate(cls_results):
                self._generate_report_test(rows, cid, tid, n, t, o, e)

        report = self.REPORT_TMPL % dict(
            test_list = ''.join(rows),
            count = str(result.success_count+result.failure_count+result.error_count),
            Pass = str(result.success_count),
            fail = str(result.failure_count),
            error = str(result.error_count),
        )
        return report

    def _generate_chart(self, result):
        chart = self.ECHARTS_SCRIPT % dict(
                Pass=str(result.success_count),
                fail=str(result.failure_count),
                error=str(result.error_count),
                )
        return chart

    def _generate_report_test(self, rows, cid, tid, n, t, o, e):
        # e.g. 'pt1.1', 'ft1.1', etc
        has_output = bool(o or e)
        tid = (n == 0 and 'p' or 'f') + 't%s.%s' % (cid+1,tid+1)
        name = t.id().split('.')[-1]
        doc = t.shortDescription() or ""
        desc = doc and ('%s: %s' % (name, doc)) or name
        tmpl = has_output and self.REPORT_TEST_WITH_OUTPUT_TMPL or self.REPORT_TEST_NO_OUTPUT_TMPL

        # o and e should be byte string because they are collected from stdout and stderr?
        if isinstance(o,str):
            # TODO: some problem with 'string_escape': it escape 
 and mess up formating
            # uo = unicode(o.encode('string_escape'))
            uo = o
        else:
            uo = o
        if isinstance(e,str):
            # TODO: some problem with 'string_escape': it escape 
 and mess up formating
            # ue = unicode(e.encode('string_escape'))
            ue = e
        else:
            ue = e

        script = self.REPORT_TEST_OUTPUT_TMPL % dict(
            id = tid,
            output = saxutils.escape(uo+ue),
        )

        row = tmpl % dict(
            tid = tid,
            Class = (n == 0 and 'hiddenRow' or 'none'),
            style = n == 2 and 'errorCase' or (n == 1 and 'failCase' or 'none'),
            desc = desc,
            script = script,
            status = self.STATUS[n],
        )
        rows.append(row)
        if not has_output:
            return

    def _generate_ending(self):
        return self.ENDING_TMPL


##############################################################################
# Facilities for running tests from the command line
##############################################################################

# Note: Reuse unittest.TestProgram to launch test. In the future we may
# build our own launcher to support more specific command line
# parameters like test title, CSS, etc.
class TestProgram(unittest.TestProgram):
    """
    A variation of the unittest.TestProgram. Please refer to the base
    class for command line parameters.
    """
    def runTests(self):
        # Pick HTMLTestRunner as the default test runner.
        # base class's testRunner parameter is not useful because it means
        # we have to instantiate HTMLTestRunner before we know self.verbosity.
        if self.testRunner is None:
            self.testRunner = HTMLTestRunner(verbosity=self.verbosity)
        unittest.TestProgram.runTests(self)

main = TestProgram

##############################################################################
# Executing this module from the command line
##############################################################################

if __name__ == "__main__":
    main(module=None)
View Code

这段代码是 HTMLTestRunner 类的部分实现,主要看__init__()初始化方法的参数。

● stream:指定生成 HTML 测试报告的文件,必填。
● verbosity:指定日志的级别,默认为 1。如果想得到更详细的日志,则可以将参数修改为 2。
● title:指定测试用例的标题,默认为 None。
● description:指定测试用例的描述,默认为 None。

在 HTMLTestRunner 类中,同样由 run()方法来运行测试套件中的测试用例。修改 run_tests.py 文件如下。

import unittest
from HTMLTestRunner import HTMLTestRunner


if __name__ == '__main__':

    #  定义测试用例的目录为当前目录
    test_dir = './test_case'
    suit = unittest.defaultTestLoader.discover(test_dir, pattern='test*.py')


    fp = open('./test_report/result.html', 'wb')
    # 调用HTMLTestRunner,运行测试用例
    runner = HTMLTestRunner(stream=fp,
                            title="百度搜索测试报告",
                            description="运行环境:Windows 10, Chrome浏览器"
                            )
    runner.run(suit)
    fp.close()
View Code

首先,使用 open()方法打开 result.html 文件,用于写入测试结果。如果没有 result.html 文件,则会自动创建该文件,并将该文件对象传给 HTMLTestRunner 类的初始化参数 stream。然后,调用 HTMLTestRunner 类中的 run()方法来运行测试套件。最后,关闭 result.html 文件。

打开/test_report/result.html 文件,将会得到一张HTML格式的报告。HTMLTestRunner 测试报告如图。

7.1.3 更易读的测试报告

现在生成的测试报告并不易读,因为它仅显示测试类名和测试方法名。如果随意命名为「test_case1」「test_case2」等,那么将很难明白这些测试用例所测试的功能。
在编写功能测试用例时,每条测试用例都有标题或说明,那么能否为自动化测试用例加上中文的标题或说明呢?答案是肯定的。在此之前,我们先来补充一个知识点:Python 的注释。

Python 的注释有两种,一种叫作 comment,另一种叫作 doc string。前者为普通注释,后者用于描述函数、类和方法。

在类或方法的下方,可以通过三引号(「」「」「」或『』『』『』)添加 doc string 类型的注释。这类注释在平时调用时不会显示,只有通过 help()方法查看时才会被显示出来。

因为 HTMLTestRunner 可以读取 doc string 类型的注释,所以,我们只需给测试类或方法添加这种类型的注释即可。

再次运行测试用例,查看测试报告,加了注释的测试报告如图 7-2 所示

7.1.4 测试报告文件名

因为测试报告的名称是固定的,所以每次新的测试报告都会覆盖上一次的。如果不想被覆盖,那么只能每次在运行前都手动修改报告的名称。这样显然非常麻烦,我们最好能为测试报告自动取不同的名称,并且还要有一定的含义。时间是个不错的选择,因为它可以标识每个报告的运行时间,更主要的是时间不会重复标识。
在 Python 的 time 模块中提供了各种关于时间操作的方法,利用这些方法可以完成这个需求。

说明如下。

● time.time():获取当前时间戳。
● time.ctime():当前时间的字符串形式。
● time.localtime():当前时间的 struct_time 形式。
● time.strftime():用来获取当前时间,可以将时间格式化为字符串。

打开 runtests.py 文件,做如下修改。

通过 strftime)方法以指定的格式获取当前日期时间,并赋值给 now_time 变量。将 now_time 通过加号(+)拼接到生成的测试报告的文件名中

7.2 数据驱动应用

数据驱动是自动化测试的一个重要功能,在第 5 章中,介绍了数据文件的使用。虽然不使用单元测试框架一样可以写测试代码和使用数据文件,但是这就意味着放弃了单元测试框架提供给我们的所有功能,如测试用例的断言、灵活的运行机制、结果统计及测试报告等。这些都需要自己去实现,显然非常麻烦。所以,抛开单元测试框架谈数据驱动的使用是没有意义的。
下面探讨数据驱动的使用,以及 unittest 关于参数化的库。

7.2.1 数据驱动

大多数文章和资料都把「读取数据文件」看作数据驱动的标志。
在 unittest 中,使用读取数据文件来实现参数化可以吗?当然可以。这里以读取 CSV 文件为例。创建一个 baidu_data.csv 文件,如图 7-4 所示。

 

文件第一列为测试用例名称,第二例为搜索的关键字。接下来创建 test_baidu_data.py 文件

import csv
import codecs
import unittest
from time import sleep
from itertools import islice
from selenium import webdriver


class TestBaidu(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        cls.driver = webdriver.Chrome()
        cls.base_url = "https://www.baidu.com"

    @classmethod
    def tearDownClass(cls):
        cls.driver.quit()

    def baidu_search(self, search_key):
        self.driver.get(self.base_url)
        self.driver.find_element_by_id("kw").send_keys(search_key)
        self.driver.find_element_by_id("su").click()
        sleep(3)

    def test_search(self):
        with codecs.open('baidu_data.csv', 'r', 'utf_8_sig') as f:
            data = csv.reader(f)
            for line in islice(data, 1, None):
                search_key = line[1]
                self.baidu_search(search_key)


if __name__ == '__main__':
    unittest.main(verbosity=2)
View Code

这样做似乎没有问题,确实可以读取 baidu_data.csv 文件中的三条数据并进行测试,测试结果如下

所有测试数据被当作一条测试用例执行了。我们知道,unittest 是以「test」开头的测试方法来划分测试用例的,而此处是在一个测试方法下面通过 for 循环来读取测试数据并执行的,因而会被当作一条测试用例

这样划分并不合理,比如,有 10 条数据,只要有 1 条数据执行失败,那么整个测试用例就执行失败了。所以,10 条数据对应 10 条测试用例更为合适,就算其中 1 条数据的测试用例执行失败了,也不会影响其他 9 条数据的测试用例的执行,并且在定位测试用例失败的原因时会更加简单

import csv
import codecs
import unittest
from time import sleep
from itertools import islice
from selenium import webdriver


class TestBaidu(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        cls.driver = webdriver.Chrome()
        cls.base_url = "https://www.baidu.com"
        cls.test_data = []
        with codecs.open('baidu_data.csv', 'r', 'utf_8_sig') as f:
            data = csv.reader(f)
            for line in islice(data, 1, None):
                cls.test_data.append(line)

    @classmethod
    def tearDownClass(cls):
        cls.driver.quit()

    def baidu_search(self, search_key):
        self.driver.get(self.base_url)
        self.driver.find_element_by_id("kw").send_keys(search_key)
        self.driver.find_element_by_id("su").click()
        sleep(3)

    def test_search_selenium(self):
        self.baidu_search(self.test_data[0][1])

    def test_search_unittest(self):
        self.baidu_search(self.test_data[1][1])

    def test_search_parameterized(self):
        self.baidu_search(self.test_data[2][1])


if __name__ == '__main__':
    unittest.main(verbosity=2)
View Code

这一次,用 setUpClass() 方法读取 baidu_data.csv 文件,并将文件中的数据存储到 test_data 数组中。分别创建不同的测试方法使用 test_data 中的数据,测试结果如下

从测试结果可以看到,3 条数据被当作 3 条测试用例执行了。那么是不是就完美解决了前面的问题呢?接下来,需要思考一下,读取数据文件带来了哪些问题

(1)增加了读取的成本。不管什么样的数据文件,在运行自动化测试用例前都需要将文件中的数据读取到程序中,这一步是不能少的

(2)不方便维护。读取数据文件是为了方便维护,但事实上恰恰相反。在 CSV 数据文件中,并不能直观体现出每一条数据对应的测试用例。而在测试用例中通过 test_data[0][1]方式获取数据也存在很多问题,如果在 CSV 文件中间插入了一条数据,那么测试用例获取到的测试数据很可能就是错的

如果在测试过程中需要用很多数据怎么办?我们知道测试脚本并不是用来存放数据的地方,如果待测试的数据很多,那么全部放到测试脚本中显然并不合适

在回答这个问题之前,先思考一下什么是 UI 自动化测试?UI 自动化测试是站在用户的角度模拟用户的操作。那么用户在什么场景下会输入大量的数据呢?其实输入大量数据的功能很少,如果整个系统都需要用户重复或大量地输入数据,那么很可能是用户体验做得不好!大多数时候,系统只允许用户输入用户名、密码和个人信息,或搜索一些关键字等

假设我们要测试用户发文章的功能,这时确实会用到大量的数据

那么读取数据文件是不是就完全没必要了呢?当然不是,比如一些自动化测试的配置就可以放到数据文件中,如运行环境、运行的浏览器等,放到配置文件中会更方便管理

7.2.2 Parameterized

Parameterized 是 Python 的一个参数化库,同时支持 unittest、Nose 和 pytest 单元测试框架。
GitHub 地址:https://github.com/wolever/parameterized
Parameterized 支持 pip 安装。

pip install parameterized

离线安装命令:python setup.py install

Installed d:python38libsite-packagesparameterized-0.7.4-py3.8.egg
Processing dependencies for parameterized==0.7.4
Finished processing dependencies for parameterized==0.7.4

在第 6.3 节实现了百度搜索的测试,这里将通过 Parameterized 实现参数化

test_baidu_parameterized

import unittest
from time import sleep
from selenium import webdriver
from parameterized import parameterized


class TestBaidu(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        cls.driver = webdriver.Chrome()
        cls.base_url = "https://www.baidu.com"

    def baidu_search(self, search_key):
        self.driver.get(self.base_url)
        self.driver.find_element_by_id("kw").send_keys(search_key)
        self.driver.find_element_by_id("su").click()
        sleep(2)
    
    # parameterized参数化
    @parameterized.expand([
        ("case1", "selenium"),
        ("case2", "unittest"),
        ("case3", "parameterized"),
    ])
    def test_search(self, name, search_key):
        self.baidu_search(search_key)
        self.assertEqual(self.driver.title, search_key + "_百度搜索")

    @classmethod
    def tearDownClass(cls):
        cls.driver.quit()


if __name__ == '__main__':
    unittest.main(verbosity=2)
View Code

这里的主要改动在测试用例部分。

首先,导入 Parameterized 库下面的 parameterized 类。
其次,通过@parameterized.expand()来装饰测试用例 test_search()。
在@parameterized.expand()中,每个元组都可以被认为是一条测试用例。元组中的数据为该条测试用例变化的值。在测试用例中,通过参数来取每个元组中的数据。
在 test_search()中,name 参数对应元组中第一列数据,即「case1」「case2」「case3」,用来定义测试用例的名称;search_key 参数对应元组中第二列数据,即「selenium」「unittest」「parameterized」,用来定义搜索的关键字。
最后,使用 unittest 的 main()方法,设置 verbosity 参数为 2,输出更详细的执行日志。运行上面的测试用例,结果如下。

 

通过测试结果可以看到,因为是根据@parameterized.expand()中元组的个数来统计测试用例数的,所以产生了 3 条测试用例。test_search 为定义的测试用例的名称。参数化会自动加上「0「1」和「2」来区分每条测试用例,在元组中定义的「case1「case2「case3」也会作为每条测试用例名称的后缀出现

7.2.3 DDT

DDT(Data-Driven Tests)是针对 unittest 单元测试框架设计的扩展库。允许使用不同的测试数据来运行一个测试用例,并将其展示为多个测试用例。
GitHub 地址:https://github.com/datadriventests/ddt

DDT 支持 pip 安装。

pip install ddt

同样以百度搜索为例,来看看 DDT 的用法。创建 test_baidu_ddt.py 文件

import unittest
from selenium import webdriver
from time import sleep
from ddt import ddt, data, file_data, unpack

@ddt
class TestBaidu(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.driver = webdriver.Chrome()
        cls.base_url = "https://www.baidu.com"

    def baidu_search(self, search_key):
        self.driver.get(self.base_url)
        self.driver.find_element_by_id("kw").send_keys(search_key)
        self.driver.find_element_by_id("su").click()
        sleep(3)

    # 参数化使用方式一
    @data(["case1", "selenium"], ["case2", "ddt"], ["case3", "python"])
    @unpack
    def test_search1(self, case, search_key):
        print("第一组测试用例:", case)
        self.baidu_search(search_key)
        self.assertEqual(self.driver.title, search_key + "_百度搜索")

    # 参数化使用方式二
    @data(("case1", "selenium"), ("case2", "ddt"), ("case3", "python"))
    @unpack
    def test_search2(self, case, search_key):
        print("第二组测试用例:", case)
        self.baidu_search(search_key)
        self.assertEqual(self.driver.title, search_key + "_百度搜索")

    # 参数化使用方式三
    @data({"search_key": "selenium"},
          {"search_key": "ddt"},
          {"search_key": "python"})
    @unpack
    def test_search3(self, search_key):
        print("第三组测试用例:", search_key)
        self.baidu_search(search_key)
        self.assertEqual(self.driver.title, search_key + "_百度搜索")

    # 参数化读取JSON文件
    @file_data('ddt_data_file.json')
    def test_search4(self, search_key):
        print("第四组测试用例:", search_key)
        self.baidu_search(search_key)
        self.assertEqual(self.driver.title, search_key + "_百度搜索")

    # 参数化读取yaml文件
    @file_data('ddt_data_file.yaml')
    def test_search5(self, case):
        search_key = case[0]["search_key"]
        print("第五组测试用例:", search_key)
        self.baidu_search(search_key)
        self.assertEqual(self.driver.title, search_key + "_百度搜索")

    @classmethod
    def tearDownClass(cls):
        cls.driver.quit()


if __name__ == '__main__':
    unittest.main(verbosity=2)
View Code

使用 DDT 需要注意以下几点。
首先,测试类需要通过@ddt 装饰器进行装饰。
其次,DDT 提供了不同形式的参数化。这里列举了三组参数化,第一组为列表,第二组为元组,第三组为字典。需要注意的是,字典的 key 与测试方法的参数要保持一致。

执行结果如下。

DDT 同样支持数据文件的参数化。它封装了数据文件的读取,让我们更专注于数据文件中的内容,以及在测试用例中的使用,而不需要关心数据文件是如何被读取进来的

首先,创建 ddt_data_file.json 文件

{
    "case1": {"search_key": "python"},
    "case2": {"search_key": "ddt"},
    "case3": {"search_key": "Selenium"}
}

在测试用例中使用 test_data_file.json 文件参数化测试用例,在 test_baidu_ddt.py 文件中增加测试用例数据

注意,ddt_data_file.json 文件需要与 test_baidu_ddt.py 放在同一目录下面,否则需要指定 ddt_data_file.json 文件的路径

除此之外,DDT 还支持 yaml 格式的数据文件。创建 ddt_data_file.yaml 文件

case1:
  - search_key: "python"
case2:
  - search_key: "ddt"
case3:
  - search_key: "unittest"

在 test_baidu_ddt.py 文件中增加测试用例

这里的取值与上面的 JSON 文件有所不同,因为每一条用例都被解析为[{『search_key』:『python』}],所以要想取到搜索关键字,则需要通过 case[0][「search_key」]的方式获取 

7.3 自动发送邮件功能

自动发送邮件功能是自动化测试项目的重要需求之一,当自动化测试用例运行完成之后,可自动向相关人员的邮箱发送测试报告。
SMTP(Simple Mail Transfer Protocol)是简单邮件传输协议,是一组由源地址到目的地址传送邮件的规则,可以控制信件的中转方式。Python 的 smtplib 模块提供了简单的 API 用来实现发送邮件功能,它对 SMTP 进行了简单的封装。
在给其他人发送邮件之前,首先需要通过浏览器打开邮箱网址(如 www.126.com),或打开邮箱客户端(如 Foxmail),登录自己的邮箱账号。如果是邮箱客户端,则还需要配置邮箱服务器地址(如 smtp.126.com)。然后填写收件人地址、邮件的主题和正文,以及添加附件等。即便通过 Python 实现发送邮件功能,也需要设置这些信息。

7.3.1 Python 自带的发送邮件功能

在发送邮件时,除填写主题和正文外,还可以添加抄送人、添加附件等。这里我们分别把测试报告作为正文和附件进行发送

1.发送邮件正文

send_mail_text.py

import smtplib
from email.mime.text import MIMEText
from email.header import Header

# 发送邮件标题
subject = 'Python email test'

# 编写HTML类型的邮件正文
msg = MIMEText('<html><h1>你好!</h1></html>', 'html', 'utf-8')
msg['Subject'] = Header(subject, 'utf-8')

# 连接发送邮件
smtp = smtplib.SMTP()
smtp.connect("smtp.126.com")
smtp.login("sender@126.com", "a123456")
smtp.sendmail("sender@126.com", "receiver@126.com", msg.as_string())
smtp.quit()
View Code

首先,调用 email 模块下面的 MIMEText 类,定义发送邮件的正文、格式,以及编码。
然后,调用 email 模块下面的 Header 类,定义邮件的主题和编码类型。
smtplib 模块用于发送邮件。connect()方法指定连接的邮箱服务;login()方法指定登录邮箱的账号和密码;sendmail()方法指定发件人、收件人,以及邮件的正文;quit()方法用于关闭邮件服务器的连接。

2.发送带附件的邮件

send_mail_att.py

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

# 邮件主题
subject = 'Python send email test'
# 发送的附件
with open('log.txt', 'rb') as f:
    send_att = f.read()

att = MIMEText(send_att, 'text', 'utf-8')
att["Content-Type"] = 'application/octet-stream'
att["Content-Disposition"] = 'attachment; filename="log.txt"'

msg = MIMEMultipart()
msg['Subject'] = subject
msg.attach(att)

smtp = smtplib.SMTP()
smtp.connect("smtp.126.com")
smtp.login("sender@126.com", "a123456")
smtp.sendmail("sender@126.com", "receiver@126.com", msg.as_string())
smtp.quit()
View Code

首先,读取附件的内容。通过 MIMEText 类,定义发送邮件的正文、格式,以及编码;Content-Type 指定附件内容类型;application/octet-stream 表示二进制流;Content-Disposition 指定显示附件的文件;attachment;filename=「log.txt」指定附件的文件名。
然后,使用 MIMEMultipart 类定义邮件的主题,attach()指定附件信息。
最后,通过 smtplib 模块发送邮件,发送过程与第一个例子相同。

7.3.2 用 yagmail 发送邮件

yagmail 是 Python 的一个第三方库,可以让我们以非常简单的方法实现自动发送邮件功能。
GitHub 项目地址:https://github.com/kootenpv/yagmail
通过 pip 命令安装。

pip install yagmail

项目文档提供了的简单发送邮件的例子

yagmail_demo.py

import yagmail

#连接邮箱服务器
yag = yagmail.SMTP(user="sender@126.com", password="a123456", host='smtp.126.com')

# 邮箱正文
contents = ['This is the body, and here is just text http://somedomain/image.png',
            'You can find an audio file attached.']

# 发送邮件
yag.send('receiver@126.com', 'send email subject', contents)
View Code

总共四行代码,是不是比上面的例子简单太多了。有了前面的基础,这里的代码就不需要做过多解释了。
如果想给多个用户发送邮件,那么只需把收件人放到一个 list 中即可。

如果想发送带附件的邮件,那么只需指定本地附件的路径即可

 

另外,还可以通过 list 指定多个附件。yagmail 库极大地简化了发送邮件的代码

7.3.3 整合自动发送邮件功能

在学习了如何用 Python 实现发送邮件之后,现在只需将功能集成到自动化测试项目中即可。打开 run_tests.py 文件,修改代码如下。

run_tests.py

import time
import unittest
import yagmail
from HTMLTestRunner import HTMLTestRunner


# 把测试报告作为附件发送到指定邮箱。
def send_mail(report):
    yag = yagmail.SMTP(user="testingwtb@126.com",
                       password="a123456",
                       host='smtp.126.com')
    subject = "标题,自动化测试报告"
    contents = "正文,请查看附件。"
    yag.send('testingwtb@126.com', subject, contents, report)
    print('email has send out !')


if __name__ == '__main__':
    # 定义测试用例的目录为当前目录
    test_dir = './test_case'
    suit = unittest.defaultTestLoader.discover(test_dir, pattern='test_baidu_parameterized.py')

    # 取当前日期时间
    now_time = time.strftime("%Y-%m-%d %H_%M_%S")
    html_report = './test_report/' + now_time + 'result.html'
    fp = open(html_report, 'wb')
    # 调用HTMLTestRunner,运行测试用例
    runner = HTMLTestRunner(stream=fp,
                            title="百度搜索测试报告",
                            description="运行环境:Windows 10, Chrome浏览器"
                            )
    runner.run(suit)
    fp.close()
    send_mail(html_report)  # 发送报告
View Code

整个程序的执行过程可以分为两部分:
(1)定义测试报告文件,并赋值给变量 html_report,通过 HTMLTestRunner 运行测试用例,将结果写入文件后关闭。
(2)调用 send_mail()函数,并传入 html_report 文件。在 send_mail()函数中,把测试报告作为邮件的附件发送到指定邮箱。

为什么不把测试报告的内容读取出来作为邮件正文发送呢?因为 HTMLTestRunner 报告在展示时引用了 Bootstrap 样式库,当作为邮件正文「写死」在邮件中时,会导致样式丢失,所以作为附件发送更为合适。

部分内容来自于学习编程期间收集于网络的免费分享资源和工作后购买的付费内容。
原文地址:https://www.cnblogs.com/MarlonKang/p/12448059.html