mirror of
https://github.com/httprunner/httprunner.git
synced 2026-07-11 07:22:33 +08:00
Merge pull request #799 from httprunner/leo_dev
2.4.5 (2019-12-20) **Added** - feat: integrate sentry sdk **Fixed** - fix: catch UnicodeDecodeError when json loads request body - fix: display indented json for request json body **Changed** - change: detect request/response bytes encoding, instead of assuming utf-8 - refactor: make report as submodule
This commit is contained in:
@@ -1,5 +1,21 @@
|
|||||||
# Release History
|
# Release History
|
||||||
|
|
||||||
|
## 2.4.5 (2019-12-20)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
|
||||||
|
- feat: integrate sentry sdk
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- fix: catch UnicodeDecodeError when json loads request body
|
||||||
|
- fix: display indented json for request json body
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
|
||||||
|
- change: detect request/response bytes encoding, instead of assuming utf-8
|
||||||
|
- refactor: make report as submodule
|
||||||
|
|
||||||
## 2.4.4 (2019-12-17)
|
## 2.4.4 (2019-12-17)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
__version__ = "2.4.4"
|
__version__ = "2.4.5"
|
||||||
__description__ = "One-stop solution for HTTP(S) testing."
|
__description__ = "One-stop solution for HTTP(S) testing."
|
||||||
|
|
||||||
__all__ = ["__version__", "__description__"]
|
__all__ = ["__version__", "__description__"]
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
|
|
||||||
from httprunner.cli import main
|
from httprunner.cli import main
|
||||||
|
|
||||||
|
sentry_sdk.init("https://cc6dd86fbe9f4e7fbd95248cfcff114d@sentry.io/1862849")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ def is_testcase_path(path):
|
|||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# TODO: check file format if valid
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,406 +0,0 @@
|
|||||||
import io
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
from base64 import b64encode
|
|
||||||
from collections import Iterable
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from jinja2 import Template, escape
|
|
||||||
from requests.cookies import RequestsCookieJar
|
|
||||||
|
|
||||||
from httprunner import __version__, logger
|
|
||||||
from httprunner.compat import basestring, bytes, json, numeric_types, JSONDecodeError
|
|
||||||
|
|
||||||
|
|
||||||
def get_platform():
|
|
||||||
return {
|
|
||||||
"httprunner_version": __version__,
|
|
||||||
"python_version": "{} {}".format(
|
|
||||||
platform.python_implementation(),
|
|
||||||
platform.python_version()
|
|
||||||
),
|
|
||||||
"platform": platform.platform()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_summary(result):
|
|
||||||
""" get summary from test result
|
|
||||||
|
|
||||||
Args:
|
|
||||||
result (instance): HtmlTestResult() instance
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: summary extracted from result.
|
|
||||||
|
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
"stat": {},
|
|
||||||
"time": {},
|
|
||||||
"records": []
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
summary = {
|
|
||||||
"success": result.wasSuccessful(),
|
|
||||||
"stat": {
|
|
||||||
'total': result.testsRun,
|
|
||||||
'failures': len(result.failures),
|
|
||||||
'errors': len(result.errors),
|
|
||||||
'skipped': len(result.skipped),
|
|
||||||
'expectedFailures': len(result.expectedFailures),
|
|
||||||
'unexpectedSuccesses': len(result.unexpectedSuccesses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
summary["stat"]["successes"] = summary["stat"]["total"] \
|
|
||||||
- summary["stat"]["failures"] \
|
|
||||||
- summary["stat"]["errors"] \
|
|
||||||
- summary["stat"]["skipped"] \
|
|
||||||
- summary["stat"]["expectedFailures"] \
|
|
||||||
- summary["stat"]["unexpectedSuccesses"]
|
|
||||||
|
|
||||||
summary["time"] = {
|
|
||||||
'start_at': result.start_at,
|
|
||||||
'duration': result.duration
|
|
||||||
}
|
|
||||||
summary["records"] = result.records
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
def aggregate_stat(origin_stat, new_stat):
|
|
||||||
""" aggregate new_stat to origin_stat.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
origin_stat (dict): origin stat dict, will be updated with new_stat dict.
|
|
||||||
new_stat (dict): new stat dict.
|
|
||||||
|
|
||||||
"""
|
|
||||||
for key in new_stat:
|
|
||||||
if key not in origin_stat:
|
|
||||||
origin_stat[key] = new_stat[key]
|
|
||||||
elif key == "start_at":
|
|
||||||
# start datetime
|
|
||||||
origin_stat["start_at"] = min(origin_stat["start_at"], new_stat["start_at"])
|
|
||||||
elif key == "duration":
|
|
||||||
# duration = max_end_time - min_start_time
|
|
||||||
max_end_time = max(origin_stat["start_at"] + origin_stat["duration"],
|
|
||||||
new_stat["start_at"] + new_stat["duration"])
|
|
||||||
min_start_time = min(origin_stat["start_at"], new_stat["start_at"])
|
|
||||||
origin_stat["duration"] = max_end_time - min_start_time
|
|
||||||
else:
|
|
||||||
origin_stat[key] += new_stat[key]
|
|
||||||
|
|
||||||
|
|
||||||
def stringify_summary(summary):
|
|
||||||
""" stringify summary, in order to dump json file and generate html report.
|
|
||||||
"""
|
|
||||||
for index, suite_summary in enumerate(summary["details"]):
|
|
||||||
|
|
||||||
if not suite_summary.get("name"):
|
|
||||||
suite_summary["name"] = "testcase {}".format(index)
|
|
||||||
|
|
||||||
for record in suite_summary.get("records"):
|
|
||||||
meta_datas = record['meta_datas']
|
|
||||||
__stringify_meta_datas(meta_datas)
|
|
||||||
meta_datas_expanded = []
|
|
||||||
__expand_meta_datas(meta_datas, meta_datas_expanded)
|
|
||||||
record["meta_datas_expanded"] = meta_datas_expanded
|
|
||||||
record["response_time"] = __get_total_response_time(meta_datas_expanded)
|
|
||||||
|
|
||||||
|
|
||||||
def dumps_json(value):
|
|
||||||
""" dumps json value to indented string
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (dict): raw json data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: indented json dump string
|
|
||||||
|
|
||||||
"""
|
|
||||||
return json.dumps(value, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def __stringify_request(request_data):
|
|
||||||
""" stringfy HTTP request data
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request_data (dict): HTTP request data in dict.
|
|
||||||
|
|
||||||
{
|
|
||||||
"url": "http://127.0.0.1:5000/api/get-token",
|
|
||||||
"method": "POST",
|
|
||||||
"headers": {
|
|
||||||
"User-Agent": "python-requests/2.20.0",
|
|
||||||
"Accept-Encoding": "gzip, deflate",
|
|
||||||
"Accept": "*/*",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"user_agent": "iOS/10.3",
|
|
||||||
"device_sn": "TESTCASE_CREATE_XXX",
|
|
||||||
"os_platform": "ios",
|
|
||||||
"app_version": "2.8.6",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Content-Length": "52"
|
|
||||||
},
|
|
||||||
"json": {
|
|
||||||
"sign": "cb9d60acd09080ea66c8e63a1c78c6459ea00168"
|
|
||||||
},
|
|
||||||
"verify": false
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
for key, value in request_data.items():
|
|
||||||
|
|
||||||
if isinstance(value, (list, dict)):
|
|
||||||
value = dumps_json(value)
|
|
||||||
|
|
||||||
elif isinstance(value, bytes):
|
|
||||||
try:
|
|
||||||
encoding = "utf-8"
|
|
||||||
value = escape(value.decode(encoding))
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if key == "body":
|
|
||||||
try:
|
|
||||||
# request body is in json format
|
|
||||||
value = json.loads(value)
|
|
||||||
except JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif not isinstance(value, (basestring, numeric_types, Iterable)):
|
|
||||||
# class instance, e.g. MultipartEncoder()
|
|
||||||
value = repr(value)
|
|
||||||
|
|
||||||
elif isinstance(value, RequestsCookieJar):
|
|
||||||
value = value.get_dict()
|
|
||||||
|
|
||||||
request_data[key] = value
|
|
||||||
|
|
||||||
|
|
||||||
def __stringify_response(response_data):
|
|
||||||
""" stringfy HTTP response data
|
|
||||||
|
|
||||||
Args:
|
|
||||||
response_data (dict):
|
|
||||||
|
|
||||||
{
|
|
||||||
"status_code": 404,
|
|
||||||
"headers": {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Content-Length": "30",
|
|
||||||
"Server": "Werkzeug/0.14.1 Python/3.7.0",
|
|
||||||
"Date": "Tue, 27 Nov 2018 06:19:27 GMT"
|
|
||||||
},
|
|
||||||
"encoding": "None",
|
|
||||||
"content_type": "application/json",
|
|
||||||
"ok": false,
|
|
||||||
"url": "http://127.0.0.1:5000/api/users/9001",
|
|
||||||
"reason": "NOT FOUND",
|
|
||||||
"cookies": {},
|
|
||||||
"json": {
|
|
||||||
"success": false,
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
for key, value in response_data.items():
|
|
||||||
|
|
||||||
if isinstance(value, (list, dict)):
|
|
||||||
value = dumps_json(value)
|
|
||||||
|
|
||||||
elif isinstance(value, bytes):
|
|
||||||
try:
|
|
||||||
encoding = response_data.get("encoding")
|
|
||||||
if not encoding or encoding == "None":
|
|
||||||
encoding = "utf-8"
|
|
||||||
|
|
||||||
if key == "body" and "image" in response_data["content_type"]:
|
|
||||||
# display image
|
|
||||||
value = "data:{};base64,{}".format(
|
|
||||||
response_data["content_type"],
|
|
||||||
b64encode(value).decode(encoding)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
value = escape(value.decode(encoding))
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif not isinstance(value, (basestring, numeric_types, Iterable)):
|
|
||||||
# class instance, e.g. MultipartEncoder()
|
|
||||||
value = repr(value)
|
|
||||||
|
|
||||||
elif isinstance(value, RequestsCookieJar):
|
|
||||||
value = value.get_dict()
|
|
||||||
|
|
||||||
response_data[key] = value
|
|
||||||
|
|
||||||
|
|
||||||
def __expand_meta_datas(meta_datas, meta_datas_expanded):
|
|
||||||
""" expand meta_datas to one level
|
|
||||||
|
|
||||||
Args:
|
|
||||||
meta_datas (dict/list): maybe in nested format
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: expanded list in one level
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> meta_datas = [
|
|
||||||
[
|
|
||||||
dict1,
|
|
||||||
dict2
|
|
||||||
],
|
|
||||||
dict3
|
|
||||||
]
|
|
||||||
>>> meta_datas_expanded = []
|
|
||||||
>>> __expand_meta_datas(meta_datas, meta_datas_expanded)
|
|
||||||
>>> print(meta_datas_expanded)
|
|
||||||
[dict1, dict2, dict3]
|
|
||||||
|
|
||||||
"""
|
|
||||||
if isinstance(meta_datas, dict):
|
|
||||||
meta_datas_expanded.append(meta_datas)
|
|
||||||
elif isinstance(meta_datas, list):
|
|
||||||
for meta_data in meta_datas:
|
|
||||||
__expand_meta_datas(meta_data, meta_datas_expanded)
|
|
||||||
|
|
||||||
|
|
||||||
def __get_total_response_time(meta_datas_expanded):
|
|
||||||
""" caculate total response time of all meta_datas
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response_time = 0
|
|
||||||
for meta_data in meta_datas_expanded:
|
|
||||||
response_time += meta_data["stat"]["response_time_ms"]
|
|
||||||
|
|
||||||
return "{:.2f}".format(response_time)
|
|
||||||
|
|
||||||
except TypeError:
|
|
||||||
# failure exists
|
|
||||||
return "N/A"
|
|
||||||
|
|
||||||
|
|
||||||
def __stringify_meta_datas(meta_datas):
|
|
||||||
|
|
||||||
if isinstance(meta_datas, list):
|
|
||||||
for _meta_data in meta_datas:
|
|
||||||
__stringify_meta_datas(_meta_data)
|
|
||||||
elif isinstance(meta_datas, dict):
|
|
||||||
data_list = meta_datas["data"]
|
|
||||||
for data in data_list:
|
|
||||||
__stringify_request(data["request"])
|
|
||||||
__stringify_response(data["response"])
|
|
||||||
|
|
||||||
|
|
||||||
def gen_html_report(summary, report_template=None, report_dir=None, report_file=None):
|
|
||||||
""" render html report with specified report name and template
|
|
||||||
|
|
||||||
Args:
|
|
||||||
summary (dict): test result summary data
|
|
||||||
report_template (str): specify html report template path, template should be in Jinja2 format.
|
|
||||||
report_dir (str): specify html report save directory
|
|
||||||
report_file (str): specify html report file path, this has higher priority than specifying report dir.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not report_template:
|
|
||||||
report_template = os.path.join(
|
|
||||||
os.path.abspath(os.path.dirname(__file__)),
|
|
||||||
"static",
|
|
||||||
"report_template.html"
|
|
||||||
)
|
|
||||||
logger.log_debug("No html report template specified, use default.")
|
|
||||||
else:
|
|
||||||
logger.log_info("render with html report template: {}".format(report_template))
|
|
||||||
|
|
||||||
logger.log_info("Start to render Html report ...")
|
|
||||||
|
|
||||||
start_at_timestamp = int(summary["time"]["start_at"])
|
|
||||||
summary["time"]["start_datetime"] = datetime.fromtimestamp(start_at_timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
|
|
||||||
if report_file:
|
|
||||||
report_dir = os.path.dirname(report_file)
|
|
||||||
report_file_name = os.path.basename(report_file)
|
|
||||||
else:
|
|
||||||
report_dir = report_dir or os.path.join(os.getcwd(), "reports")
|
|
||||||
report_file_name = "{}.html".format(start_at_timestamp)
|
|
||||||
|
|
||||||
if not os.path.isdir(report_dir):
|
|
||||||
os.makedirs(report_dir)
|
|
||||||
|
|
||||||
report_path = os.path.join(report_dir, report_file_name)
|
|
||||||
with io.open(report_template, "r", encoding='utf-8') as fp_r:
|
|
||||||
template_content = fp_r.read()
|
|
||||||
with io.open(report_path, 'w', encoding='utf-8') as fp_w:
|
|
||||||
rendered_content = Template(
|
|
||||||
template_content,
|
|
||||||
extensions=["jinja2.ext.loopcontrols"]
|
|
||||||
).render(summary)
|
|
||||||
fp_w.write(rendered_content)
|
|
||||||
|
|
||||||
logger.log_info("Generated Html report: {}".format(report_path))
|
|
||||||
|
|
||||||
return report_path
|
|
||||||
|
|
||||||
|
|
||||||
class HtmlTestResult(unittest.TextTestResult):
|
|
||||||
""" A html result class that can generate formatted html results.
|
|
||||||
Used by TextTestRunner.
|
|
||||||
"""
|
|
||||||
def __init__(self, stream, descriptions, verbosity):
|
|
||||||
super(HtmlTestResult, self).__init__(stream, descriptions, verbosity)
|
|
||||||
self.records = []
|
|
||||||
|
|
||||||
def _record_test(self, test, status, attachment=''):
|
|
||||||
data = {
|
|
||||||
'name': test.shortDescription(),
|
|
||||||
'status': status,
|
|
||||||
'attachment': attachment,
|
|
||||||
"meta_datas": test.meta_datas
|
|
||||||
}
|
|
||||||
self.records.append(data)
|
|
||||||
|
|
||||||
def startTestRun(self):
|
|
||||||
self.start_at = time.time()
|
|
||||||
|
|
||||||
def startTest(self, test):
|
|
||||||
""" add start test time """
|
|
||||||
super(HtmlTestResult, self).startTest(test)
|
|
||||||
logger.color_print(test.shortDescription(), "yellow")
|
|
||||||
|
|
||||||
def addSuccess(self, test):
|
|
||||||
super(HtmlTestResult, self).addSuccess(test)
|
|
||||||
self._record_test(test, 'success')
|
|
||||||
print("")
|
|
||||||
|
|
||||||
def addError(self, test, err):
|
|
||||||
super(HtmlTestResult, self).addError(test, err)
|
|
||||||
self._record_test(test, 'error', self._exc_info_to_string(err, test))
|
|
||||||
print("")
|
|
||||||
|
|
||||||
def addFailure(self, test, err):
|
|
||||||
super(HtmlTestResult, self).addFailure(test, err)
|
|
||||||
self._record_test(test, 'failure', self._exc_info_to_string(err, test))
|
|
||||||
print("")
|
|
||||||
|
|
||||||
def addSkip(self, test, reason):
|
|
||||||
super(HtmlTestResult, self).addSkip(test, reason)
|
|
||||||
self._record_test(test, 'skipped', reason)
|
|
||||||
print("")
|
|
||||||
|
|
||||||
def addExpectedFailure(self, test, err):
|
|
||||||
super(HtmlTestResult, self).addExpectedFailure(test, err)
|
|
||||||
self._record_test(test, 'ExpectedFailure', self._exc_info_to_string(err, test))
|
|
||||||
print("")
|
|
||||||
|
|
||||||
def addUnexpectedSuccess(self, test):
|
|
||||||
super(HtmlTestResult, self).addUnexpectedSuccess(test)
|
|
||||||
self._record_test(test, 'UnexpectedSuccess')
|
|
||||||
print("")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def duration(self):
|
|
||||||
return time.time() - self.start_at
|
|
||||||
20
httprunner/report/__init__.py
Normal file
20
httprunner/report/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
HttpRunner report
|
||||||
|
|
||||||
|
- summarize: aggregate test stat data to summary
|
||||||
|
- stringify: stringify summary, in order to dump json file and generate html report.
|
||||||
|
- html: render html report
|
||||||
|
"""
|
||||||
|
|
||||||
|
from httprunner.report.summarize import get_platform, aggregate_stat, get_summary
|
||||||
|
from httprunner.report.stringify import stringify_summary
|
||||||
|
from httprunner.report.html import HtmlTestResult, gen_html_report
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_platform",
|
||||||
|
"aggregate_stat",
|
||||||
|
"get_summary",
|
||||||
|
"stringify_summary",
|
||||||
|
"HtmlTestResult",
|
||||||
|
"gen_html_report"
|
||||||
|
]
|
||||||
15
httprunner/report/html/__init__.py
Normal file
15
httprunner/report/html/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
HttpRunner html report
|
||||||
|
|
||||||
|
- result: define resultclass for unittest TextTestRunner
|
||||||
|
- gen_report: render html report with jinja2 template
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from httprunner.report.html.result import HtmlTestResult
|
||||||
|
from httprunner.report.html.gen_report import gen_html_report
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HtmlTestResult",
|
||||||
|
"gen_html_report"
|
||||||
|
]
|
||||||
57
httprunner/report/html/gen_report.py
Normal file
57
httprunner/report/html/gen_report.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
from httprunner import logger
|
||||||
|
|
||||||
|
|
||||||
|
def gen_html_report(summary, report_template=None, report_dir=None, report_file=None):
|
||||||
|
""" render html report with specified report name and template
|
||||||
|
|
||||||
|
Args:
|
||||||
|
summary (dict): test result summary data
|
||||||
|
report_template (str): specify html report template path, template should be in Jinja2 format.
|
||||||
|
report_dir (str): specify html report save directory
|
||||||
|
report_file (str): specify html report file path, this has higher priority than specifying report dir.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not report_template:
|
||||||
|
report_template = os.path.join(
|
||||||
|
os.path.abspath(os.path.dirname(__file__)),
|
||||||
|
"template.html"
|
||||||
|
)
|
||||||
|
logger.log_debug("No html report template specified, use default.")
|
||||||
|
else:
|
||||||
|
logger.log_info("render with html report template: {}".format(report_template))
|
||||||
|
|
||||||
|
logger.log_info("Start to render Html report ...")
|
||||||
|
|
||||||
|
start_at_timestamp = int(summary["time"]["start_at"])
|
||||||
|
summary["time"]["start_datetime"] = datetime.fromtimestamp(start_at_timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
if report_file:
|
||||||
|
report_dir = os.path.dirname(report_file)
|
||||||
|
report_file_name = os.path.basename(report_file)
|
||||||
|
else:
|
||||||
|
report_dir = report_dir or os.path.join(os.getcwd(), "reports")
|
||||||
|
report_file_name = "{}.html".format(start_at_timestamp)
|
||||||
|
|
||||||
|
if not os.path.isdir(report_dir):
|
||||||
|
os.makedirs(report_dir)
|
||||||
|
|
||||||
|
report_path = os.path.join(report_dir, report_file_name)
|
||||||
|
with io.open(report_template, "r", encoding='utf-8') as fp_r:
|
||||||
|
template_content = fp_r.read()
|
||||||
|
with io.open(report_path, 'w', encoding='utf-8') as fp_w:
|
||||||
|
rendered_content = Template(
|
||||||
|
template_content,
|
||||||
|
extensions=["jinja2.ext.loopcontrols"]
|
||||||
|
).render(summary)
|
||||||
|
fp_w.write(rendered_content)
|
||||||
|
|
||||||
|
logger.log_info("Generated Html report: {}".format(report_path))
|
||||||
|
|
||||||
|
return report_path
|
||||||
|
|
||||||
64
httprunner/report/html/result.py
Normal file
64
httprunner/report/html/result.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from httprunner import logger
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlTestResult(unittest.TextTestResult):
|
||||||
|
""" A html result class that can generate formatted html results.
|
||||||
|
Used by TextTestRunner.
|
||||||
|
"""
|
||||||
|
def __init__(self, stream, descriptions, verbosity):
|
||||||
|
super(HtmlTestResult, self).__init__(stream, descriptions, verbosity)
|
||||||
|
self.records = []
|
||||||
|
|
||||||
|
def _record_test(self, test, status, attachment=''):
|
||||||
|
data = {
|
||||||
|
'name': test.shortDescription(),
|
||||||
|
'status': status,
|
||||||
|
'attachment': attachment,
|
||||||
|
"meta_datas": test.meta_datas
|
||||||
|
}
|
||||||
|
self.records.append(data)
|
||||||
|
|
||||||
|
def startTestRun(self):
|
||||||
|
self.start_at = time.time()
|
||||||
|
|
||||||
|
def startTest(self, test):
|
||||||
|
""" add start test time """
|
||||||
|
super(HtmlTestResult, self).startTest(test)
|
||||||
|
logger.color_print(test.shortDescription(), "yellow")
|
||||||
|
|
||||||
|
def addSuccess(self, test):
|
||||||
|
super(HtmlTestResult, self).addSuccess(test)
|
||||||
|
self._record_test(test, 'success')
|
||||||
|
print("")
|
||||||
|
|
||||||
|
def addError(self, test, err):
|
||||||
|
super(HtmlTestResult, self).addError(test, err)
|
||||||
|
self._record_test(test, 'error', self._exc_info_to_string(err, test))
|
||||||
|
print("")
|
||||||
|
|
||||||
|
def addFailure(self, test, err):
|
||||||
|
super(HtmlTestResult, self).addFailure(test, err)
|
||||||
|
self._record_test(test, 'failure', self._exc_info_to_string(err, test))
|
||||||
|
print("")
|
||||||
|
|
||||||
|
def addSkip(self, test, reason):
|
||||||
|
super(HtmlTestResult, self).addSkip(test, reason)
|
||||||
|
self._record_test(test, 'skipped', reason)
|
||||||
|
print("")
|
||||||
|
|
||||||
|
def addExpectedFailure(self, test, err):
|
||||||
|
super(HtmlTestResult, self).addExpectedFailure(test, err)
|
||||||
|
self._record_test(test, 'ExpectedFailure', self._exc_info_to_string(err, test))
|
||||||
|
print("")
|
||||||
|
|
||||||
|
def addUnexpectedSuccess(self, test):
|
||||||
|
super(HtmlTestResult, self).addUnexpectedSuccess(test)
|
||||||
|
self._record_test(test, 'UnexpectedSuccess')
|
||||||
|
print("")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self):
|
||||||
|
return time.time() - self.start_at
|
||||||
216
httprunner/report/stringify.py
Normal file
216
httprunner/report/stringify.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
from base64 import b64encode
|
||||||
|
from collections import Iterable
|
||||||
|
|
||||||
|
from jinja2 import escape
|
||||||
|
from requests.cookies import RequestsCookieJar
|
||||||
|
|
||||||
|
from httprunner.compat import basestring, bytes, json, numeric_types, JSONDecodeError
|
||||||
|
|
||||||
|
|
||||||
|
def dumps_json(value):
|
||||||
|
""" dumps json value to indented string
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (dict): raw json data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: indented json dump string
|
||||||
|
|
||||||
|
"""
|
||||||
|
return json.dumps(value, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_encoding(value):
|
||||||
|
try:
|
||||||
|
return json.detect_encoding(value)
|
||||||
|
except AttributeError:
|
||||||
|
return "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
def __stringify_request(request_data):
|
||||||
|
""" stringfy HTTP request data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_data (dict): HTTP request data in dict.
|
||||||
|
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:5000/api/get-token",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"User-Agent": "python-requests/2.20.0",
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"user_agent": "iOS/10.3",
|
||||||
|
"device_sn": "TESTCASE_CREATE_XXX",
|
||||||
|
"os_platform": "ios",
|
||||||
|
"app_version": "2.8.6",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": "52"
|
||||||
|
},
|
||||||
|
"body": b'{"sign": "cb9d60acd09080ea66c8e63a1c78c6459ea00168"}',
|
||||||
|
"verify": false
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
for key, value in request_data.items():
|
||||||
|
|
||||||
|
if isinstance(value, (list, dict)):
|
||||||
|
value = dumps_json(value)
|
||||||
|
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
try:
|
||||||
|
encoding = detect_encoding(value)
|
||||||
|
value = value.decode(encoding)
|
||||||
|
if key == "body":
|
||||||
|
try:
|
||||||
|
# request body is in json format
|
||||||
|
value = json.loads(value)
|
||||||
|
value = dumps_json(value)
|
||||||
|
except JSONDecodeError:
|
||||||
|
pass
|
||||||
|
value = escape(value)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif not isinstance(value, (basestring, numeric_types, Iterable)):
|
||||||
|
# class instance, e.g. MultipartEncoder()
|
||||||
|
value = repr(value)
|
||||||
|
|
||||||
|
elif isinstance(value, RequestsCookieJar):
|
||||||
|
value = value.get_dict()
|
||||||
|
|
||||||
|
request_data[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def __stringify_response(response_data):
|
||||||
|
""" stringfy HTTP response data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response_data (dict):
|
||||||
|
|
||||||
|
{
|
||||||
|
"status_code": 404,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": "30",
|
||||||
|
"Server": "Werkzeug/0.14.1 Python/3.7.0",
|
||||||
|
"Date": "Tue, 27 Nov 2018 06:19:27 GMT"
|
||||||
|
},
|
||||||
|
"encoding": "None",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"ok": false,
|
||||||
|
"url": "http://127.0.0.1:5000/api/users/9001",
|
||||||
|
"reason": "NOT FOUND",
|
||||||
|
"cookies": {},
|
||||||
|
"body": {
|
||||||
|
"success": false,
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
for key, value in response_data.items():
|
||||||
|
|
||||||
|
if isinstance(value, (list, dict)):
|
||||||
|
value = dumps_json(value)
|
||||||
|
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
try:
|
||||||
|
encoding = response_data.get("encoding")
|
||||||
|
if not encoding or encoding == "None":
|
||||||
|
encoding = detect_encoding(value)
|
||||||
|
|
||||||
|
if key == "body" and "image" in response_data["content_type"]:
|
||||||
|
# display image
|
||||||
|
value = "data:{};base64,{}".format(
|
||||||
|
response_data["content_type"],
|
||||||
|
b64encode(value).decode(encoding)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
value = escape(value.decode(encoding))
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif not isinstance(value, (basestring, numeric_types, Iterable)):
|
||||||
|
# class instance, e.g. MultipartEncoder()
|
||||||
|
value = repr(value)
|
||||||
|
|
||||||
|
elif isinstance(value, RequestsCookieJar):
|
||||||
|
value = value.get_dict()
|
||||||
|
|
||||||
|
response_data[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def __expand_meta_datas(meta_datas, meta_datas_expanded):
|
||||||
|
""" expand meta_datas to one level
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meta_datas (dict/list): maybe in nested format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: expanded list in one level
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> meta_datas = [
|
||||||
|
[
|
||||||
|
dict1,
|
||||||
|
dict2
|
||||||
|
],
|
||||||
|
dict3
|
||||||
|
]
|
||||||
|
>>> meta_datas_expanded = []
|
||||||
|
>>> __expand_meta_datas(meta_datas, meta_datas_expanded)
|
||||||
|
>>> print(meta_datas_expanded)
|
||||||
|
[dict1, dict2, dict3]
|
||||||
|
|
||||||
|
"""
|
||||||
|
if isinstance(meta_datas, dict):
|
||||||
|
meta_datas_expanded.append(meta_datas)
|
||||||
|
elif isinstance(meta_datas, list):
|
||||||
|
for meta_data in meta_datas:
|
||||||
|
__expand_meta_datas(meta_data, meta_datas_expanded)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_total_response_time(meta_datas_expanded):
|
||||||
|
""" caculate total response time of all meta_datas
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response_time = 0
|
||||||
|
for meta_data in meta_datas_expanded:
|
||||||
|
response_time += meta_data["stat"]["response_time_ms"]
|
||||||
|
|
||||||
|
return "{:.2f}".format(response_time)
|
||||||
|
|
||||||
|
except TypeError:
|
||||||
|
# failure exists
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
def __stringify_meta_datas(meta_datas):
|
||||||
|
|
||||||
|
if isinstance(meta_datas, list):
|
||||||
|
for _meta_data in meta_datas:
|
||||||
|
__stringify_meta_datas(_meta_data)
|
||||||
|
elif isinstance(meta_datas, dict):
|
||||||
|
data_list = meta_datas["data"]
|
||||||
|
for data in data_list:
|
||||||
|
__stringify_request(data["request"])
|
||||||
|
__stringify_response(data["response"])
|
||||||
|
|
||||||
|
|
||||||
|
def stringify_summary(summary):
|
||||||
|
""" stringify summary, in order to dump json file and generate html report.
|
||||||
|
"""
|
||||||
|
for index, suite_summary in enumerate(summary["details"]):
|
||||||
|
|
||||||
|
if not suite_summary.get("name"):
|
||||||
|
suite_summary["name"] = "testcase {}".format(index)
|
||||||
|
|
||||||
|
for record in suite_summary.get("records"):
|
||||||
|
meta_datas = record['meta_datas']
|
||||||
|
__stringify_meta_datas(meta_datas)
|
||||||
|
meta_datas_expanded = []
|
||||||
|
__expand_meta_datas(meta_datas, meta_datas_expanded)
|
||||||
|
record["meta_datas_expanded"] = meta_datas_expanded
|
||||||
|
record["response_time"] = __get_total_response_time(meta_datas_expanded)
|
||||||
82
httprunner/report/summarize.py
Normal file
82
httprunner/report/summarize.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import platform
|
||||||
|
|
||||||
|
from httprunner import __version__
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform():
|
||||||
|
return {
|
||||||
|
"httprunner_version": __version__,
|
||||||
|
"python_version": "{} {}".format(
|
||||||
|
platform.python_implementation(),
|
||||||
|
platform.python_version()
|
||||||
|
),
|
||||||
|
"platform": platform.platform()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_stat(origin_stat, new_stat):
|
||||||
|
""" aggregate new_stat to origin_stat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
origin_stat (dict): origin stat dict, will be updated with new_stat dict.
|
||||||
|
new_stat (dict): new stat dict.
|
||||||
|
|
||||||
|
"""
|
||||||
|
for key in new_stat:
|
||||||
|
if key not in origin_stat:
|
||||||
|
origin_stat[key] = new_stat[key]
|
||||||
|
elif key == "start_at":
|
||||||
|
# start datetime
|
||||||
|
origin_stat["start_at"] = min(origin_stat["start_at"], new_stat["start_at"])
|
||||||
|
elif key == "duration":
|
||||||
|
# duration = max_end_time - min_start_time
|
||||||
|
max_end_time = max(origin_stat["start_at"] + origin_stat["duration"],
|
||||||
|
new_stat["start_at"] + new_stat["duration"])
|
||||||
|
min_start_time = min(origin_stat["start_at"], new_stat["start_at"])
|
||||||
|
origin_stat["duration"] = max_end_time - min_start_time
|
||||||
|
else:
|
||||||
|
origin_stat[key] += new_stat[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_summary(result):
|
||||||
|
""" get summary from test result
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result (instance): HtmlTestResult() instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: summary extracted from result.
|
||||||
|
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"stat": {},
|
||||||
|
"time": {},
|
||||||
|
"records": []
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
summary = {
|
||||||
|
"success": result.wasSuccessful(),
|
||||||
|
"stat": {
|
||||||
|
'total': result.testsRun,
|
||||||
|
'failures': len(result.failures),
|
||||||
|
'errors': len(result.errors),
|
||||||
|
'skipped': len(result.skipped),
|
||||||
|
'expectedFailures': len(result.expectedFailures),
|
||||||
|
'unexpectedSuccesses': len(result.unexpectedSuccesses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary["stat"]["successes"] = summary["stat"]["total"] \
|
||||||
|
- summary["stat"]["failures"] \
|
||||||
|
- summary["stat"]["errors"] \
|
||||||
|
- summary["stat"]["skipped"] \
|
||||||
|
- summary["stat"]["expectedFailures"] \
|
||||||
|
- summary["stat"]["unexpectedSuccesses"]
|
||||||
|
|
||||||
|
summary["time"] = {
|
||||||
|
'start_at': result.start_at,
|
||||||
|
'duration': result.duration
|
||||||
|
}
|
||||||
|
summary["records"] = result.records
|
||||||
|
|
||||||
|
return summary
|
||||||
32
poetry.lock
generated
32
poetry.lock
generated
@@ -174,6 +174,32 @@ version = "0.9.1"
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
requests = ">=2.0.1,<3.0.0"
|
requests = ">=2.0.1,<3.0.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "main"
|
||||||
|
description = "Python client for Sentry (https://getsentry.com)"
|
||||||
|
name = "sentry-sdk"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
version = "0.13.5"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
certifi = "*"
|
||||||
|
urllib3 = ">=1.10.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
aiohttp = ["aiohttp (>=3.5)"]
|
||||||
|
beam = ["beam (>=2.12)"]
|
||||||
|
bottle = ["bottle (>=0.12.13)"]
|
||||||
|
celery = ["celery (>=3)"]
|
||||||
|
django = ["django (>=1.8)"]
|
||||||
|
falcon = ["falcon (>=1.4)"]
|
||||||
|
flask = ["flask (>=0.11)", "blinker (>=1.1)"]
|
||||||
|
pyspark = ["pyspark (>=2.4.4)"]
|
||||||
|
rq = ["0.6"]
|
||||||
|
sanic = ["sanic (>=0.8)"]
|
||||||
|
sqlalchemy = ["sqlalchemy (>=1.2)"]
|
||||||
|
tornado = ["tornado (>=5)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
category = "main"
|
category = "main"
|
||||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||||
@@ -201,7 +227,7 @@ termcolor = ["termcolor"]
|
|||||||
watchdog = ["watchdog"]
|
watchdog = ["watchdog"]
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
content-hash = "8963771b32271f9cfbec299fdccfd46f8847a4817c1d2083ca451ccff81646cb"
|
content-hash = "3a262aa9fb64682ee5fcecc0e72249d0a549b78c697fce7bbabc79648d615fef"
|
||||||
python-versions = "~2.7 || ^3.5"
|
python-versions = "~2.7 || ^3.5"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
@@ -340,6 +366,10 @@ requests-toolbelt = [
|
|||||||
{file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"},
|
{file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"},
|
||||||
{file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"},
|
{file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"},
|
||||||
]
|
]
|
||||||
|
sentry-sdk = [
|
||||||
|
{file = "sentry-sdk-0.13.5.tar.gz", hash = "sha256:c6b919623e488134a728f16326c6f0bcdab7e3f59e7f4c472a90eea4d6d8fe82"},
|
||||||
|
{file = "sentry_sdk-0.13.5-py2.py3-none-any.whl", hash = "sha256:05285942901d38c7ce2498aba50d8e87b361fc603281a5902dda98f3f8c5e145"},
|
||||||
|
]
|
||||||
urllib3 = [
|
urllib3 = [
|
||||||
{file = "urllib3-1.25.7-py2.py3-none-any.whl", hash = "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293"},
|
{file = "urllib3-1.25.7-py2.py3-none-any.whl", hash = "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293"},
|
||||||
{file = "urllib3-1.25.7.tar.gz", hash = "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"},
|
{file = "urllib3-1.25.7.tar.gz", hash = "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "httprunner"
|
name = "httprunner"
|
||||||
version = "2.4.4"
|
version = "2.4.5"
|
||||||
description = "One-stop solution for HTTP(S) testing."
|
description = "One-stop solution for HTTP(S) testing."
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -41,6 +41,7 @@ colorlog = "^4.0.2"
|
|||||||
filetype = "^1.0.5"
|
filetype = "^1.0.5"
|
||||||
jsonpath = "^0.82"
|
jsonpath = "^0.82"
|
||||||
future = { version = "^0.18.1", python = "~2.7" }
|
future = { version = "^0.18.1", python = "~2.7" }
|
||||||
|
sentry-sdk = "^0.13.5"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
flask = "<1.0.0"
|
flask = "<1.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user